From 7820c18622e39ed9f397852f3022940396d615ec Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Thu, 26 Feb 2026 21:47:15 +0300 Subject: [PATCH] feat(08-01): add directory support to pack/unpack/inspect - Implement collect_entries() with recursive directory traversal (DFS preorder) - pack() handles mixed file and directory arguments with relative paths - Directory entries stored with entry_type=1, zero-length crypto fields - unpack() creates directory hierarchy and restores Unix mode bits - inspect() displays entry type (dir/file) and octal permissions - Update cli.rs doc comments for directory support Co-Authored-By: Claude Opus 4.6 --- src/archive.rs | 396 +++++++++++++++++++++++++++++++++++-------------- src/cli.rs | 4 +- 2 files changed, 286 insertions(+), 114 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index 2d16bdf..cd05f69 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -3,6 +3,7 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use rand::Rng; +use std::os::unix::fs::PermissionsExt; use crate::compression; use crate::crypto; @@ -52,84 +53,211 @@ fn read_archive_metadata(file: &mut fs::File) -> anyhow::Result<(Header, Vec anyhow::Result { + let metadata = fs::metadata(path)?; + Ok((metadata.permissions().mode() & 0o7777) as u16) +} + +/// Process a single file through the crypto pipeline, returning a ProcessedFile. +fn process_file( + file_path: &Path, + name: String, + permissions: u16, + no_compress: &[String], + rng: &mut impl Rng, +) -> anyhow::Result { + 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() + ); + + // 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); + + // Step 6: Generate decoy padding (FORMAT.md Section 9.3) + let padding_after: u16 = rng.random_range(64..=4096); + let mut padding_bytes = vec![0u8; padding_after as usize]; + rand::Fill::fill(&mut padding_bytes[..], rng); + + Ok(ProcessedFile { + name, + entry_type: 0, + permissions, + original_size, + compressed_size, + encrypted_size, + iv, + hmac, + sha256, + compression_flag, + ciphertext, + padding_after, + padding_bytes, + }) +} + +/// Create a ProcessedFile for a directory entry (no data block). +fn make_directory_entry(name: String, permissions: u16) -> ProcessedFile { + ProcessedFile { + name, + entry_type: 1, + permissions, + original_size: 0, + compressed_size: 0, + encrypted_size: 0, + iv: [0u8; 16], + hmac: [0u8; 32], + sha256: [0u8; 32], + compression_flag: 0, + ciphertext: Vec::new(), + padding_after: 0, + padding_bytes: Vec::new(), + } +} + +/// Recursively collect all entries (directories and files) from a directory path. +/// +/// Entries are emitted in parent-before-child order (DFS preorder). +/// The base_name is the top-level directory name used as prefix for all relative paths. +fn collect_directory_entries( + dir_path: &Path, + base_name: &str, + no_compress: &[String], + rng: &mut impl Rng, +) -> anyhow::Result> { + let mut entries = Vec::new(); + + // Add the directory itself first (parent-before-child) + let dir_perms = get_permissions(dir_path)?; + entries.push(make_directory_entry(base_name.to_string(), dir_perms)); + + // Collect children sorted by name for deterministic ordering + let mut children: Vec = fs::read_dir(dir_path)? + .collect::, _>>()?; + children.sort_by_key(|e| e.file_name()); + + for child in children { + let child_path = child.path(); + let child_name = format!( + "{}/{}", + base_name, + child.file_name().to_str() + .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 filename: {}", child_path.display()))? + ); + + if child_path.is_dir() { + // Recurse into subdirectory + let sub_entries = collect_directory_entries( + &child_path, + &child_name, + no_compress, + rng, + )?; + entries.extend(sub_entries); + } else { + // Process file + let file_perms = get_permissions(&child_path)?; + let pf = process_file(&child_path, child_name, file_perms, no_compress, rng)?; + entries.push(pf); + } + } + + Ok(entries) +} + +/// Collect all entries from input paths (files and directories). +/// +/// For files: processes through crypto pipeline with filename-only name. +/// For directories: recursively collects all children with relative paths. +fn collect_entries( + inputs: &[PathBuf], + no_compress: &[String], + rng: &mut impl Rng, +) -> anyhow::Result> { + let mut processed = Vec::new(); + + for input_path in inputs { + if input_path.is_dir() { + // Get the directory's own name for the archive prefix + let dir_name = input_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid directory path: {}", input_path.display()))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 directory name: {}", input_path.display()))? + .to_string(); + + let dir_entries = collect_directory_entries( + input_path, + &dir_name, + no_compress, + rng, + )?; + processed.extend(dir_entries); + } else { + // Single file: use just the filename + let name = input_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", input_path.display()))? + .to_str() + .ok_or_else(|| anyhow::anyhow!("Non-UTF-8 filename: {}", input_path.display()))? + .to_string(); + + let file_perms = get_permissions(input_path)?; + let pf = process_file(input_path, name, file_perms, no_compress, rng)?; + processed.push(pf); + } + } + + Ok(processed) +} + +/// Pack files and directories into an encrypted archive. /// /// Two-pass algorithm with full obfuscation: /// Pass 1: Read, hash, compress, encrypt each file; generate decoy padding. +/// Directories are stored as zero-length entries. /// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive. pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> { anyhow::ensure!(!files.is_empty(), "No input files specified"); let mut rng = rand::rng(); - // --- Pass 1: Process all files --- - let mut processed: Vec = Vec::with_capacity(files.len()); + // --- Pass 1: Collect and process all entries --- + let processed = collect_entries(files, no_compress, &mut rng)?; - for file_path in files { - let data = fs::read(file_path)?; + anyhow::ensure!(!processed.is_empty(), "No entries to archive"); - // 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); - - // Step 6: Generate decoy padding (FORMAT.md Section 9.3) - let padding_after: u16 = rng.random_range(64..=4096); - let mut padding_bytes = vec![0u8; padding_after as usize]; - rand::Fill::fill(&mut padding_bytes[..], &mut rng); - - processed.push(ProcessedFile { - name, - entry_type: 0, - permissions: 0o644, - original_size, - compressed_size, - encrypted_size, - iv, - hmac, - sha256, - compression_flag, - ciphertext, - padding_after, - padding_bytes, - }); - } + // Count files and directories + let file_count = processed.iter().filter(|pf| pf.entry_type == 0).count(); + let dir_count = processed.iter().filter(|pf| pf.entry_type == 1).count(); // --- Pass 2: Compute offsets and write archive --- @@ -171,12 +299,18 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow: let toc_offset = HEADER_SIZE; // Compute data offsets (accounting for encrypted TOC size and padding) + // Directory entries are skipped (no data block). let data_block_start = toc_offset + encrypted_toc_size; let mut data_offsets: Vec = Vec::with_capacity(processed.len()); let mut current_offset = data_block_start; for pf in &processed { - data_offsets.push(current_offset); - current_offset += pf.encrypted_size + pf.padding_after as u32; + if pf.entry_type == 1 { + // Directory: no data block, offset is 0 + data_offsets.push(0); + } else { + data_offsets.push(current_offset); + current_offset += pf.encrypted_size + pf.padding_after as u32; + } } // Now re-serialize TOC with correct data_offsets @@ -233,16 +367,21 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow: // Write encrypted TOC out_file.write_all(&final_encrypted_toc)?; - // Write data blocks with interleaved decoy padding + // Write data blocks with interleaved decoy padding (skip directory entries) for pf in &processed { + if pf.entry_type == 1 { + continue; // directories have no data block + } out_file.write_all(&pf.ciphertext)?; out_file.write_all(&pf.padding_bytes)?; } let total_bytes = current_offset; println!( - "Packed {} files into {} ({} bytes)", + "Packed {} entries ({} files, {} directories) into {} ({} bytes)", processed.len(), + file_count, + dir_count, output.display(), total_bytes ); @@ -269,41 +408,49 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> { println!("Archive: {}", filename); println!("Version: {}", header.version); println!("Flags: 0x{:02X}", header.flags); - println!("Files: {}", header.file_count); + println!("Entries: {}", header.file_count); println!("TOC offset: {}", header.toc_offset); println!("TOC size: {}", header.toc_size); println!(); - // Print each file entry + // Print each 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" - }; + let type_str = if entry.entry_type == 1 { "dir" } else { "file" }; + let perms_str = format!("{:04o}", entry.permissions); - 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!(" Padding after: {} bytes", entry.padding_after); - 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::() - ); + println!("[{}] {} ({}, {})", i, entry.name, type_str, perms_str); + println!(" Permissions: {}", perms_str); - total_original += entry.original_size as u64; + if entry.entry_type == 0 { + // File entry: show size and crypto details + let compression_str = if entry.compression_flag == 1 { + "yes" + } else { + "no" + }; + + 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!(" Padding after: {} bytes", entry.padding_after); + 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!(); @@ -312,12 +459,15 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> { Ok(()) } -/// Unpack an encrypted archive, extracting all files with HMAC and SHA-256 verification. +/// Unpack an encrypted archive, extracting all files and directories with +/// HMAC and SHA-256 verification, and Unix permission restoration. /// /// Follows FORMAT.md Section 10 decode order: /// 1. Read header with XOR bootstrapping /// 2. Read and decrypt TOC entries -/// 3. For each file: seek to data_offset, verify HMAC, decrypt, decompress, verify SHA-256, write +/// 3. For each entry: +/// - Directory: create directory, set permissions +/// - File: seek to data_offset, verify HMAC, decrypt, decompress, verify SHA-256, write, set permissions pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> { let mut file = fs::File::open(archive)?; @@ -327,7 +477,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> { // Create output directory fs::create_dir_all(output_dir)?; - let file_count = entries.len(); + let entry_count = entries.len(); let mut error_count: usize = 0; let mut success_count: usize = 0; @@ -335,13 +485,34 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> { // Sanitize filename: reject directory traversal if entry.name.starts_with('/') || entry.name.contains("..") { eprintln!( - "Skipping file with unsafe name: {} (directory traversal attempt)", + "Skipping entry with unsafe name: {} (directory traversal attempt)", entry.name ); error_count += 1; continue; } + let output_path = output_dir.join(&entry.name); + + if entry.entry_type == 1 { + // Directory entry: create and set permissions + fs::create_dir_all(&output_path)?; + fs::set_permissions( + &output_path, + fs::Permissions::from_mode(entry.permissions as u32), + )?; + println!("Created directory: {}", entry.name); + success_count += 1; + continue; + } + + // File entry: extract with full verification pipeline + + // Create parent directories if name contains path separators + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + // 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]; @@ -389,25 +560,26 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> { // 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 + // Step 5: Write file fs::write(&output_path, &decompressed)?; + + // Step 6: Set file permissions + fs::set_permissions( + &output_path, + fs::Permissions::from_mode(entry.permissions as u32), + )?; + println!("Extracted: {} ({} bytes)", entry.name, entry.original_size); success_count += 1; } println!( - "Extracted {}/{} files", - success_count, file_count + "Extracted {}/{} entries", + success_count, entry_count ); if error_count > 0 { - anyhow::bail!("{} file(s) had verification errors", error_count); + anyhow::bail!("{} entry(ies) had verification errors", error_count); } Ok(()) diff --git a/src/cli.rs b/src/cli.rs index d411b42..2369d15 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,9 +11,9 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Pack files into an encrypted archive + /// Pack files and directories into an encrypted archive Pack { - /// Input files to archive + /// Input files and directories to archive #[arg(required = true)] files: Vec, /// Output archive file