From be507070b68b899effa85486656dec7a3ea1d655 Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Wed, 25 Feb 2026 00:03:28 +0300 Subject: [PATCH] feat(02-02): implement pack, unpack, and inspect archive commands - pack: two-pass algorithm reads/hashes/compresses/encrypts files then writes header+TOC+data - inspect: reads header and TOC, displays all metadata (sizes, offsets, IVs, HMACs, SHA-256) - unpack: HMAC-first verification, AES-256-CBC decryption, optional gzip decompression, SHA-256 check - CLI dispatch wired from main.rs to archive module - Directory traversal protection: rejects filenames starting with / or containing .. - Compression auto-detection: .apk/.zip/etc stored without gzip (flags=0x00 when no file compressed) - Round-trip verified: pack+unpack produces byte-identical files - HMAC tamper detection verified: flipped ciphertext byte triggers rejection Co-Authored-By: Claude Opus 4.6 --- src/archive.rs | 330 ++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 19 +-- 2 files changed, 333 insertions(+), 16 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index 458cf60..8221da8 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -1,2 +1,328 @@ -// Pack, unpack, and inspect archive orchestration. -// Will be implemented in Plan 02-02. +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use crate::compression; +use crate::crypto; +use crate::format::{self, Header, TocEntry, HEADER_SIZE}; +use crate::key::KEY; + +/// Processed file data collected during Pass 1 of pack. +struct ProcessedFile { + name: String, + original_size: u32, + compressed_size: u32, + encrypted_size: u32, + iv: [u8; 16], + hmac: [u8; 32], + sha256: [u8; 32], + compression_flag: u8, + ciphertext: Vec, +} + +/// Pack files into an encrypted archive. +/// +/// Two-pass algorithm: +/// Pass 1: Read, hash, compress, encrypt each file. +/// Pass 2: Compute offsets, write header + TOC + data blocks. +pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> { + anyhow::ensure!(!files.is_empty(), "No input files specified"); + + // --- Pass 1: Process all files --- + let mut processed: Vec = Vec::with_capacity(files.len()); + + for file_path in files { + let data = fs::read(file_path)?; + + // Validate file size <= u32::MAX + anyhow::ensure!( + data.len() <= u32::MAX as usize, + "File too large: {} ({} bytes exceeds 4 GB limit)", + file_path.display(), + data.len() + ); + + // Use just the filename (not the full path) as the archive entry name + let name = file_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", file_path.display()))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 filename: {}", file_path.display()))? + .to_string(); + + // Step 1: SHA-256 of original data + let sha256 = crypto::sha256_hash(&data); + + // Step 2: Determine compression and compress if needed + let should_compress = compression::should_compress(&name, no_compress); + let (compressed_data, compression_flag) = if should_compress { + let compressed = compression::compress(&data)?; + (compressed, 1u8) + } else { + (data.clone(), 0u8) + }; + + let original_size = data.len() as u32; + let compressed_size = compressed_data.len() as u32; + + // Step 3: Generate random IV + let iv = crypto::generate_iv(); + + // Step 4: Encrypt + let ciphertext = crypto::encrypt_data(&compressed_data, &KEY, &iv); + let encrypted_size = ciphertext.len() as u32; + + // Step 5: Compute HMAC over IV || ciphertext + let hmac = crypto::compute_hmac(&KEY, &iv, &ciphertext); + + processed.push(ProcessedFile { + name, + original_size, + compressed_size, + encrypted_size, + iv, + hmac, + sha256, + compression_flag, + ciphertext, + }); + } + + // --- Pass 2: Compute offsets and write archive --- + + // Build TOC entries (without data_offset yet) to compute TOC size + let toc_size: u32 = processed + .iter() + .map(|pf| 101 + pf.name.len() as u32) + .sum(); + + let toc_offset = HEADER_SIZE; + + // Compute data offsets + let mut data_offsets: Vec = Vec::with_capacity(processed.len()); + let mut current_offset = toc_offset + toc_size; + for pf in &processed { + data_offsets.push(current_offset); + current_offset += pf.encrypted_size; + } + + // Determine flags byte: bit 0 if any file is compressed + let any_compressed = processed.iter().any(|pf| pf.compression_flag == 1); + let flags: u8 = if any_compressed { 0x01 } else { 0x00 }; + + // Create header + let header = Header { + version: format::VERSION, + flags, + file_count: processed.len() as u16, + toc_offset, + toc_size, + toc_iv: [0u8; 16], // TOC not encrypted in v1 Phase 2 + reserved: [0u8; 8], + }; + + // Open output file + let mut out_file = fs::File::create(output)?; + + // Write header + format::write_header(&mut out_file, &header)?; + + // Write TOC entries + for (i, pf) in processed.iter().enumerate() { + let entry = TocEntry { + name: pf.name.clone(), + original_size: pf.original_size, + compressed_size: pf.compressed_size, + encrypted_size: pf.encrypted_size, + data_offset: data_offsets[i], + iv: pf.iv, + hmac: pf.hmac, + sha256: pf.sha256, + compression_flag: pf.compression_flag, + padding_after: 0, // No decoy padding in Phase 2 + }; + format::write_toc_entry(&mut out_file, &entry)?; + } + + // Write data blocks + for pf in &processed { + out_file.write_all(&pf.ciphertext)?; + } + + let total_bytes = current_offset; + println!( + "Packed {} files into {} ({} bytes)", + processed.len(), + output.display(), + total_bytes + ); + + Ok(()) +} + +/// Inspect archive metadata without decryption. +/// +/// Reads and displays the header and all TOC entries. +pub fn inspect(archive: &Path) -> anyhow::Result<()> { + let mut file = fs::File::open(archive)?; + + // Read header + let header = format::read_header(&mut file)?; + + // Read TOC entries + let entries = format::read_toc(&mut file, header.file_count)?; + + // Print header info + let filename = archive + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| archive.display().to_string()); + + println!("Archive: {}", filename); + println!("Version: {}", header.version); + println!("Flags: 0x{:02X}", header.flags); + println!("Files: {}", header.file_count); + println!("TOC offset: {}", header.toc_offset); + println!("TOC size: {}", header.toc_size); + println!(); + + // Print each file entry + let mut total_original: u64 = 0; + for (i, entry) in entries.iter().enumerate() { + let compression_str = if entry.compression_flag == 1 { + "yes" + } else { + "no" + }; + + println!("[{}] {}", i, entry.name); + println!(" Original: {} bytes", entry.original_size); + println!(" Compressed: {} bytes", entry.compressed_size); + println!(" Encrypted: {} bytes", entry.encrypted_size); + println!(" Offset: {}", entry.data_offset); + println!(" Compression: {}", compression_str); + println!( + " IV: {}", + entry.iv.iter().map(|b| format!("{:02x}", b)).collect::() + ); + println!( + " HMAC: {}", + entry.hmac.iter().map(|b| format!("{:02x}", b)).collect::() + ); + println!( + " SHA-256: {}", + entry.sha256.iter().map(|b| format!("{:02x}", b)).collect::() + ); + + total_original += entry.original_size as u64; + } + + println!(); + println!("Total original size: {} bytes", total_original); + + Ok(()) +} + +/// Unpack an encrypted archive, extracting all files with HMAC and SHA-256 verification. +/// +/// Follows FORMAT.md Section 10 decode order: +/// 1. Read header (validates magic, version, flags) +/// 2. Read TOC entries +/// 3. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write +pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> { + let mut file = fs::File::open(archive)?; + + // Read header + let header = format::read_header(&mut file)?; + + // Read TOC entries + let entries = format::read_toc(&mut file, header.file_count)?; + + // Create output directory + fs::create_dir_all(output_dir)?; + + let file_count = entries.len(); + let mut error_count: usize = 0; + let mut success_count: usize = 0; + + for entry in &entries { + // Sanitize filename: reject directory traversal + if entry.name.starts_with('/') || entry.name.contains("..") { + eprintln!( + "Skipping file with unsafe name: {} (directory traversal attempt)", + entry.name + ); + error_count += 1; + continue; + } + + // Seek to data_offset and read ciphertext + file.seek(SeekFrom::Start(entry.data_offset as u64))?; + let mut ciphertext = vec![0u8; entry.encrypted_size as usize]; + file.read_exact(&mut ciphertext)?; + + // Step 1: Verify HMAC FIRST (encrypt-then-MAC) + if !crypto::verify_hmac(&KEY, &entry.iv, &ciphertext, &entry.hmac) { + eprintln!("HMAC verification failed for {}, skipping", entry.name); + error_count += 1; + continue; + } + + // Step 2: Decrypt + let decrypted = match crypto::decrypt_data(&ciphertext, &KEY, &entry.iv) { + Ok(data) => data, + Err(e) => { + eprintln!("Decryption failed for {}: {}", entry.name, e); + error_count += 1; + continue; + } + }; + + // Step 3: Decompress if compressed + let decompressed = if entry.compression_flag == 1 { + match compression::decompress(&decrypted) { + Ok(data) => data, + Err(e) => { + eprintln!("Decompression failed for {}: {}", entry.name, e); + error_count += 1; + continue; + } + } + } else { + decrypted + }; + + // Step 4: Verify SHA-256 + let computed_sha256 = crypto::sha256_hash(&decompressed); + if computed_sha256 != entry.sha256 { + eprintln!( + "SHA-256 mismatch for {} (data may be corrupted)", + entry.name + ); + error_count += 1; + // Still write the file per spec + } + + // Step 5: Create parent directories if name contains path separators + let output_path = output_dir.join(&entry.name); + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + + // Step 6: Write file + fs::write(&output_path, &decompressed)?; + println!("Extracted: {} ({} bytes)", entry.name, entry.original_size); + success_count += 1; + } + + println!( + "Extracted {}/{} files", + success_count, file_count + ); + + if error_count > 0 { + anyhow::bail!("{} file(s) had verification errors", error_count); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 31ef369..073bfdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,27 +17,18 @@ fn main() -> anyhow::Result<()> { output, no_compress, } => { - println!( - "Pack: {} files -> {:?} (no_compress: {:?})", - files.len(), - output, - no_compress - ); - println!("Not implemented yet"); - Ok(()) + archive::pack(&files, &output, &no_compress)?; } Commands::Unpack { archive, output_dir, } => { - println!("Unpack: {:?} -> {:?}", archive, output_dir); - println!("Not implemented yet"); - Ok(()) + archive::unpack(&archive, &output_dir)?; } Commands::Inspect { archive } => { - println!("Inspect: {:?}", archive); - println!("Not implemented yet"); - Ok(()) + archive::inspect(&archive)?; } } + + Ok(()) }