ADRIANOLAUREANO← Artigos

Construindo um compactador de arquivos do zero em Rust

Aprenda como vários arquivos podem ser armazenados dentro de apenas um, criando seu próprio formato de arquivamento em Rust.

UM ÚNICO ARQUIVOprojeto.lzipíndice + dados + integridade
Três arquivos continuam existindo — agora organizados dentro de um contêiner.
16 Bcabeçalho fixo
64 bitsoffsets e tamanhos
4 comandoscriar, listar, extrair, validar
0 cratesdependências externas

01 / A PERGUNTA

Como um único arquivo consegue guardar centenas de outros arquivos?

Como um jogo guarda milhares de imagens, sons e mapas? Como um APK chega ao celular como um único download? Como um documento DOCX, com textos, estilos, imagens e configurações, parece ser apenas um arquivo? E como um compactador consegue listar tudo quase instantaneamente?

A resposta começa com uma ideia muito mais simples do que parece: colocar os bytes de um arquivo depois dos bytes do outro. O desafio verdadeiro não é juntar. É conseguir separar novamente. Para isso, precisamos guardar um mapa dizendo onde cada arquivo começa, quanto ocupa e qual nome deverá recuperar.

A IDEIA CENTRAL

Um arquivador é uma sequência de dados acompanhada por um índice que explica essa sequência.

Isso aparece em ZIP, TAR, PAK e muitos formatos proprietários. JAR, APK e EPUB usam ZIP como contêiner e acrescentam regras sobre o que deve existir lá dentro. Nosso objetivo não é copiar essas especificações. Vamos construir uma versão pequena o bastante para enxergar cada decisão.

02 / O PROBLEMA

Uma pasta é ótima no computador. Para distribuir, ela é uma coleção de entregas.

Imagine um projeto com três arquivos. texto.txt contém uma mensagem;sprite.bin guarda pixels de um personagem; config.jsondefine opções. No sistema de arquivos, a pasta organiza os nomes:

PROJETO/
TXTtexto.txt120 bytes
BINsprite.bin2.048 bytes
JSONconfig.json90 bytes

Para enviar a pasta por um protocolo que espera um arquivo, precisaríamos mandar três itens, preservar seus nomes e explicar que pertencem ao mesmo projeto. Se a transferência parar no segundo item, o destinatário fica com um conjunto incompleto. Se dois arquivos tiverem versões diferentes, talvez o projeto não funcione.

Um contêiner transforma a coleção em uma unidade. Copiar, baixar, verificar hash e versionar ficam mais simples. A pasta não deixa de existir como conceito; sua estrutura é representada dentro do arquivo.

03 / A PRIMEIRA SOLUÇÃO

Vamos concatenar os dados. Em seguida descobriremos o que ainda falta.

A primeira tentativa poderia ser: ler texto.txt, depoissprite.bin, depois config.json e gravar tudo nessa ordem. Os bytes caberiam em projeto.lzip. Mas, ao abrir o resultado, onde termina o texto? Onde começa o sprite? Como sabemos o nome do terceiro item?

Se o texto sempre tivesse 120 bytes, poderíamos decorar esse número. Mas arquivos variam. Procurar um caractere separador também falha: qualquer sequência escolhida pode aparecer legitimamente em um binário. Precisamos guardar tamanhos de forma inequívoca.

DADOS

O conteúdo original

Os bytes que pertencem a cada arquivo.

METADADOS

Como reencontrá-los

Nome, posição, tamanho e checksum.

Essa distinção é fundamental. Dados são o que o usuário quer guardar. Metadados descrevem como interpretar, localizar e validar esses dados. Um índice é uma coleção organizada de metadados.

04 / O ÍNDICE DE ARQUIVOS

O índice é o catálogo que transforma bytes anônimos em arquivos localizáveis.

Para cada item, guardaremos nome, offset, tamanho armazenado, tamanho original e checksum. Na primeira versão não existe compressão, então os dois tamanhos são iguais. Manter ambos desde agora reserva uma evolução natural.

NOMEOFFSETTAMANHOCHECKSUM
texto.txt160120A13F…
sprite.bin280204882C1…
config.json23289077D0…

Por que o primeiro offset não é zero? Porque os bytes iniciais pertencem ao cabeçalho e ao próprio índice. Os dados começam depois deles. O valor exato muda conforme a quantidade de arquivos e o comprimento de cada nome.

Um leitor carrega o índice uma vez. Quando alguém pede sprite.bin, procura o nome, encontra offset 280 e tamanho 2.048, depois lê apenas[280..2328]. Ele não precisa decodificar texto.txt.

O índice pode ficar no começo ou no fim?

Pode. Colocá-lo no começo, como faremos, torna a leitura intuitiva: abrimos o arquivo, encontramos o catálogo e só então buscamos os dados. O preço é que o writer precisa calcular todos os nomes e offsets antes de gravar o primeiro conteúdo. Se uma entrada mudar de tamanho, os offsets seguintes também mudam.

Colocar o índice no fim inverte a vantagem. O programa pode despejar os arquivos um após o outro enquanto anota suas posições e escrever o catálogo por último. Porém, o leitor precisa saber onde esse catálogo começa. Uma solução comum é guardar no rodapé um marcador e a distância até o índice. Assim ele salta para o final, encontra o rodapé e volta exatamente até o catálogo.

Há ainda formatos sequenciais: cada registro carrega seu próprio nome e tamanho imediatamente antes dos dados. Eles são excelentes para transmitir um fluxo que ainda está sendo produzido. Em troca, localizar o centésimo item pode exigir passar pelos 99 anteriores. Nenhuma organização é universalmente melhor; cada uma otimiza um tipo de uso.

DECISÃO DA VERSÃO 1

Índice no começo: mais fácil de desenhar, inspecionar em hexadecimal e explicar. Evoluções podem criar outro layout sem reescrever a ideia central.

05 / O QUE É UM OFFSET?

Offset é uma distância a partir do início — não um endereço mágico.

Imagine o arquivo como uma fita numerada. O primeiro byte está no offset 0, o segundo no 1 e assim por diante. Offset 500 significa: “avance quinhentos bytes a partir do começo”. Ele não aponta para uma posição fixa da memória RAM e continua válido mesmo quando o arquivo é aberto em outro computador.

Offset e tamanho formam uma fatia. Se um item começa em 500 e ocupa 30 bytes, seu intervalo é 500..530. O início está incluído; o fim não. Essa convenção combina com fatias de Rust e evita somar ou subtrair um de maneira confusa.

REGRA DE SEGURANÇA

Antes de recortar bytes[offset..offset+tamanho], prove que a soma não estoura e que o fim cabe no arquivo.

Em disco, o sistema operacional pode realizar um seek: reposicionar o cursor de leitura. Em memória, usamos índices de uma fatia. Nos dois casos, a informação do índice evita percorrer todo o conteúdo anterior.

Offset, ponteiro e cursor não são a mesma coisa

Um offset é um número persistente: uma distância definida pelo formato. Um ponteiro é um endereço válido dentro da memória de um processo naquele momento. Um cursor é o estado atual de uma leitura: “estou no byte 37”. Misturar essas três ideias é uma fonte clássica de bugs em parsers.

Suponha que o sistema carregue o LZIP no endereço de memória0x7F00. Uma entrada com offset 500 estará, naquela execução, perto de0x7F00 + 500. Em outra execução, o endereço inicial pode mudar, mas o offset continua 500. Por isso gravamos offsets no arquivo, nunca endereços de memória.

Também precisamos combinar de onde a distância é medida. Nosso formato usa offsets absolutos a partir do primeiro byte do LZIP. Poderíamos usar offsets relativos ao início da área de dados, mas todo leitor teria de somardata_start. O importante não é a escolha isolada; é documentá-la e aplicá-la de modo idêntico no writer e no reader.

Acesso instantâneo significa “sem varrer os dados”

O disco ainda precisa buscar e transferir os bytes. O ganho é algorítmico: o programa não precisa interpretar o conteúdo anterior para descobrir onde o item começa. Depois de indexar os nomes em um HashMap, a localização de uma entrada costuma ser praticamente constante, seguida de uma leitura direta da região necessária.

06 / A ESPECIFICAÇÃO LZIP

Antes do código, precisamos de um contrato que duas implementações possam seguir.

Nosso arquivo terá cinco regiões: cabeçalho fixo, índice variável, dados concatenados e checksum final. Cada entrada do índice possui uma parte fixa de 32 bytes seguida pelos bytes UTF-8 do nome.

Mapa completo de um arquivo .lzip
0–15HEADERidentidade e contagem
16…ÍNDICEentradas variáveis
DADOSarquivos concatenados
OFFSETTAMANHOCAMPOREGRA
0x004MagicASCII LZIP
0x041Versão1
0x051Flagszero nesta versão
0x062Arquivosu16 little endian
0x084Índicetamanho total em bytes
0x0C4Reservadozeros

Cada entrada do índice

ORDEMTAMANHOCAMPOFUNÇÃO
12Nomequantidade de bytes UTF-8
22Flagsreservadas por entrada
38Offsetinício dos dados
48Armazenadobytes dentro do LZIP
58Originalbytes depois de descomprimir
64ChecksumFNV-1a dos dados
7variávelNome UTF-81 a 255 bytes

Os números usam little endian e tamanhos explícitos. Offset e tamanhos usamu64, portanto o arquivo não depende de ponteiros de 32 ou 64 bits da máquina. A versão 1 permite até 4.096 entradas e nomes de até 255 bytes.

Nosso magic LZIP identifica o contrato, mas não significa compatibilidade com ZIP. A extensão .lzip é uma convenção do projeto. Um programa sério deve conferir os quatro bytes antes de interpretar o restante.

Little endian: em que ordem os bytes de um número aparecem?

Um byte só representa de 0 a 255. Para guardar o número 1.000, precisamos de mais de um. Em hexadecimal, 1.000 é 0x03E8. Em little endian gravamos o pedaço menos significativo primeiro: E8 03. Em big endian seria03 E8. Os dois representam o mesmo valor; são apenas convenções de ordem.

Rust torna a decisão visível com to_le_bytes efrom_le_bytes. Isso é melhor do que depender da arquitetura da máquina. Um LZIP criado hoje em um notebook deve continuar legível em outro processador no futuro.

Por que há campos reservados?

Formatos vivem mais que o primeiro programa que os escreve. Os quatro bytes reservados do cabeçalho devem ser zero na versão 1, mas deixam espaço para uma necessidade futura sem deslocar todas as regiões seguintes. Flags cumprem função parecida: cada bit pode anunciar uma capacidade, como compressão ou criptografia.

Reservar espaço não elimina versionamento. Se mudarmos o significado de campos ou criarmos uma estrutura incompatível, incrementaremos a versão. O leitor da versão 1 deve recusar a versão 2 que não entende, em vez de “tentar” e produzir dados silenciosamente errados.

Alinhamento e padding

CPUs às vezes preferem números em endereços múltiplos de quatro ou oito. Um formato pode inserir padding — bytes sem conteúdo — para alinhar regiões. Nosso LZIP não precisa disso: os campos são lidos como sequências de bytes e reconstruídos explicitamente. Se adicionássemos alinhamento, esses bytes teriam de aparecer na especificação e no cálculo dos offsets. Padding invisível da memória de uma struct nunca deve vazar para o disco por acidente.

07 / ORGANIZANDO O PROJETO RUST

Cada módulo conhece uma etapa. Nenhum precisa entender tudo ao mesmo tempo.

lzip/
├── Cargo.toml
├── README.md
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── archive.rs
│   ├── checksum.rs
│   ├── error.rs
│   ├── writer.rs
│   ├── reader.rs
│   └── extract.rs
└── tests/
    └── archive_roundtrip.rs

archive.rs define o contrato e os tipos em memória.writer.rs recebe caminhos e produz bytes. reader.rsvalida bytes e reconstrói o índice. extract.rs grava os itens de volta ao disco. checksum.rs implementa integridade.main.rs converte argumentos de linha de comando em chamadas.

pub const MAGIC: [u8; 4] = *b"LZIP";
pub const VERSION: u8 = 1;
pub const HEADER_SIZE: usize = 16;
pub const ENTRY_FIXED_SIZE: usize = 32;
pub const FOOTER_SIZE: usize = 4;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
    pub name: String,
    pub offset: u64,
    pub stored_size: u64,
    pub original_size: u64,
    pub checksum: u32,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Archive {
    pub entries: Vec<Entry>,
    pub bytes: Vec<u8>,
}

Entry é uma linha do índice já interpretada. O nome é umaString; os três valores de posição e tamanho são u64; o checksum é u32. Archive mantém as entradas e todos os bytes originais.

Guardar os bytes junto ao catálogo permite que data(entry) devolva uma fatia sem copiar. A fatia apenas aponta para a região correta doVec<u8>. Enquanto o Archive existir, essa memória continua válida.

archivevocabulário
writerarquivos → bytes
readerbytes → índice
extractíndice → arquivos
maincomandos
PROJETO COMPLETO

Você pode acompanhar copiando cada arquivo abaixo ou abrir o repositório completo no GitHub, com exemplos e testes.

Ver o projeto Rust completo no GitHub

07.1 / CÓDIGO COMPLETO

Agora vamos criar todos os arquivos — sem esconder nenhuma parte.

Comece com cargo new lzip, entre na pasta e substitua o conteúdo gerado pelos arquivos abaixo. O código exibido nesta página vem diretamente do projeto mantido junto ao artigo: se um mudar, o outro muda junto.

cargo new lzip
cd lzip

# crie os módulos dentro de src/
# crie a pasta tests/ para o teste de integração
cargo test

Leia na ordem apresentada. Primeiro definimos o pacote e sua API; depois o vocabulário e os erros; só então escrevemos, lemos, extraímos e conectamos tudo ao terminal. Ao final, os testes atravessam o sistema completo.

01
Cargo.toml

A identidade do pacote

[package]
name = "lzip"
version = "0.1.0"
edition = "2024"
description = "Arquivador binário didático criado para o artigo do slaureano.com.br"

[dependencies]

A seção package dá nome e versão ao programa, escolhe a edição 2024 da linguagem e registra uma descrição. A seção dependencies está vazia de propósito: todo o projeto usa apenas a biblioteca padrão do Rust.

  • name = "lzip" define o nome da biblioteca e do executável.
  • version = "0.1.0" comunica que esta é a primeira versão experimental.
  • edition = "2024" seleciona as regras modernas da linguagem.
  • Sem crates externas, cargo build funciona sem baixar algoritmos prontos.
02
src/lib.rs

A porta de entrada da biblioteca

pub mod archive;
pub mod checksum;
pub mod error;
pub mod extract;
pub mod reader;
pub mod writer;

pub use archive::{Archive, Entry};
pub use error::LzipError;
pub use extract::extract_all;
pub use reader::read_archive;
pub use writer::create_archive;

Cada declaração pub mod manda o compilador carregar um arquivo do mesmo nome. Os pub use reexportam a API principal: quem usa lzip não precisa conhecer a pasta interna para criar, ler ou extrair um arquivo.

  • mod separa responsabilidades sem criar programas independentes.
  • pub torna um módulo ou item visível fora da biblioteca.
  • pub use cria uma API curta: lzip::create_archive.
  • main.rs consome essa biblioteca como qualquer outro cliente.
03
src/archive.rs

O vocabulário do formato

pub const MAGIC: [u8; 4] = *b"LZIP";
pub const VERSION: u8 = 1;
pub const HEADER_SIZE: usize = 16;
pub const ENTRY_FIXED_SIZE: usize = 32;
pub const FOOTER_SIZE: usize = 4;
pub const MAX_FILES: u16 = 4096;
pub const MAX_NAME_BYTES: u16 = 255;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
    pub name: String,
    pub offset: u64,
    pub stored_size: u64,
    pub original_size: u64,
    pub checksum: u32,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Archive {
    pub entries: Vec<Entry>,
    pub bytes: Vec<u8>,
}

impl Archive {
    pub fn find(&self, name: &str) -> Option<&Entry> {
        self.entries.iter().find(|entry| entry.name == name)
    }

    pub fn data(&self, entry: &Entry) -> Result<&[u8], crate::LzipError> {
        let start = usize::try_from(entry.offset)
            .map_err(|_| crate::LzipError::NumberTooLarge("offset"))?;
        let size = usize::try_from(entry.stored_size)
            .map_err(|_| crate::LzipError::NumberTooLarge("tamanho"))?;
        let end = start
            .checked_add(size)
            .ok_or(crate::LzipError::NumberTooLarge("fim da entrada"))?;
        self.bytes
            .get(start..end)
            .ok_or(crate::LzipError::EntryOutsideArchive(entry.name.clone()))
    }
}

As constantes repetem os números oficiais da especificação. Entry representa uma linha do índice já interpretada; Archive reúne o catálogo e os bytes originais. find localiza pelo nome e data transforma offset mais tamanho em uma fatia segura.

  • MAGIC e VERSION identificam o contrato antes da leitura.
  • usize mede regiões na memória; u64 é o tipo persistido no arquivo.
  • checked_add impede overflow ao calcular o final de uma entrada.
  • get(start..end) devolve erro em vez de causar panic fora dos limites.
04
src/checksum.rs

A verificação de integridade

const FNV_OFFSET_BASIS: u32 = 0x811C9DC5;
const FNV_PRIME: u32 = 0x01000193;

pub fn calculate(bytes: &[u8]) -> u32 {
    let mut hash = FNV_OFFSET_BASIS;
    for &byte in bytes {
        hash ^= u32::from(byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    hash
}

calculate implementa FNV-1a. O hash começa no offset basis; cada byte entra por XOR e o estado é multiplicado pelo primo. wrapping_mul descarta os bits que ultrapassam 32, comportamento definido pelo algoritmo.

  • A função recebe &[u8], portanto não copia nem modifica os dados.
  • for &byte extrai o valor u8 de cada referência da fatia.
  • u32::from(byte) faz uma conversão explícita e sem perda.
  • FNV detecta corrupção acidental, mas não prova autoria.
05
src/error.rs

Erros que explicam o problema

use std::{fmt, io};

#[derive(Debug)]
pub enum LzipError {
    Io(io::Error),
    InvalidMagic([u8; 4]),
    UnsupportedVersion(u8),
    TooManyFiles(usize),
    InvalidName(String),
    NameTooLong(usize),
    DuplicateName(String),
    Truncated(&'static str),
    InvalidUtf8,
    InvalidIndexSize,
    EntryOutsideArchive(String),
    OverlappingEntry(String),
    NumberTooLarge(&'static str),
    EntryChecksum { name: String, expected: u32, actual: u32 },
    ArchiveChecksum { expected: u32, actual: u32 },
}

impl fmt::Display for LzipError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(error) => write!(f, "erro de entrada ou saída: {error}"),
            Self::InvalidMagic(found) => write!(f, "magic inválido: {found:02X?}"),
            Self::UnsupportedVersion(version) => write!(f, "versão {version} não suportada"),
            Self::TooManyFiles(count) => write!(f, "arquivos demais: {count}"),
            Self::InvalidName(name) => write!(f, "nome inseguro ou inválido: {name}"),
            Self::NameTooLong(length) => write!(f, "nome possui {length} bytes; máximo 255"),
            Self::DuplicateName(name) => write!(f, "nome duplicado: {name}"),
            Self::Truncated(section) => write!(f, "arquivo terminou durante {section}"),
            Self::InvalidUtf8 => write!(f, "nome não é UTF-8 válido"),
            Self::InvalidIndexSize => write!(f, "tamanho do índice não combina com as entradas"),
            Self::EntryOutsideArchive(name) => write!(f, "dados de {name} ficam fora do arquivo"),
            Self::OverlappingEntry(name) => write!(f, "dados de {name} invadem o índice ou rodapé"),
            Self::NumberTooLarge(field) => write!(f, "{field} não cabe nesta plataforma"),
            Self::EntryChecksum { name, expected, actual } => {
                write!(f, "checksum de {name}: esperado {expected:08X}, calculado {actual:08X}")
            }
            Self::ArchiveChecksum { expected, actual } => {
                write!(f, "checksum do arquivo: esperado {expected:08X}, calculado {actual:08X}")
            }
        }
    }
}

impl std::error::Error for LzipError {}

impl From<io::Error> for LzipError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

LzipError enumera as falhas possíveis em vez de esconder tudo em uma mensagem genérica. Display converte cada variante em texto legível, Error integra o tipo ao ecossistema Rust e From<io::Error> permite usar ? em operações de arquivo.

  • Variantes com dados preservam nome, versão e checksums envolvidos.
  • fmt::Display é a mensagem que chega à pessoa no terminal.
  • impl Error declara que LzipError é um erro convencional de Rust.
  • From transforma automaticamente erros de fs::read e fs::write.
06
src/writer.rs

Arquivos entram, bytes saem

use std::{
    collections::HashSet,
    fs,
    path::{Path, PathBuf},
};

use crate::{
    archive::{
        ENTRY_FIXED_SIZE, FOOTER_SIZE, HEADER_SIZE, MAGIC, MAX_FILES, MAX_NAME_BYTES, VERSION,
    },
    checksum,
    error::LzipError,
};

struct SourceFile {
    name: String,
    data: Vec<u8>,
    checksum: u32,
}

pub fn create_archive(output: &Path, inputs: &[PathBuf]) -> Result<(), LzipError> {
    if inputs.len() > usize::from(MAX_FILES) {
        return Err(LzipError::TooManyFiles(inputs.len()));
    }

    let mut names = HashSet::new();
    let mut files = Vec::with_capacity(inputs.len());
    for path in inputs {
        let name = safe_file_name(path)?;
        if !names.insert(name.clone()) {
            return Err(LzipError::DuplicateName(name));
        }
        let data = fs::read(path)?;
        files.push(SourceFile {
            checksum: checksum::calculate(&data),
            name,
            data,
        });
    }

    let index_size = files.iter().try_fold(0usize, |total, file| {
        total
            .checked_add(ENTRY_FIXED_SIZE)
            .and_then(|value| value.checked_add(file.name.len()))
            .ok_or(LzipError::NumberTooLarge("índice"))
    })?;

    let data_start = HEADER_SIZE
        .checked_add(index_size)
        .ok_or(LzipError::NumberTooLarge("início dos dados"))?;
    let data_size = files.iter().try_fold(0usize, |total, file| {
        total
            .checked_add(file.data.len())
            .ok_or(LzipError::NumberTooLarge("dados"))
    })?;
    let capacity = data_start
        .checked_add(data_size)
        .and_then(|value| value.checked_add(FOOTER_SIZE))
        .ok_or(LzipError::NumberTooLarge("arquivo"))?;

    let mut output_bytes = Vec::with_capacity(capacity);
    output_bytes.extend_from_slice(&MAGIC);
    output_bytes.push(VERSION);
    output_bytes.push(0);
    output_bytes.extend_from_slice(&(files.len() as u16).to_le_bytes());
    output_bytes.extend_from_slice(&(index_size as u32).to_le_bytes());
    output_bytes.extend_from_slice(&[0; 4]);

    let mut next_offset = data_start as u64;
    for file in &files {
        output_bytes.extend_from_slice(&(file.name.len() as u16).to_le_bytes());
        output_bytes.extend_from_slice(&0u16.to_le_bytes());
        output_bytes.extend_from_slice(&next_offset.to_le_bytes());
        output_bytes.extend_from_slice(&(file.data.len() as u64).to_le_bytes());
        output_bytes.extend_from_slice(&(file.data.len() as u64).to_le_bytes());
        output_bytes.extend_from_slice(&file.checksum.to_le_bytes());
        output_bytes.extend_from_slice(file.name.as_bytes());
        next_offset += file.data.len() as u64;
    }

    for file in &files {
        output_bytes.extend_from_slice(&file.data);
    }

    let archive_checksum = checksum::calculate(&output_bytes);
    output_bytes.extend_from_slice(&archive_checksum.to_le_bytes());
    fs::write(output, output_bytes)?;
    Ok(())
}

fn safe_file_name(path: &Path) -> Result<String, LzipError> {
    let name = path
        .file_name()
        .and_then(|value| value.to_str())
        .ok_or_else(|| LzipError::InvalidName(path.display().to_string()))?;
    if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
        return Err(LzipError::InvalidName(name.to_owned()));
    }
    if name.len() > usize::from(MAX_NAME_BYTES) {
        return Err(LzipError::NameTooLong(name.len()));
    }
    Ok(name.to_owned())
}

O writer valida nomes, lê cada entrada, calcula checksums e descobre o tamanho do índice. Em seguida serializa o cabeçalho, as entradas, os dados e o checksum global exatamente nessa ordem.

  • HashSet impede dois arquivos com o mesmo nome interno.
  • try_fold e checked_add calculam tamanhos sem overflow.
  • to_le_bytes fixa a ordem dos bytes independentemente da CPU.
  • next_offset avança pelo tamanho real de cada conteúdo.
  • O checksum global é calculado antes de anexar seus próprios quatro bytes.
07
src/reader.rs

Bytes não confiáveis viram um Archive validado

use std::{collections::HashSet, fs, path::Path};

use crate::{
    archive::{Archive, Entry, FOOTER_SIZE, HEADER_SIZE, MAGIC, MAX_FILES, MAX_NAME_BYTES, VERSION},
    checksum,
    error::LzipError,
};

pub fn read_archive(path: &Path) -> Result<Archive, LzipError> {
    let bytes = fs::read(path)?;
    parse(bytes)
}

fn parse(bytes: Vec<u8>) -> Result<Archive, LzipError> {
    if bytes.len() < HEADER_SIZE + FOOTER_SIZE {
        return Err(LzipError::Truncated("cabeçalho"));
    }

    let footer_start = bytes.len() - FOOTER_SIZE;
    let expected_archive_checksum = u32::from_le_bytes(
        bytes[footer_start..]
            .try_into()
            .map_err(|_| LzipError::Truncated("checksum final"))?,
    );
    let actual_archive_checksum = checksum::calculate(&bytes[..footer_start]);
    if expected_archive_checksum != actual_archive_checksum {
        return Err(LzipError::ArchiveChecksum {
            expected: expected_archive_checksum,
            actual: actual_archive_checksum,
        });
    }

    let magic: [u8; 4] = bytes[0..4]
        .try_into()
        .map_err(|_| LzipError::Truncated("magic"))?;
    if magic != MAGIC {
        return Err(LzipError::InvalidMagic(magic));
    }
    if bytes[4] != VERSION {
        return Err(LzipError::UnsupportedVersion(bytes[4]));
    }

    let count = u16::from_le_bytes([bytes[6], bytes[7]]);
    if count > MAX_FILES {
        return Err(LzipError::TooManyFiles(usize::from(count)));
    }
    let index_size = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
    let index_end = HEADER_SIZE
        .checked_add(index_size)
        .ok_or(LzipError::NumberTooLarge("fim do índice"))?;
    if index_end > footer_start {
        return Err(LzipError::InvalidIndexSize);
    }

    let mut cursor = Cursor::new(&bytes, HEADER_SIZE, index_end);
    let mut entries = Vec::with_capacity(usize::from(count));
    let mut names = HashSet::new();
    for _ in 0..count {
        let name_len = cursor.read_u16()? as usize;
        if name_len == 0 || name_len > usize::from(MAX_NAME_BYTES) {
            return Err(LzipError::NameTooLong(name_len));
        }
        let _flags = cursor.read_u16()?;
        let offset = cursor.read_u64()?;
        let stored_size = cursor.read_u64()?;
        let original_size = cursor.read_u64()?;
        let entry_checksum = cursor.read_u32()?;
        let name = std::str::from_utf8(cursor.read_exact(name_len)?)
            .map_err(|_| LzipError::InvalidUtf8)?
            .to_owned();
        validate_name(&name)?;
        if !names.insert(name.clone()) {
            return Err(LzipError::DuplicateName(name));
        }
        entries.push(Entry {
            name,
            offset,
            stored_size,
            original_size,
            checksum: entry_checksum,
        });
    }
    if cursor.position != index_end {
        return Err(LzipError::InvalidIndexSize);
    }

    let archive = Archive { entries, bytes };
    for entry in &archive.entries {
        if entry.offset < index_end as u64 {
            return Err(LzipError::OverlappingEntry(entry.name.clone()));
        }
        let data = archive.data(entry)?;
        let entry_end = entry
            .offset
            .checked_add(entry.stored_size)
            .ok_or(LzipError::NumberTooLarge("fim da entrada"))?;
        if entry_end > footer_start as u64 {
            return Err(LzipError::OverlappingEntry(entry.name.clone()));
        }
        let actual = checksum::calculate(data);
        if actual != entry.checksum {
            return Err(LzipError::EntryChecksum {
                name: entry.name.clone(),
                expected: entry.checksum,
                actual,
            });
        }
    }
    Ok(archive)
}

fn validate_name(name: &str) -> Result<(), LzipError> {
    if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
        return Err(LzipError::InvalidName(name.to_owned()));
    }
    Ok(())
}

struct Cursor<'a> {
    bytes: &'a [u8],
    position: usize,
    end: usize,
}

impl<'a> Cursor<'a> {
    fn new(bytes: &'a [u8], position: usize, end: usize) -> Self {
        Self { bytes, position, end }
    }

    fn read_exact(&mut self, length: usize) -> Result<&'a [u8], LzipError> {
        let end = self
            .position
            .checked_add(length)
            .ok_or(LzipError::NumberTooLarge("cursor"))?;
        if end > self.end {
            return Err(LzipError::Truncated("índice"));
        }
        let result = &self.bytes[self.position..end];
        self.position = end;
        Ok(result)
    }

    fn read_u16(&mut self) -> Result<u16, LzipError> {
        Ok(u16::from_le_bytes(
            self.read_exact(2)?.try_into().expect("dois bytes"),
        ))
    }

    fn read_u32(&mut self) -> Result<u32, LzipError> {
        Ok(u32::from_le_bytes(
            self.read_exact(4)?.try_into().expect("quatro bytes"),
        ))
    }

    fn read_u64(&mut self) -> Result<u64, LzipError> {
        Ok(u64::from_le_bytes(
            self.read_exact(8)?.try_into().expect("oito bytes"),
        ))
    }
}

O reader começa pelo tamanho mínimo e pelo checksum global. Depois valida magic, versão, contagem e limite do índice. Cursor centraliza toda leitura com limites; por fim cada entrada tem região e checksum conferidos.

  • O arquivo recebido nunca é considerado confiável.
  • Cursor só avança depois de provar que há bytes suficientes.
  • from_le_bytes reconstrói inteiros na ordem definida pelo formato.
  • UTF-8, nomes duplicados e caminhos perigosos são recusados.
  • Dados não podem começar dentro do índice nem alcançar o rodapé.
08
src/extract.rs

Reconstruindo os arquivos

use std::{fs, path::Path};

use crate::{archive::Archive, error::LzipError};

pub fn extract_all(archive: &Archive, output_dir: &Path) -> Result<(), LzipError> {
    fs::create_dir_all(output_dir)?;
    for entry in &archive.entries {
        let destination = output_dir.join(&entry.name);
        fs::write(destination, archive.data(entry)?)?;
    }
    Ok(())
}

A extração é curta porque todo o trabalho perigoso ocorreu no parser. A pasta é criada, cada nome seguro é combinado com o destino e a fatia validada é gravada sem interpretar seu conteúdo.

  • create_dir_all também aceita uma pasta que já existe.
  • join monta o caminho abaixo do diretório escolhido.
  • archive.data reutiliza as verificações de offset e tamanho.
  • O formato v1 proíbe subpastas para reduzir riscos de traversal.
09
src/main.rs

A interface de linha de comando

use std::{
    env,
    path::{Path, PathBuf},
};

use lzip::{LzipError, create_archive, extract_all, read_archive};

fn main() {
    if let Err(error) = run() {
        eprintln!("erro: {error}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), LzipError> {
    let arguments: Vec<String> = env::args().collect();
    match arguments.get(1).map(String::as_str) {
        Some("create") => {
            let output = required(&arguments, 2, "informe o arquivo .lzip")?;
            let inputs: Vec<PathBuf> = arguments[3..].iter().map(PathBuf::from).collect();
            create_archive(Path::new(output), &inputs)?;
            println!("criado: {output} ({} arquivos)", inputs.len());
        }
        Some("list") => {
            let path = required(&arguments, 2, "informe o arquivo .lzip")?;
            let archive = read_archive(Path::new(path))?;
            println!("{:<24} {:>12} {:>12}  {:>8}", "NOME", "OFFSET", "TAMANHO", "CHECKSUM");
            for entry in archive.entries {
                println!(
                    "{:<24} {:>12} {:>12}  {:08X}",
                    entry.name, entry.offset, entry.stored_size, entry.checksum
                );
            }
        }
        Some("extract") => {
            let path = required(&arguments, 2, "informe o arquivo .lzip")?;
            let destination = arguments.get(3).map(String::as_str).unwrap_or("extracted");
            let archive = read_archive(Path::new(path))?;
            extract_all(&archive, Path::new(destination))?;
            println!("extraído em: {destination}");
        }
        Some("check") => {
            let path = required(&arguments, 2, "informe o arquivo .lzip")?;
            let archive = read_archive(Path::new(path))?;
            println!("arquivo íntegro: {} entradas", archive.entries.len());
        }
        _ => print_help(),
    }
    Ok(())
}

fn required<'a>(
    arguments: &'a [String],
    index: usize,
    message: &'static str,
) -> Result<&'a str, LzipError> {
    arguments
        .get(index)
        .map(String::as_str)
        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, message).into())
}

fn print_help() {
    println!("LZIP — arquivador binário didático");
    println!("  lzip create projeto.lzip texto.txt sprite.bin config.json");
    println!("  lzip list projeto.lzip");
    println!("  lzip extract projeto.lzip [diretório]");
    println!("  lzip check projeto.lzip");
}

main executa run e transforma qualquer erro em mensagem e código de saída 1. run lê os argumentos, escolhe um dos quatro comandos e chama a biblioteca. A regra do formato continua fora da interface.

  • env::args coleta o nome do programa e os argumentos seguintes.
  • match seleciona create, list, extract ou check.
  • Path e PathBuf representam caminhos sem concatenar strings manualmente.
  • required converte argumento ausente em erro de entrada inválida.
  • check valida o pacote apenas chamando o reader completo.
10
tests/archive_roundtrip.rs

Provando ida, volta e corrupção

use std::{fs, path::PathBuf};

use lzip::{create_archive, extract_all, read_archive};

fn temp_dir(name: &str) -> PathBuf {
    let path = std::env::temp_dir().join(format!("lzip-{name}-{}", std::process::id()));
    let _ = fs::remove_dir_all(&path);
    fs::create_dir_all(&path).unwrap();
    path
}

#[test]
fn creates_lists_and_extracts_files() {
    let root = temp_dir("roundtrip");
    let text = root.join("texto.txt");
    let sprite = root.join("sprite.bin");
    let config = root.join("config.json");
    fs::write(&text, b"Ola, LZIP!\n").unwrap();
    fs::write(&sprite, [0, 1, 2, 3, 255]).unwrap();
    fs::write(&config, br#"{"tema":"escuro"}"#).unwrap();

    let output = root.join("projeto.lzip");
    create_archive(&output, &[text, sprite, config]).unwrap();
    let archive = read_archive(&output).unwrap();
    assert_eq!(archive.entries.len(), 3);
    assert_eq!(archive.data(archive.find("texto.txt").unwrap()).unwrap(), b"Ola, LZIP!\n");

    let extracted = root.join("extracted");
    extract_all(&archive, &extracted).unwrap();
    assert_eq!(fs::read(extracted.join("sprite.bin")).unwrap(), [0, 1, 2, 3, 255]);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn rejects_corruption() {
    let root = temp_dir("corruption");
    let input = root.join("a.txt");
    let output = root.join("a.lzip");
    fs::write(&input, b"abc").unwrap();
    create_archive(&output, &[input]).unwrap();

    let mut bytes = fs::read(&output).unwrap();
    bytes[20] ^= 0xFF;
    fs::write(&output, bytes).unwrap();
    assert!(read_archive(&output).is_err());
    fs::remove_dir_all(root).unwrap();
}

O primeiro teste cria três arquivos reais, empacota, lê, encontra uma entrada, extrai e compara bytes. O segundo altera um byte do pacote e exige rejeição. Assim testamos comportamento, não detalhes internos.

  • temp_dir isola cada execução e remove resíduos anteriores.
  • round trip significa entrada → LZIP → saída idêntica.
  • assert_eq compara bytes, inclusive zero e valores não textuais.
  • O teste de corrupção prova que checksums participam da leitura.
NÃO COPIE TUDO DE UMA VEZ

Crie um arquivo, rode cargo check e só então avance. O compilador transforma cada etapa em uma pergunta pequena e mostra exatamente onde o contrato deixou de encaixar.

08 / GRAVANDO O ARQUIVO

Serializar é transformar valores em uma sequência reproduzível de bytes.

O writer primeiro lê cada arquivo de entrada. Extrai somente o nome final, recusa nomes vazios, ., .., separadores e bytes nulos. Depois carrega os dados e calcula o checksum individual.

Usamos Vec<u8> porque o tamanho cresce durante a construção. Um vetor guarda bytes contíguos, conhece seu comprimento e pode reservar capacidade antecipadamente. Reservar não altera o conteúdo; apenas reduz realocações.

let mut output_bytes = Vec::with_capacity(capacity);

output_bytes.extend_from_slice(&MAGIC);
output_bytes.push(VERSION);
output_bytes.push(0); // flags
output_bytes.extend_from_slice(&(files.len() as u16).to_le_bytes());
output_bytes.extend_from_slice(&(index_size as u32).to_le_bytes());
output_bytes.extend_from_slice(&[0; 4]); // reservado

extend_from_slice copia vários bytes. push acrescenta um. to_le_bytes transforma um inteiro nos bytes little endian definidos pela especificação. Não gravamos a memória bruta de uma struct, pois padding, ponteiros e layout não fazem parte do contrato.

let mut next_offset = data_start as u64;

for file in &files {
    output_bytes.extend_from_slice(&(file.name.len() as u16).to_le_bytes());
    output_bytes.extend_from_slice(&0u16.to_le_bytes()); // flags
    output_bytes.extend_from_slice(&next_offset.to_le_bytes());
    output_bytes.extend_from_slice(&(file.data.len() as u64).to_le_bytes());
    output_bytes.extend_from_slice(&(file.data.len() as u64).to_le_bytes());
    output_bytes.extend_from_slice(&file.checksum.to_le_bytes());
    output_bytes.extend_from_slice(file.name.as_bytes());

    next_offset += file.data.len() as u64;
}

O laço grava cada linha do índice. O offset atual começa emdata_start. Depois de uma entrada, somamos o comprimento de seus dados para descobrir o início da próxima. O nome vem depois dos 32 bytes fixos.

for file in &files {
    output_bytes.extend_from_slice(&file.data);
}

let archive_checksum = checksum::calculate(&output_bytes);
output_bytes.extend_from_slice(&archive_checksum.to_le_bytes());

std::fs::write(output, output_bytes)?;

Só depois do índice anexamos os dados. O checksum global cobre tudo que veio antes dele. fs::write cria ou substitui o arquivo. Em uma ferramenta de produção, gravaríamos em um temporário e renomearíamos ao final para evitar deixar uma saída parcial.

O que write_all resolve?

A operação de escrita de baixo nível pode aceitar menos bytes do que recebeu. Isso não significa necessariamente erro: o dispositivo ou buffer pode ter espaço naquele instante para apenas uma parte. write_all repete a operação até consumir a fatia inteira ou encontrar uma falha. É a escolha certa quando cada byte faz parte de uma estrutura indivisível.

Neste projeto montamos um Vec<u8> completo e usamosfs::write, que internamente realiza essa obrigação. A versão pedagógica fica simples porque offsets e checksum podem ser calculados em memória. Para um pacote de 80 GB isso seria inviável: precisaríamos escrever em streaming com um BufWriter, calcular checksums enquanto os blocos passam e guardar apenas metadados.

Escrita transacional: o arquivo velho ou o novo, nunca metade

Imagine falta de energia após gravar o cabeçalho, mas antes dos dados. Se escrevemos diretamente sobre projeto.lzip, destruímos uma versão válida e deixamos outra truncada. Um utilitário real criaprojeto.lzip.tmp na mesma pasta, termina a escrita, sincroniza quando necessário e só então renomeia para o destino.

Renomear no mesmo sistema de arquivos costuma ser uma operação atômica: outros processos observam o nome antigo ou o novo, não um estado intermediário. Ainda precisamos decidir o que fazer se o destino já existe, preservar permissões e limpar temporários abandonados. Esses detalhes não mudam o formato, mas separam uma demonstração correta de uma ferramenta confiável.

09 / CALCULANDO OFFSETS

Para escrever o índice antes dos dados, precisamos saber antecipadamente onde os dados começarão.

O cabeçalho mede sempre 16 bytes. Cada entrada mede 32 bytes fixos mais o tamanho do nome. Com três nomes de 9, 10 e 11 bytes, o índice mede:

CABEÇALHO16
+
TEXTO32 + 9
+
SPRITE32 + 10
+
CONFIG32 + 11
=
DATA START141

Portanto texto.txt começa em 141. Se ele tem 120 bytes,sprite.bin começa em 261. Como o sprite tem 2.048 bytes,config.json começa em 2.309. O número muda conforme os arquivos, mas a fórmula é estável.

let index_size = files.iter().try_fold(0usize, |total, file| {
    total
        .checked_add(ENTRY_FIXED_SIZE)
        .and_then(|value| value.checked_add(file.name.len()))
        .ok_or(LzipError::NumberTooLarge("índice"))
})?;

let data_start = HEADER_SIZE
    .checked_add(index_size)
    .ok_or(LzipError::NumberTooLarge("início dos dados"))?;

try_fold acumula o tamanho. checked_add retornaNone em overflow, em vez de produzir um número incorreto.and_then só executa a segunda soma se a primeira funcionou. O operador ? encerra a função quando aparece erro.

POR QUE TANTA CAUTELA?

Offsets errados não falham somente naquele item: deslocam a leitura de tudo que vem depois.

Vamos conferir a conta ao contrário

Uma boa técnica de engenharia é verificar a mesma estrutura por outra perspectiva. Depois de escrever os três conteúdos, o cursor final esperado é141 + 120 + 2.048 + 90 = 2.399. Em seguida vêm quatro bytes do checksum global, então o arquivo completo mede 2.403 bytes. Se o vetor terminar com outro comprimento, alguma conta, nome ou escrita divergiu.

Esse tipo de invariante é barato e poderoso. Podemos afirmar que o primeiro offset é igual ao fim do índice, que cada offset seguinte é igual ao fim da entrada anterior e que a última entrada termina antes do rodapé. O reader repetirá essas provas usando apenas bytes recebidos, sem acreditar nas intenções do writer.

10 / LENDO O ARQUIVO

O reader não confia no writer. Ele prova cada limite novamente.

Um arquivo pode ter sido truncado, alterado ou criado por outro programa. Primeiro exigimos pelo menos 20 bytes: 16 de cabeçalho e quatro de rodapé. Validamos o checksum global, o magic, a versão e a contagem.

O tamanho do índice define index_end. Esse fim deve estar antes do rodapé. Um cursor começa no byte 16 e não pode ultrapassarindex_end.

fn read_exact(&mut self, length: usize) -> Result<&'a [u8], LzipError> {
    let end = self.position
        .checked_add(length)
        .ok_or(LzipError::NumberTooLarge("cursor"))?;

    if end > self.end {
        return Err(LzipError::Truncated("índice"));
    }

    let result = &self.bytes[self.position..end];
    self.position = end;
    Ok(result)
}

O cursor soma posição e comprimento com checked_add. Se o resultado passa do fim permitido, devolve Truncated. Somente depois cria a fatia. O avanço fica centralizado, então read_u16,read_u32 e read_u64 herdam a mesma proteção.

let name_len = cursor.read_u16()? as usize;
let _flags = cursor.read_u16()?;
let offset = cursor.read_u64()?;
let stored_size = cursor.read_u64()?;
let original_size = cursor.read_u64()?;
let entry_checksum = cursor.read_u32()?;

let name = std::str::from_utf8(cursor.read_exact(name_len)?)
    .map_err(|_| LzipError::InvalidUtf8)?
    .to_owned();

Ler a entrada na ordem especificada é um pequeno parser binário. O nome só é convertido para String depois de confirmar o comprimento e UTF-8. Rejeitamos nomes duplicados. Ao terminar a quantidade declarada, a posição do cursor deve coincidir exatamente com o fim do índice.

pub fn find(&self, name: &str) -> Option<&Entry> {
    self.entries.iter().find(|entry| entry.name == name)
}

pub fn data(&self, entry: &Entry) -> Result<&[u8], LzipError> {
    let start = usize::try_from(entry.offset)
        .map_err(|_| LzipError::NumberTooLarge("offset"))?;
    let size = usize::try_from(entry.stored_size)
        .map_err(|_| LzipError::NumberTooLarge("tamanho"))?;
    let end = start
        .checked_add(size)
        .ok_or(LzipError::NumberTooLarge("fim da entrada"))?;

    self.bytes
        .get(start..end)
        .ok_or(LzipError::EntryOutsideArchive(entry.name.clone()))
}

find percorre as entradas pelo nome. Para milhares de itens, poderíamos montar um HashMap. data converte offset e tamanho para usize, calcula o fim sem overflow e usaget(start..end). Diferente de [start..end],get retorna None em limite inválido em vez de panic.

1Validar arquivochecksum e magic
2Ler índicenomes e limites
3Procurar entradanome → offset
4Recortar dadosoffset..fim

Parser é uma fronteira de segurança

Abrir um arquivo significa processar dados controlados por outra pessoa. Uma contagem pode declarar 65 mil entradas em um arquivo de vinte bytes. Um tamanho pode ser u64::MAX. Dois registros podem apontar para a mesma região, ou para dentro do índice. Um nome pode conter bytes inválidos. Cada campo deve ser tratado como uma alegação que ainda precisa ser provada.

Por isso há uma ordem útil de validação. Primeiro verificamos o mínimo estrutural e o checksum global. Depois os limites do índice. Em seguida lemos cada entrada sem sair desse limite. Por fim confirmamos que todas as regiões de dados começam depois do índice, terminam antes do rodapé e têm checksum correto.

Também limitamos 4.096 entradas e 255 bytes por nome. O campo numérico suportaria mais, mas limites de produto impedem que um arquivo minúsculo obrigue o programa a reservar memória ou gastar CPU sem controle. Isso é defesa contra negação de serviço, não apenas organização.

Falhar cedo e explicar o motivo

Um bom parser não devolve apenas “arquivo inválido”. Ele distingue magic incorreto, versão desconhecida, truncamento, UTF-8 inválido, região sobreposta, checksum divergente e nome inseguro. Mensagens específicas ajudam o usuário e tornam testes precisos. Ao mesmo tempo, o parser nunca deve continuar com um estado parcialmente confiável.

11 / EXTRAINDO ARQUIVOS

Extrair é reconstruir nomes e dados no sistema de arquivos.

pub fn extract_all(
    archive: &Archive,
    output_dir: &Path,
) -> Result<(), LzipError> {
    std::fs::create_dir_all(output_dir)?;

    for entry in &archive.entries {
        let destination = output_dir.join(&entry.name);
        std::fs::write(destination, archive.data(entry)?)?;
    }

    Ok(())
}

A função cria a pasta de destino. Para cada entrada, combina o diretório com o nome e grava a fatia correspondente. O parser já conferiu checksum e limites, portanto a extração trabalha com um Archive validado.

Parece simples porque deliberadamente proibimos pastas no formato v1. Essa restrição evita path traversal: um nome como../../configuracao-secreta poderia escapar da pasta escolhida e sobrescrever outro arquivo.

LZIP3 entradas
saida/
texto.txt
sprite.bin
config.json

Quando adicionarmos diretórios, a regra deverá ser mais forte: aceitar somente caminhos relativos normalizados, rejeitar componentes .., rejeitar caminhos absolutos e confirmar que o destino final permanece abaixo da pasta de extração.

O nome do arquivo também é entrada não confiável

Validar os bytes do conteúdo e usar o nome sem cuidado ainda seria perigoso. Além de ../, sistemas diferentes têm caminhos absolutos como/etc/config, C:\Windows e caminhos de rede. Há nomes reservados, diferenças entre maiúsculas e minúsculas e caracteres que um sistema aceita e outro não.

Links simbólicos complicam mais. Mesmo que o caminho textual esteja dentro desaida/, uma pasta intermediária pode ser um link para fora dela. Extratores robustos abrem diretórios com APIs que evitam seguir links inesperados, controlam colisões e nunca sobrescrevem por padrão um arquivo sensível.

A versão 1 toma uma decisão honesta: somente nomes simples, sem pastas. Menos recursos significam menos estados para validar. Quando uma versão futura aceitar diretórios, ela deverá definir separador canônico, normalização, política de sobrescrita e comportamento em plataformas distintas antes de escrever código.

12 / INTEGRIDADE

Um byte corrompido pode mudar um nome, um offset ou o conteúdo inteiro.

Discos, redes, programas e pessoas podem alterar bytes. Se um pixel do sprite mudar, o jogo pode mostrar um ponto errado. Se um byte do offset mudar, o leitor pode buscar a região errada. O segundo caso é mais perigoso porque altera a estrutura.

const FNV_OFFSET_BASIS: u32 = 0x811C9DC5;
const FNV_PRIME: u32 = 0x01000193;

pub fn calculate(bytes: &[u8]) -> u32 {
    let mut hash = FNV_OFFSET_BASIS;

    for &byte in bytes {
        hash ^= u32::from(byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }

    hash
}

Usamos FNV-1a de 32 bits por ser pequeno e fácil de estudar. Começamos por uma constante. Para cada byte, aplicamos XOR e multiplicamos por um primo.wrapping_mul define que o excesso de 32 bits é descartado.

ARQUIVO ORIGINAL7A 31 00 FFFNV = 8C12A4D0
1 BYTE MUDA
ARQUIVO ALTERADO7A 31 01 FFFNV = 1B09E763

Cada entrada possui seu checksum, então sabemos qual item falhou. O rodapé possui um checksum global, que também protege cabeçalho e índice. FNV não é criptográfico: alguém pode alterar e recalcular. Assinatura digital seria necessária para verificar autoria.

13 / ONDE ENTRA A COMPRESSÃO?

Até aqui arquivamos. Não reduzimos um único byte dos dados originais.

Juntar e comprimir são operações diferentes. Nosso LZIP pode ficar ligeiramente maior que a soma das entradas, pois acrescenta cabeçalho, índice e checksums. Mesmo assim já resolve distribuição, organização e acesso por nome.

Compressão procura redundância. Se um sprite contém cem pixels azuis seguidos, podemos representar “azul repetido cem vezes”. Se uma frase aparece várias vezes, podemos guardar a frase uma vez e apontar para ela. Se alguns símbolos são mais frequentes, podemos usar códigos menores para eles.

RLE

Repetições consecutivas

AAAAA vira 5 × A. Ótimo para sequências longas; ruim para dados variados.

LZ77

Trechos vistos antes

Substitui repetições por distância e comprimento dentro de uma janela.

HUFFMAN

Códigos por frequência

Símbolos comuns recebem códigos curtos; raros recebem códigos maiores.

DEFLATE

Duas ideias combinadas

Usa referências no estilo LZ77 e codificação Huffman para representar o resultado.

ANTESAAAAA BBB CCCCCCCC18 símbolos
DEPOIS5A 3B 8C6 símbolos conceituais

RLE: a primeira experiência que cabe em uma folha

Run-Length Encoding lê uma sequência e produz pares(quantidade, valor). Para AAAAABB, teríamos(5, A)(2, B). O decoder faz o caminho inverso: repete A cinco vezes e B duas. A transformação é reversível porque guardamos tudo que é necessário para reconstruir o original.

Mas tente comprimir ABCDEFG. O resultado conceitual seria1A1B1C1D1E1F1G, maior que a entrada. Um formato real precisa comparar os tamanhos e manter os bytes originais quando a “compressão” aumenta o item. Uma flag por entrada informa ao reader qual caminho usar.

LZ77: apontar para o que já apareceu

Considere CASA-CASA-CASA. Depois de ler a primeiraCASA-, o encoder encontra a mesma sequência novamente. Em vez de repeti-la, pode registrar algo como “volte cinco posições e copie cinco símbolos”. Esse par contém uma distância e um comprimento — curiosamente, outra aplicação de offsets.

O decoder mantém uma janela dos bytes recentes. Ao encontrar uma referência, copia da posição indicada. Distâncias e comprimentos pequenos ocupam menos espaço que sequências longas. Procurar a melhor repetição, porém, custa tempo; níveis de compressão normalmente trocam CPU por uma busca mais cuidadosa.

Huffman: gastar poucos bits com o que aparece muito

Se A aparece centenas de vezes e Z aparece uma, não somos obrigados a gastar oito bits com ambos. Huffman constrói uma árvore sem ambiguidades: códigos frequentes ficam curtos, códigos raros ficam longos. O decoder precisa conhecer a árvore ou uma descrição equivalente para separar o fluxo de bits.

Deflate combina essas duas famílias. Primeiro encontra literais e referências a trechos anteriores; depois codifica esses símbolos com tabelas Huffman. Não implementaremos Deflate porque nosso objetivo é enxergar as interfaces: o arquivador entrega bytes a um compressor, guarda método e tamanhos no índice e exige que o decoder produza exatamente o tamanho original.

Compressão por arquivo ou do pacote inteiro?

Comprimir cada entrada separadamente preserva acesso aleatório: para abrirsprite.bin, descomprimimos somente o sprite. Comprimir todos os dados como um fluxo único pode encontrar redundâncias entre arquivos, mas talvez exija descomprimir muito conteúdo anterior para alcançar um item. Jogos e ferramentas escolhem blocos, agrupamentos e tamanhos pensando no padrão real de leitura.

Há ainda a bomba de descompressão: poucos bytes codificados prometem gerar gigabytes. O original_size permite recusar saídas acima de um limite antes de alocar memória. O decoder também deve parar se produzir mais bytes que o declarado ou se terminar antes deles.

Uma versão 2 poderia comprimir cada arquivo separadamente. A entrada já possuistored_size e original_size. Uma flag indicaria o algoritmo. Para extrair, o reader recortaria o tamanho armazenado, descomprimiria até o tamanho original e imporia um limite contra bombas de descompressão.

14 / COMPARANDO COM FORMATOS REAIS

Nosso formato é pequeno, mas as perguntas fundamentais são as mesmas.

FORMATOIDEIA PRINCIPALRELAÇÃO COM O LZIP
LZIPíndice antes dos dadosdidático, sem compressão
ZIPentradas + diretório centralnomes, offsets, tamanhos, CRC e compressão
TARregistros sequenciaisprioriza fluxo; não exige índice central clássico
PAKpacote de assetsíndice permite localizar recursos de jogos
JARZIP com convenções Javacontêiner mais regras e metadados
APKpacote Android baseado em ZIParquivos, manifesto, recursos e assinaturas
EPUBpublicação dentro de ZIPHTML, estilos, imagens e catálogo

ZIP costuma guardar cabeçalhos próximos aos dados e um diretório central no fim. Isso facilita escrever entradas sequencialmente e montar o catálogo depois. TAR favorece um fluxo de registros, historicamente adequado a fitas. PAK varia entre engines, mas geralmente concentra assets e um índice eficiente.

JAR, APK e EPUB mostram um princípio poderoso: um formato pode reutilizar outro como camada de armazenamento. O contêiner resolve nomes, bytes e compressão; a especificação superior define quais arquivos significam o quê.

15 / COMO O FORMATO PODERIA CRESCER

Cada recurso novo exige bytes, regras de validação e uma estratégia de compatibilidade.

PASTAS

Caminhos relativos

Normalização rigorosa e bloqueio de traversal.

PERMISSÕES

Metadados do sistema

Definir o que é portátil entre Windows e Unix.

DATAS

Tempo padronizado

Epoch, UTC, precisão e intervalo representável.

COMPRESSÃO

Por entrada

Flag, algoritmo, tamanho original e limites.

CRIPTOGRAFIA

Confidencialidade

Chaves, nonce, autenticação e derivação de senha.

ASSINATURAS

Autenticidade

Provar que uma chave confiável aprovou o pacote.

COMENTÁRIOS

Metadados livres

Tamanho limitado, codificação e localização.

STREAMING

Leitura gradual

Índice final ou registros para escrita sem buffer total.

VERSÃO 2

Evolução explícita

Recusar o desconhecido ou declarar campos ignoráveis.

Reservamos flags e quatro bytes no cabeçalho, mas reserva não resolve tudo. Uma versão nova precisa dizer se leitores antigos podem ignorar o recurso. Alterar o significado de um campo mantendo versão 1 quebraria o contrato.

16 / CURIOSIDADES

Depois de entender índice e offset, muitos “arquivos misteriosos” ficam familiares.

DOCX

Um documento é uma pequena pasta

Textos, estilos, relações e imagens vivem em arquivos internos organizados dentro de ZIP. Renomear para .zip pode revelar a estrutura, embora editar sem respeitar as regras possa corromper o documento.

APK

Um aplicativo Android também é um pacote

Classes compiladas, manifesto, recursos e bibliotecas são reunidos em um contêiner baseado em ZIP. Assinaturas acrescentam confiança sobre o pacote distribuído.

JOGOS

Milhares de assets viram poucos pacotes

Pacotes reduzem a quantidade de operações de abertura, centralizam índices e facilitam patches. A engine busca o nome lógico e pula para o offset do recurso.

UNREAL E UNITY

Contêineres são parte do pipeline

Engines cozinham assets para formatos de distribuição, agrupando dados e metadados otimizados para a plataforma. Os detalhes variam, mas catálogo e regiões de dados continuam presentes.

CARREGAMENTO

Mapas rápidos dependem de organização

Ordenar assets que são usados juntos reduz saltos de leitura. Índices, alinhamento, pré-carregamento e memória mapeada podem importar tanto quanto compressão.

CORRUPÇÃO

Um byte do índice afeta outro lugar

Se o tamanho do nome muda, o parser pode desalinha-se; se o offset muda, lê dados errados. Limites, checksums e versões impedem que um erro silencioso se propague.

17 / PROJETO FINAL

Crie, liste, valide e extraia seu primeiro arquivo .lzip.

O projeto completo está no GitHub e não usa dependências externas. Com Rust e Git instalados, clone o repositório e entre na pasta:

Abrir o código-fonte do LZIP no GitHub
git clone https://github.com/sl4ureano/lzip.git
cd lzip

Agora prepare três arquivos e execute os comandos do utilitário:

cargo test

cargo run -- create projeto.lzip \
  exemplos/texto.txt \
  exemplos/sprite.bin \
  exemplos/config.json

cargo run -- list projeto.lzip
cargo run -- check projeto.lzip
cargo run -- extract projeto.lzip saida

create lê as entradas e grava o contêiner. list mostra nome, offset, tamanho e checksum. check executa todo o parser e confirma integridade. extract recria os arquivos.

01Criar arquivos
02Empacotar .lzip
03Listar o índice
04Validar checksums
05Extrair os dados

Experimentos para consolidar

EXPERIMENTO 01Altere um byte dos dados e rode check.
EXPERIMENTO 02Altere um offset no editor hexadecimal.
EXPERIMENTO 03Empacote um arquivo vazio.
EXPERIMENTO 04Tente adicionar nomes duplicados.

Os testes automatizados fazem round trip: criam arquivos, empacotam, leem, encontram uma entrada, extraem e comparam os bytes. Outro teste modifica um byte e exige erro. Uma suíte maior testaria truncamentos em cada offset, limites de nome, contagens máximas e arquivos vazios.

Como pensar nos testes por camadas

O primeiro teste é o caminho feliz: os bytes extraídos devem ser idênticos aos de entrada. Não basta comparar texto, porque um arquivo binário pode conter zero, UTF-8 inválido e todos os 256 valores possíveis. A comparação correta é byte por byte.

Depois vêm testes de fronteira: zero arquivos, arquivo vazio, nome com 255 bytes, maior contagem permitida e tamanhos próximos dos limites. Em seguida fazemos mutações: removemos cada byte final possível, trocamos magic e versão, exageramos a contagem, apontamos dados para dentro do índice e corrompemos o checksum.

Uma técnica chamada fuzzing gera muitas entradas inesperadas e verifica uma propriedade essencial: o parser pode aceitar ou devolver erro, mas nunca deve entrar em loop, acessar memória fora dos limites ou entrar em panic. O formato é pequeno o suficiente para esse exercício e grande o bastante para revelar por que parsers merecem testes agressivos.

Leitura hexadecimal como ferramenta de depuração

Abra projeto.lzip em um editor hexadecimal. Nos offsets 0 a 3 você verá 4C 5A 49 50, o ASCII de LZIP. No byte 4 estará a versão. Conte os 16 bytes do cabeçalho, localize o comprimento do primeiro nome e procure seus caracteres. Depois vá diretamente ao offset informado e compare os dados.

Esse exercício conecta especificação, código e arquivo real. Quando uma conta falhar, não precisamos adivinhar: marcamos regiões, conferimos endianess e observamos exatamente em qual byte writer e reader deixaram de concordar.

O QUE CONSTRUÍMOS

Não copiamos ZIP. Reconstruímos as ideias que tornam um arquivador possível.

Agora a pergunta inicial tem uma resposta precisa: vários arquivos cabem em um porque seus bytes são colocados em regiões conhecidas e um índice preserva nomes, offsets, tamanhos e integridade. Compressão é uma camada adicional — importante, mas não necessária para que o contêiner exista.

18 / PERGUNTAS FREQUENTES

Dúvidas comuns sobre arquivadores, offsets e compactação.

Arquivar e compactar são a mesma coisa?

Não. Arquivar reúne vários arquivos em um contêiner. Compactar tenta reduzir a quantidade de bytes. Um formato pode arquivar sem comprimir, comprimir um único fluxo ou fazer as duas coisas.

O .lzip criado no artigo abre em programas ZIP?

Não. Ele é um formato didático próprio, com magic LZIP e uma especificação diferente. A extensão e o nome foram escolhidos para a aula, não para prometer compatibilidade.

O que é um offset dentro de um arquivo?

É a distância, em bytes, desde o início do arquivo até uma posição. Offset 0 é o primeiro byte; offset 500 fica quinhentos bytes depois do começo.

Por que guardar um índice?

O índice associa nomes a offsets e tamanhos. Assim o leitor localiza um item sem interpretar todos os dados anteriores.

Por que o índice fica antes dos dados no LZIP?

Para que a aula mostre o cálculo prévio dos offsets e para permitir leitura imediata do catálogo. Formatos reais também podem guardar índices no final.

Checksum impede alterações maliciosas?

Não. O FNV-1a usado no projeto detecta alterações acidentais, mas qualquer pessoa consegue recalculá-lo. Autenticidade exige assinatura criptográfica.

Por que um DOCX, APK, JAR ou EPUB pode ser aberto como ZIP?

Porque esses formatos usam ZIP como contêiner e definem regras próprias sobre nomes, metadados e arquivos obrigatórios dentro dele.

Como adicionar pastas ao LZIP?

Uma evolução poderia aceitar caminhos relativos normalizados no índice. O extrator teria de rejeitar caminhos absolutos, .. e qualquer destino que escape da pasta escolhida.

O projeto implementa compressão?

Não. A versão 1 arquiva bytes sem reduzi-los. O artigo explica RLE, LZ77, Huffman e Deflate para mostrar como a compressão poderia ser adicionada depois.

Por que os offsets usam u64?

Porque oito bytes permitem representar arquivos muito maiores e tornam o contrato independente do tamanho de ponteiro da máquina que executa o programa.

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.