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 <noreply@anthropic.com>
This commit is contained in:
330
src/archive.rs
330
src/archive.rs
@@ -1,2 +1,328 @@
|
|||||||
// Pack, unpack, and inspect archive orchestration.
|
use std::fs;
|
||||||
// Will be implemented in Plan 02-02.
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ProcessedFile> = 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<u32> = 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::<String>()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" HMAC: {}",
|
||||||
|
entry.hmac.iter().map(|b| format!("{:02x}", b)).collect::<String>()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" SHA-256: {}",
|
||||||
|
entry.sha256.iter().map(|b| format!("{:02x}", b)).collect::<String>()
|
||||||
|
);
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
21
src/main.rs
21
src/main.rs
@@ -17,27 +17,18 @@ fn main() -> anyhow::Result<()> {
|
|||||||
output,
|
output,
|
||||||
no_compress,
|
no_compress,
|
||||||
} => {
|
} => {
|
||||||
println!(
|
archive::pack(&files, &output, &no_compress)?;
|
||||||
"Pack: {} files -> {:?} (no_compress: {:?})",
|
|
||||||
files.len(),
|
|
||||||
output,
|
|
||||||
no_compress
|
|
||||||
);
|
|
||||||
println!("Not implemented yet");
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
Commands::Unpack {
|
Commands::Unpack {
|
||||||
archive,
|
archive,
|
||||||
output_dir,
|
output_dir,
|
||||||
} => {
|
} => {
|
||||||
println!("Unpack: {:?} -> {:?}", archive, output_dir);
|
archive::unpack(&archive, &output_dir)?;
|
||||||
println!("Not implemented yet");
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
Commands::Inspect { archive } => {
|
Commands::Inspect { archive } => {
|
||||||
println!("Inspect: {:?}", archive);
|
archive::inspect(&archive)?;
|
||||||
println!("Not implemented yet");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user