Ferramenta de Backup Incremental

Construa uma ferramenta de backup incremental em Rust com deteccao por hash, compressao e manifesto JSON de alteracoes.

Backups sao a ultima linha de defesa contra perda de dados. Um backup incremental inteligente copia apenas os arquivos que realmente mudaram, economizando tempo e espaco. Neste projeto, vamos construir uma ferramenta de backup incremental que usa hashes SHA-256 para detectar alteracoes, comprime os arquivos copiados com flate2 e mantem um manifesto JSON com o historico de cada operacao.

Voce aprendera a trabalhar com hashing criptografico, compressao de dados, manipulacao avancada do sistema de arquivos e serializacao estruturada.

O Que Vamos Construir

  • Deteccao de mudancas baseada em hash SHA-256
  • Compressao de arquivos com gzip via flate2
  • Manifesto JSON com metadados de cada backup
  • Copia incremental (apenas arquivos novos ou modificados)
  • Restauracao de backup a partir do manifesto
  • Relatorio de operacoes com estatisticas

Estrutura do Projeto

backup-tool/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── hasher.rs
    ├── compressor.rs
    └── manifesto.rs

Configurando o Projeto

cargo new backup-tool
cd backup-tool

Edite o Cargo.toml:

[package]
name = "backup-tool"
version = "0.1.0"
edition = "2021"

[dependencies]
sha2 = "0.10"
flate2 = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
clap = { version = "4.5", features = ["derive"] }
colored = "2.1"
walkdir = "2.5"

Passo 1: Calculo de Hash dos Arquivos

O modulo hasher calcula o SHA-256 de cada arquivo para detectar se houve mudancas. Crie src/hasher.rs:

use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;

/// Calcula o hash SHA-256 de um arquivo e retorna como string hexadecimal.
pub fn calcular_hash(caminho: &Path) -> Result<String, std::io::Error> {
    let arquivo = File::open(caminho)?;
    let mut leitor = BufReader::new(arquivo);
    let mut hasher = Sha256::new();

    let mut buffer = [0u8; 8192];
    loop {
        let bytes_lidos = leitor.read(&mut buffer)?;
        if bytes_lidos == 0 {
            break;
        }
        hasher.update(&buffer[..bytes_lidos]);
    }

    let resultado = hasher.finalize();
    Ok(format!("{:x}", resultado))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn teste_hash_consistente() {
        let dir = std::env::temp_dir().join("teste_hash_backup");
        std::fs::create_dir_all(&dir).unwrap();
        let caminho = dir.join("teste.txt");

        let mut arquivo = File::create(&caminho).unwrap();
        arquivo.write_all(b"conteudo de teste").unwrap();

        let hash1 = calcular_hash(&caminho).unwrap();
        let hash2 = calcular_hash(&caminho).unwrap();

        assert_eq!(hash1, hash2);
        assert_eq!(hash1.len(), 64); // SHA-256 = 64 caracteres hex

        std::fs::remove_dir_all(&dir).unwrap();
    }
}

O hasher le o arquivo em blocos de 8KB para manter o uso de memoria baixo, mesmo com arquivos grandes. O hash SHA-256 garante que qualquer alteracao minima no conteudo seja detectada.

Passo 2: Compressao de Arquivos

O compressor reduz o tamanho dos arquivos no backup. Crie src/compressor.rs:

use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::path::Path;

/// Comprime um arquivo usando gzip e salva com extensao .gz.
pub fn comprimir(origem: &Path, destino: &Path) -> io::Result<u64> {
    let arquivo_origem = File::open(origem)?;
    let leitor = BufReader::new(arquivo_origem);

    let arquivo_destino = File::create(destino)?;
    let escritor = BufWriter::new(arquivo_destino);

    let mut encoder = GzEncoder::new(escritor, Compression::default());

    let mut leitor_buf = BufReader::new(leitor);
    let mut buffer = [0u8; 8192];
    let mut total_bytes = 0u64;

    loop {
        let bytes_lidos = leitor_buf.read(&mut buffer)?;
        if bytes_lidos == 0 {
            break;
        }
        encoder.write_all(&buffer[..bytes_lidos])?;
        total_bytes += bytes_lidos as u64;
    }

    encoder.finish()?;
    Ok(total_bytes)
}

/// Descomprime um arquivo gzip.
pub fn descomprimir(origem: &Path, destino: &Path) -> io::Result<u64> {
    let arquivo_origem = File::open(origem)?;
    let decoder = GzDecoder::new(BufReader::new(arquivo_origem));

    let arquivo_destino = File::create(destino)?;
    let mut escritor = BufWriter::new(arquivo_destino);

    let mut leitor = BufReader::new(decoder);
    let mut buffer = [0u8; 8192];
    let mut total_bytes = 0u64;

    loop {
        let bytes_lidos = leitor.read(&mut buffer)?;
        if bytes_lidos == 0 {
            break;
        }
        escritor.write_all(&buffer[..bytes_lidos])?;
        total_bytes += bytes_lidos as u64;
    }

    escritor.flush()?;
    Ok(total_bytes)
}

A compressao gzip oferece um bom equilibrio entre taxa de compressao e velocidade. Arquivos de texto, logs e codigos-fonte costumam ter reducoes de 60-80% no tamanho.

Passo 3: Manifesto de Backup

O manifesto registra quais arquivos foram copiados e seus hashes, permitindo backups incrementais futuros. Crie src/manifesto.rs:

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;

/// Metadados de um arquivo no backup.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EntradaArquivo {
    pub caminho_relativo: String,
    pub hash: String,
    pub tamanho_original: u64,
    pub tamanho_comprimido: u64,
    pub data_backup: DateTime<Utc>,
}

/// Manifesto completo de um backup.
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifesto {
    pub versao: String,
    pub data_criacao: DateTime<Utc>,
    pub diretorio_origem: String,
    pub total_arquivos: usize,
    pub tamanho_total_original: u64,
    pub tamanho_total_comprimido: u64,
    pub arquivos: HashMap<String, EntradaArquivo>,
}

impl Manifesto {
    /// Cria um novo manifesto vazio.
    pub fn new(diretorio_origem: &str) -> Self {
        Manifesto {
            versao: "1.0".to_string(),
            data_criacao: Utc::now(),
            diretorio_origem: diretorio_origem.to_string(),
            total_arquivos: 0,
            tamanho_total_original: 0,
            tamanho_total_comprimido: 0,
            arquivos: HashMap::new(),
        }
    }

    /// Adiciona uma entrada de arquivo ao manifesto.
    pub fn adicionar_entrada(&mut self, entrada: EntradaArquivo) {
        self.tamanho_total_original += entrada.tamanho_original;
        self.tamanho_total_comprimido += entrada.tamanho_comprimido;
        self.total_arquivos += 1;
        self.arquivos
            .insert(entrada.caminho_relativo.clone(), entrada);
    }

    /// Salva o manifesto em um arquivo JSON.
    pub fn salvar(&self, caminho: &Path) -> Result<(), Box<dyn std::error::Error>> {
        let json = serde_json::to_string_pretty(self)?;
        fs::write(caminho, json)?;
        Ok(())
    }

    /// Carrega um manifesto de um arquivo JSON.
    pub fn carregar(caminho: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        let conteudo = fs::read_to_string(caminho)?;
        let manifesto: Manifesto = serde_json::from_str(&conteudo)?;
        Ok(manifesto)
    }

    /// Verifica se um arquivo precisa ser atualizado no backup.
    pub fn arquivo_mudou(&self, caminho_relativo: &str, hash_atual: &str) -> bool {
        match self.arquivos.get(caminho_relativo) {
            Some(entrada) => entrada.hash != hash_atual,
            None => true, // Arquivo novo
        }
    }
}

O manifesto e a peca central do backup incremental. Ao comparar os hashes do manifesto anterior com os hashes atuais, o programa sabe exatamente quais arquivos precisam ser copiados novamente.

Passo 4: Juntando Tudo no main.rs

Crie src/main.rs:

mod compressor;
mod hasher;
mod manifesto;

use chrono::Utc;
use clap::{Parser, Subcommand};
use colored::*;
use manifesto::{EntradaArquivo, Manifesto};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[derive(Parser)]
#[command(name = "backup-tool")]
#[command(about = "Ferramenta de backup incremental com compressao")]
struct Argumentos {
    #[command(subcommand)]
    comando: Comando,
}

#[derive(Subcommand)]
enum Comando {
    /// Criar backup de um diretorio
    Backup {
        /// Diretorio de origem
        origem: PathBuf,
        /// Diretorio de destino para o backup
        destino: PathBuf,
    },
    /// Restaurar backup para um diretorio
    Restaurar {
        /// Diretorio do backup (contendo manifesto.json)
        backup: PathBuf,
        /// Diretorio de destino para restauracao
        destino: PathBuf,
    },
    /// Exibir informacoes de um backup existente
    Info {
        /// Diretorio do backup (contendo manifesto.json)
        backup: PathBuf,
    },
}

fn executar_backup(origem: &Path, destino: &Path) -> Result<(), Box<dyn std::error::Error>> {
    println!("{}", "=== Backup Incremental ===".green().bold());
    println!("Origem:  {}", origem.display());
    println!("Destino: {}", destino.display());

    // Criar diretorio de destino
    std::fs::create_dir_all(destino)?;

    // Tentar carregar manifesto anterior
    let caminho_manifesto = destino.join("manifesto.json");
    let manifesto_anterior = Manifesto::carregar(&caminho_manifesto).ok();

    let mut novo_manifesto = Manifesto::new(&origem.to_string_lossy());

    let mut arquivos_copiados = 0u32;
    let mut arquivos_ignorados = 0u32;
    let mut bytes_economizados = 0u64;

    println!("\nAnalisando arquivos...\n");

    // Percorrer todos os arquivos na origem
    for entrada in WalkDir::new(origem)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
    {
        let caminho_absoluto = entrada.path();
        let caminho_relativo = caminho_absoluto
            .strip_prefix(origem)
            .unwrap()
            .to_string_lossy()
            .to_string();

        // Calcular hash do arquivo atual
        let hash = hasher::calcular_hash(caminho_absoluto)?;

        // Verificar se precisa copiar
        let precisa_copiar = match &manifesto_anterior {
            Some(m) => m.arquivo_mudou(&caminho_relativo, &hash),
            None => true,
        };

        let tamanho_original = entrada.metadata()?.len();

        if precisa_copiar {
            // Criar estrutura de diretorios no destino
            let destino_arquivo = destino.join(&caminho_relativo);
            if let Some(pai) = destino_arquivo.parent() {
                std::fs::create_dir_all(pai)?;
            }

            // Comprimir e copiar
            let destino_gz = destino.join(format!("{}.gz", caminho_relativo));
            if let Some(pai) = destino_gz.parent() {
                std::fs::create_dir_all(pai)?;
            }

            compressor::comprimir(caminho_absoluto, &destino_gz)?;
            let tamanho_comprimido = std::fs::metadata(&destino_gz)?.len();

            println!(
                "  {} {} ({} -> {} bytes)",
                "[COPIADO]".green(),
                caminho_relativo,
                tamanho_original,
                tamanho_comprimido
            );

            novo_manifesto.adicionar_entrada(EntradaArquivo {
                caminho_relativo,
                hash,
                tamanho_original,
                tamanho_comprimido,
                data_backup: Utc::now(),
            });

            arquivos_copiados += 1;
        } else {
            // Manter entrada anterior no manifesto
            if let Some(ref m) = manifesto_anterior {
                if let Some(entrada_ant) = m.arquivos.get(&caminho_relativo) {
                    novo_manifesto.adicionar_entrada(entrada_ant.clone());
                }
            }

            bytes_economizados += tamanho_original;
            arquivos_ignorados += 1;
            println!(
                "  {} {} (sem alteracao)",
                "[IGNORADO]".dimmed(),
                caminho_relativo.dimmed()
            );
        }
    }

    // Salvar manifesto
    novo_manifesto.salvar(&caminho_manifesto)?;

    // Relatorio final
    println!("\n{}", "=== Relatorio ===".green().bold());
    println!("Arquivos copiados:   {}", arquivos_copiados.to_string().cyan());
    println!("Arquivos inalterados: {}", arquivos_ignorados.to_string().dimmed());
    println!(
        "Tamanho original:    {:.2} MB",
        novo_manifesto.tamanho_total_original as f64 / 1_048_576.0
    );
    println!(
        "Tamanho comprimido:  {:.2} MB",
        novo_manifesto.tamanho_total_comprimido as f64 / 1_048_576.0
    );
    if novo_manifesto.tamanho_total_original > 0 {
        let taxa = (1.0
            - novo_manifesto.tamanho_total_comprimido as f64
                / novo_manifesto.tamanho_total_original as f64)
            * 100.0;
        println!("Taxa de compressao:  {:.1}%", taxa);
    }
    println!(
        "Dados economizados:  {:.2} MB (arquivos inalterados)",
        bytes_economizados as f64 / 1_048_576.0
    );
    println!("Manifesto salvo em:  {}", caminho_manifesto.display());

    Ok(())
}

fn executar_restauracao(backup: &Path, destino: &Path) -> Result<(), Box<dyn std::error::Error>> {
    println!("{}", "=== Restauracao de Backup ===".green().bold());

    let caminho_manifesto = backup.join("manifesto.json");
    let manifesto = Manifesto::carregar(&caminho_manifesto)?;

    std::fs::create_dir_all(destino)?;

    println!("Restaurando {} arquivos...\n", manifesto.total_arquivos);

    for (caminho_rel, _entrada) in &manifesto.arquivos {
        let origem_gz = backup.join(format!("{}.gz", caminho_rel));
        let destino_arquivo = destino.join(caminho_rel);

        if let Some(pai) = destino_arquivo.parent() {
            std::fs::create_dir_all(pai)?;
        }

        if origem_gz.exists() {
            compressor::descomprimir(&origem_gz, &destino_arquivo)?;
            println!("  {} {}", "[RESTAURADO]".green(), caminho_rel);
        } else {
            println!("  {} {} (arquivo gz nao encontrado)", "[AVISO]".yellow(), caminho_rel);
        }
    }

    println!("\nRestauracao concluida em: {}", destino.display());
    Ok(())
}

fn exibir_info(backup: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let caminho_manifesto = backup.join("manifesto.json");
    let manifesto = Manifesto::carregar(&caminho_manifesto)?;

    println!("{}", "=== Informacoes do Backup ===".green().bold());
    println!("Origem:          {}", manifesto.diretorio_origem);
    println!("Data:            {}", manifesto.data_criacao.format("%Y-%m-%d %H:%M:%S UTC"));
    println!("Total arquivos:  {}", manifesto.total_arquivos);
    println!(
        "Tamanho original:   {:.2} MB",
        manifesto.tamanho_total_original as f64 / 1_048_576.0
    );
    println!(
        "Tamanho comprimido: {:.2} MB",
        manifesto.tamanho_total_comprimido as f64 / 1_048_576.0
    );

    println!("\nArquivos:");
    for (caminho, entrada) in &manifesto.arquivos {
        println!(
            "  {} ({} bytes, hash: {}...)",
            caminho,
            entrada.tamanho_original,
            &entrada.hash[..16]
        );
    }

    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Argumentos::parse();

    match args.comando {
        Comando::Backup { origem, destino } => executar_backup(&origem, &destino)?,
        Comando::Restaurar { backup, destino } => executar_restauracao(&backup, &destino)?,
        Comando::Info { backup } => exibir_info(&backup)?,
    }

    Ok(())
}

O programa principal usa subcomandos para organizar as tres operacoes: backup, restaurar e info. O fluxo de backup percorre todos os arquivos, calcula hashes, compara com o manifesto anterior e copia apenas o que mudou.

Como Executar

# Compilar
cargo build --release

# Criar um diretorio de teste com alguns arquivos
mkdir -p /tmp/meus_dados/docs
echo "Relatorio importante" > /tmp/meus_dados/relatorio.txt
echo "Notas de reuniao" > /tmp/meus_dados/docs/notas.txt
echo "fn main() { println!(\"Ola\"); }" > /tmp/meus_dados/app.rs

# Primeiro backup (completo)
cargo run -- backup /tmp/meus_dados /tmp/meu_backup

# Modificar um arquivo
echo "Relatorio atualizado com novos dados" > /tmp/meus_dados/relatorio.txt

# Segundo backup (incremental - so copia o que mudou)
cargo run -- backup /tmp/meus_dados /tmp/meu_backup

# Ver informacoes do backup
cargo run -- info /tmp/meu_backup

# Restaurar backup em novo local
cargo run -- restaurar /tmp/meu_backup /tmp/dados_restaurados

Saida esperada do backup incremental:

=== Backup Incremental ===
Origem:  /tmp/meus_dados
Destino: /tmp/meu_backup

Analisando arquivos...

  [COPIADO] relatorio.txt (36 -> 54 bytes)
  [IGNORADO] docs/notas.txt (sem alteracao)
  [IGNORADO] app.rs (sem alteracao)

=== Relatorio ===
Arquivos copiados:   1
Arquivos inalterados: 2
Tamanho original:    0.00 MB
Tamanho comprimido:  0.00 MB
Taxa de compressao:  12.5%
Dados economizados:  0.00 MB (arquivos inalterados)
Manifesto salvo em:  /tmp/meu_backup/manifesto.json

Desafios para Expandir

  1. Versionamento de backups: Em vez de sobrescrever, crie pastas com timestamp (ex: backup_2026-01-15_103000/) e permita restaurar versoes anteriores especificas.
  2. Backup remoto: Adicione suporte para enviar o backup para um servidor remoto via SCP/SFTP usando a crate ssh2, ou para armazenamento S3 usando aws-sdk-s3.
  3. Exclusao por padrao: Implemente opcoes --excluir e um arquivo .backupignore com padroes glob para ignorar arquivos indesejados como *.tmp ou node_modules/.
  4. Verificacao de integridade: Adicione um comando verificar que recalcula os hashes de todos os arquivos no backup e compara com o manifesto para detectar corrupcao.
  5. Criptografia: Implemente criptografia AES-256 dos arquivos de backup usando a crate aes-gcm, protegendo dados sensiveis com uma senha fornecida pelo usuario.

Veja Tambem