StackPractices
intermediate Por Mathias Paulenko

Comprimir y Descomprimir Archivos con ZIP, GZIP y TAR

Crea y extrae archivos ZIP, GZIP y TAR en Python, Node.js, Java y Bash con lectura en streaming y patrones de extracción segura.

Visión General

Una tarea nocturna tiene que empaquetar tres semanas de logs de la aplicación en un solo archivo para un auditor, y la exportación crece a 2 GB sin comprimir. Cada runtime ya trae una biblioteca de archivado, pero los comportamientos por defecto difieren en cosas que importan: ZIP comprime cada entrada por separado, así que puedes extraer un archivo sin desempaquetar el resto; TAR solo concatena y necesita un compresor externo; y GZIP maneja exactamente un stream. Esta receta cubre los tres formatos en Python, Node.js, Java y Bash, incluyendo el código de streaming y extracción segura que un snippet básico de ZipFile deja fuera.

Cuándo Usar

Usa este recurso cuando:

  • Empaquetas paquetes de logs, exportaciones de reportes o artefactos de build para descarga
  • Comprimes respuestas HTTP o payloads para reducir ancho de banda (ver compresión con Gzip y Brotli para el ajuste específico de HTTP)
  • Extraes archivos subidos por usuarios en aplicaciones web
  • Rotas y archivas logs en disco (ver rotación de logs en bash)

Omite la compresión programática cuando:

  • El payload ya está comprimido (JPEG, MP4, .zip, .parquet): gastas CPU sin ganar nada
  • Los archivos son diminutos y el endpoint es sensible a latencia; por debajo de ~1 KB, gzip puede producir una salida mayor que la entrada
  • Necesitas acceso aleatorio frecuente al contenido del archivo; eso pide una base de datos o un almacén de objetos, no un zip más grande

Requisitos

  • Python 3.9+ (zipfile, gzip, tarfile, shutil en la biblioteca estándar; is_relative_to requiere 3.9, compresslevel en tarfile requiere 3.12)
  • Node.js 18+ con npm install archiver extract-zip para los ejemplos de ZIP (zlib viene incluido)
  • JDK 11+ (java.util.zip es biblioteca estándar)
  • pigz opcional, para gzip paralelo en la sección de Bash
  • pyminizip / Zip4j solo si necesitas ZIPs cifrados (ver el FAQ)

Código companion: el repo companion de compress-decompress-files tiene estos ejemplos como archivos ejecutables, incluidos los helpers de extracción segura.

Solución

Python

import zipfile
import gzip
import tarfile

# Archivo ZIP
with zipfile.ZipFile('archive.zip', 'w', zipfile.ZIP_DEFLATED) as z:
    z.write('file.txt')

# Archivo GZIP
with open('file.txt', 'rb') as f_in:
    with gzip.open('file.txt.gz', 'wb') as f_out:
        f_out.writelines(f_in)

# Archivo TAR
with tarfile.open('archive.tar.gz', 'w:gz') as tar:
    tar.add('data/')

JavaScript

const fs = require('fs');
const zlib = require('zlib');
const archiver = require('archiver');

// Comprimir GZIP
const input = fs.createReadStream('file.txt');
const output = fs.createWriteStream('file.txt.gz');
input.pipe(zlib.createGzip()).pipe(output);

// Archivo ZIP
const archive = archiver('zip', { zlib: { level: 9 } });
archive.pipe(fs.createWriteStream('archive.zip'));
archive.file('file.txt', { name: 'file.txt' });
archive.finalize();

Java

import java.io.*;
import java.util.zip.*;

public class Compressor {
    // Comprimir GZIP
    public void gzip(String src, String dest) throws IOException {
        try (FileInputStream fis = new FileInputStream(src);
             FileOutputStream fos = new FileOutputStream(dest);
             GZIPOutputStream gzos = new GZIPOutputStream(fos)) {
            fis.transferTo(gzos);
        }
    }

    // Archivo ZIP
    public void zip(String src, String dest) throws IOException {
        try (FileOutputStream fos = new FileOutputStream(dest);
             ZipOutputStream zos = new ZipOutputStream(fos);
             FileInputStream fis = new FileInputStream(src)) {
            zos.putNextEntry(new ZipEntry(new File(src).getName()));
            fis.transferTo(zos);
            zos.closeEntry();
        }
    }
}

Explicación

Los tres formatos resuelven problemas distintos. ZIP comprime cada entrada de forma independiente y guarda un directorio central al final del archivo; por eso puedes leer un miembro sin descomprimir el archivo entero — y también por eso un ZIP truncado puede perder el índice aunque los datos estén intactos. GZIP envuelve un único stream en compresión DEFLATE más un checksum CRC32; no sabe de nombres de archivo ni directorios. TAR concatena archivos con sus metadatos y no aplica compresión alguna, por lo que .tar siempre va emparejado con .gz, .bz2 o .xz en sistemas Unix.

Los tres usan DEFLATE por defecto (LZ77 + codificación Huffman), rápido y eficaz con texto (típicamente 60–80% de reducción en logs y código fuente). Sobre datos ya comprimidos casi no hace nada, y el algoritmo es anterior a los procesadores multinúcleo: para trabajo en masa, pigz paraleliza gzip manteniendo el formato, y Zstandard (zstd) da mejores ratios a velocidad comparable pero produce un formato distinto.

Elegir formato es sobre todo una cuestión de quién consume el archivo, no de qué lo comprime:

flowchart diagram: ¿Qué estás empaquetando?

La extracción es la parte peligrosa

Leer archivos comprimidos es más arriesgado que escribirlos. Un ZIP malicioso puede llevar rutas como ../../etc/cron.d/evil que escapan del directorio de destino al extraer — es el ataque zip-slip. Además, un chequeo ingenuo de dest / entrada con prefijos de texto es explotable: un miembro que resuelve a /tmp/out-evil/x pasa un test de startswith("/tmp/out"). Compara siempre rutas resueltas componente a componente (Path.is_relative_to en Python, Path.startsWith sobre objetos Path en Java, path.relative en Node), y limita el tamaño total descomprimido para parar zip bombs. Para un recorrido más profundo, ver extracción segura de ZIP en Python.

Uso Avanzado

Python: compresión en streaming y extracción segura

import zipfile
import gzip
import tarfile
from pathlib import Path

def compress_directory_streaming(src_dir: str, dest_zip: str,
                                 level: int = 6) -> int:
    """Comprime un directorio a ZIP entrada por entrada. Retorna el conteo."""
    src_path = Path(src_dir)
    file_count = 0
    with zipfile.ZipFile(dest_zip, 'w', zipfile.ZIP_DEFLATED,
                         compresslevel=level) as zf:
        for file_path in sorted(src_path.rglob('*')):
            if file_path.is_file():
                zf.write(file_path, file_path.relative_to(src_path))
                file_count += 1
    return file_count

def decompress_zip_safe(zip_path: str, dest_dir: str,
                        max_size: int = 1 << 30) -> int:
    """Extrae un ZIP con protección contra zip-slip y zip bombs."""
    dest_path = Path(dest_dir).resolve()
    dest_path.mkdir(parents=True, exist_ok=True)
    total = 0
    count = 0
    with zipfile.ZipFile(zip_path, 'r') as zf:
        for info in zf.infolist():
            member = info.filename
            member_path = (dest_path / member).resolve()
            # is_relative_to compara componentes de ruta, no prefijos de
            # texto, así que "/tmp/out-evil/x" no pasa un chequeo de "/tmp/out"
            if not member_path.is_relative_to(dest_path):
                raise ValueError(f"Ruta insegura detectada: {member}")
            total += info.file_size
            if total > max_size:
                raise ValueError(f"El archivo excede {max_size} bytes")
            zf.extract(member, dest_path)
            count += 1
    return count

def gzip_file_streaming(src: str, dest: str, level: int = 6) -> None:
    """Comprime un archivo con GZIP en bloques de 64 KB."""
    with open(src, 'rb') as f_in, gzip.open(dest, 'wb',
                                           compresslevel=level) as f_out:
        while chunk := f_in.read(65536):
            f_out.write(chunk)

def tar_directory(src_dir: str, dest: str, mode: str = 'w:gz') -> None:
    """Crea un archivo TAR.GZ de un directorio."""
    with tarfile.open(dest, mode) as tar:
        tar.add(src_dir, arcname=Path(src_dir).name, recursive=True)

def list_archive_contents(archive_path: str) -> list[str]:
    """Lista el contenido de un archivo ZIP o TAR."""
    if archive_path.endswith('.zip'):
        with zipfile.ZipFile(archive_path, 'r') as zf:
            return zf.namelist()
    elif archive_path.endswith(('.tar.gz', '.tgz', '.tar')):
        with tarfile.open(archive_path, 'r:*') as tar:
            return tar.getnames()
    raise ValueError(f"Formato de archivo no soportado: {archive_path}")

# Uso
# count = compress_directory_streaming('logs/', 'logs.zip', level=6)
# print(f"Comprimidos {count} archivos")
# extracted = decompress_zip_safe('upload.zip', 'extracted/')
# print(f"Extraídos {extracted} archivos de forma segura")

Node.js: pipelines en streaming con zlib

const fs = require('fs');
const zlib = require('zlib');
const { pipeline } = require('stream');
const { promisify } = require('util');
const pipe = promisify(pipeline);

async function gzipFile(srcPath, destPath, level = 6) {
    const src = fs.createReadStream(srcPath);
    const gzip = zlib.createGzip({ level });
    const dest = fs.createWriteStream(destPath);
    await pipe(src, gzip, dest);   // rechaza si cualquier etapa falla
}

async function gunzipFile(srcPath, destPath) {
    const src = fs.createReadStream(srcPath);
    const gunzip = zlib.createGunzip();
    const dest = fs.createWriteStream(destPath);
    await pipe(src, gunzip, dest);
}

async function gzipDirectory(srcDir, destZip) {
    const archiver = require('archiver');
    const output = fs.createWriteStream(destZip);
    const archive = archiver('zip', { zlib: { level: 6 } });

    const done = new Promise((resolve, reject) => {
        output.on('close', () => resolve(archive.pointer()));
        output.on('error', reject);
        archive.on('error', reject);
    });

    archive.pipe(output);
    archive.directory(srcDir, false);
    archive.finalize();

    return await done;  // resuelve con los bytes escritos
}

async function extractZipSafe(zipPath, destDir) {
    const extract = require('extract-zip');
    const path = require('path');
    const destRoot = path.resolve(destDir);

    await extract(zipPath, {
        dir: destRoot,
        onEntry: (entry) => {
            // path.relative devuelve "../..." para escapes; un chequeo de
            // startsWith de texto dejaría pasar prefijos hermanos
            const rel = path.relative(destRoot, path.resolve(destRoot, entry.fileName));
            if (rel.startsWith('..') || path.isAbsolute(rel)) {
                throw new Error(`Ruta insegura en archivo: ${entry.fileName}`);
            }
        },
    });
}

// Uso
// gzipFile('large.log', 'large.log.gz', 9);
// const bytes = await gzipDirectory('logs/', 'logs.zip');
// console.log(`Tamaño del archivo: ${bytes} bytes`);
// await extractZipSafe('upload.zip', 'extracted/');

Java: compresión batch con try-with-resources

import java.io.*;
import java.nio.file.*;
import java.util.zip.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Stream;

public class BatchCompressor {

    // Comprime un archivo con GZIP
    public static void gzipFile(Path src, Path dest, int bufferSize) throws IOException {
        try (InputStream fis = Files.newInputStream(src);
             OutputStream fos = Files.newOutputStream(dest);
             GZIPOutputStream gzos = new GZIPOutputStream(fos, bufferSize)) {
            fis.transferTo(gzos);
        }
    }

    // Comprime varios archivos a ZIP con streaming
    public static int zipFiles(List<Path> sources, Path destZip) throws IOException {
        int count = 0;
        try (OutputStream fos = Files.newOutputStream(destZip);
             ZipOutputStream zos = new ZipOutputStream(fos)) {
            for (Path src : sources) {
                zos.putNextEntry(new ZipEntry(src.getFileName().toString()));
                try (InputStream fis = Files.newInputStream(src)) {
                    fis.transferTo(zos);
                }
                zos.closeEntry();
                count++;
            }
        }
        return count;
    }

    // Extrae un ZIP con protección contra zip-slip
    public static int extractZipSafe(Path zipPath, Path destDir) throws IOException {
        Files.createDirectories(destDir);
        int count = 0;
        try (InputStream fis = Files.newInputStream(zipPath);
             ZipInputStream zis = new ZipInputStream(fis)) {
            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                // Path.startsWith compara componentes de nombre, así que un
                // prefijo hermano como "out-evil" no pasa un chequeo de "out"
                Path destFile = destDir.resolve(entry.getName()).normalize();
                if (!destFile.startsWith(destDir)) {
                    throw new IOException("Entrada zip insegura: " + entry.getName());
                }
                if (entry.isDirectory()) {
                    Files.createDirectories(destFile);
                } else {
                    Files.createDirectories(destFile.getParent());
                    Files.copy(zis, destFile, StandardCopyOption.REPLACE_EXISTING);
                }
                count++;
            }
        }
        return count;
    }

    // Comprime todos los archivos de un directorio
    public static int compressDirectory(Path srcDir, Path destZip) throws IOException {
        List<Path> files = new ArrayList<>();
        try (Stream<Path> stream = Files.walk(srcDir)) {
            stream.filter(Files::isRegularFile).forEach(files::add);
        }
        return zipFiles(files, destZip);
    }
}

// Uso
// BatchCompressor.gzipFile(Path.of("large.log"), Path.of("large.log.gz"), 8192);
// int count = BatchCompressor.compressDirectory(Path.of("logs/"), Path.of("logs.zip"));
// int extracted = BatchCompressor.extractZipSafe(Path.of("upload.zip"), Path.of("extracted/"));

Bash: tar/gzip con chequeos de seguridad y compresión paralela

#!/usr/bin/env bash
set -euo pipefail

# Comprime un directorio a tar.gz
compress_tarball() {
    local src="$1"
    local dest="$2"
    local level="${3:-6}"
    tar -c -C "$(dirname "$src")" "$(basename "$src")" \
        | gzip -"$level" -c > "$dest"
    echo "Creado $dest ($(du -h "$dest" | cut -f1))"
}

# Extrae un tarball de forma segura: rechaza rutas absolutas y escapes ../
extract_tarball_safe() {
    local archive="$1"
    local dest="$2"
    mkdir -p "$dest"
    if tar -tf "$archive" | grep -E '^/|\.\.'; then
        echo "Error: rutas inseguras detectadas en $archive" >&2
        return 1
    fi
    tar -xzf "$archive" -C "$dest"
    echo "Extraído a $dest"
}

# gzip paralelo con pigz — más rápido en multinúcleo, mismo formato
compress_parallel() {
    local src="$1"
    local dest="$2"
    if command -v pigz &>/dev/null; then
        tar -c -C "$(dirname "$src")" "$(basename "$src")" | pigz -p 4 > "$dest"
    else
        tar -czf "$dest" -C "$(dirname "$src")" "$(basename "$src")"
    fi
    echo "Creado $dest"
}

# Comprime cada archivo de un directorio individualmente
batch_gzip() {
    local dir="$1"
    local count=0
    for file in "$dir"/*; do
        [[ -f "$file" ]] || continue
        gzip -c "$file" > "${file}.gz"
        ((count++))
    done
    echo "Comprimidos $count archivos en $dir"
}

# Uso
# compress_tarball logs/ logs.tar.gz 6
# extract_tarball_safe archive.tar.gz extracted/
# compress_parallel data/ data.tar.gz
# batch_gzip /var/log/app/

Variantes

TecnologíaEnfoqueNotas
Pythonshutil.make_archive()Crea ZIP/TAR en una sola llamada; sin streaming ni control por entrada
Node.jsadm-zipManipulación de ZIP en memoria; cómodo pero carga todo en el búfer
JavaApache Commons CompressAñade BZIP2, LZMA, 7Z y más formatos fuera de java.util.zip
Todoszstd / 7z CLIMejores ratios que DEFLATE; son formatos distintos, no intercambiables

Lo Que Funciona

  1. Procesa archivos en streaming en lugar de cargarlos en memoria: transferTo, pipeline y los bloques with/try mantienen la memoria plana sin importar el tamaño de entrada
  2. Usa el nivel de compresión 6 como valor predeterminado equilibrado; el nivel 9 cuesta bastante más CPU por ganancias de un dígito sobre texto
  3. Valida cada ruta extraída componente a componente y limita el tamaño total descomprimido; zip-slip y zip bombs son ataques del lado del archivo
  4. Prefiere GZIP para streams únicos y ZIP/TAR para paquetes; elige el formato según quién lo extrae, no según qué biblioteca tengas más cerca
  5. Lista el contenido de archivos subidos por usuarios antes de extraerlos (tar -tf, zf.namelist())

Errores Comunes

  1. Cargar archivos enteros en memoria cuando todas las bibliotecas soportan streaming
  2. Comprobar rutas de extracción con startswith de texto: un directorio hermano como dest-evil lo elude
  3. Olvidar finalize() o closeEntry(), produciendo archivos truncados o corruptos
  4. Comprimir formatos ya comprimidos (JPEG, MP4, otros archivos): CPU desperdiciada y a veces una salida mayor
  5. Ignorar la codificación de nombres de archivo cuando los paquetes cruzan plataformas — ZIP es anterior a UTF-8, y los nombres con caracteres no ASCII se corrupten entre Windows y Linux

Lectura Adicional

Preguntas frecuentes

¿Qué formato debo usar?

GZIP para archivos únicos y payloads HTTP, ZIP para paquetes multi-archivo que se consumen en Windows, TAR.GZ para lo que sea en Unix. Ante la duda, fíjate en quién abre el archivo — ZIP tiene soporte nativo en el Explorador de Windows, mientras que .tar.gz no necesita herramientas extra en Linux.

¿Cómo manejo archivos muy grandes?

En streaming. Cada ejemplo anterior procesa una entrada cada vez, así que un archivo de 10 GB usa aproximadamente un búfer de memoria. Para trabajo masivo de escritura, pigz paraleliza gzip entre núcleos; para lectura intensiva, el directorio central de ZIP permite extraer un solo miembro directamente.

¿Es segura la compresión ZIP?

Compresión no es cifrado. ZipCrypto estándar es lo bastante débil para romperse en minutos; si necesitas confidencialidad, usa ZIPs cifrados con AES vía pyminizip (Python) o Zip4j (Java), o cifra externamente con GPG. Trata también la extracción como superficie de ataque: valida rutas y limita tamaños en todo lo que suba un usuario.

¿Qué nivel de compresión debo elegir?

El nivel 6 es el predeterminado correcto. Los niveles 1-3 sacrifican bastante ratio por velocidad; los niveles 7-9 gastan entre un 30 y un 50% más de CPU por unos pocos puntos extra de compresión sobre texto típico. Baja de 6 solo en rutas sensibles a latencia y sube a 9 solo para datos que se comprimen una vez y se descargan muchas.

¿Puedo parar una zip bomb?

Limita el tamaño total descomprimido durante la extracción, como hace decompress_zip_safe con max_size. Una zip bomb son unos pocos kilobytes de entrada que se expanden a gigabytes de salida; comprobar file_size por entrada antes de escribir la derrota.