diff --git a/src/archive.rs b/src/archive.rs index a102565..2d16bdf 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -12,6 +12,8 @@ use crate::key::KEY; /// Processed file data collected during Pass 1 of pack. struct ProcessedFile { name: String, + entry_type: u8, // 0x00 = file, 0x01 = directory + permissions: u16, // Lower 12 bits of POSIX mode_t original_size: u32, compressed_size: u32, encrypted_size: u32, @@ -114,6 +116,8 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow: processed.push(ProcessedFile { name, + entry_type: 0, + permissions: 0o644, original_size, compressed_size, encrypted_size, @@ -142,6 +146,8 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow: .iter() .map(|pf| TocEntry { name: pf.name.clone(), + entry_type: pf.entry_type, + permissions: pf.permissions, original_size: pf.original_size, compressed_size: pf.compressed_size, encrypted_size: pf.encrypted_size, @@ -179,6 +185,8 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow: .enumerate() .map(|(i, pf)| TocEntry { name: pf.name.clone(), + entry_type: pf.entry_type, + permissions: pf.permissions, original_size: pf.original_size, compressed_size: pf.compressed_size, encrypted_size: pf.encrypted_size, diff --git a/src/format.rs b/src/format.rs index 32627ad..7dc3a4e 100644 --- a/src/format.rs +++ b/src/format.rs @@ -3,8 +3,8 @@ use std::io::{Cursor, Read, Write}; /// Custom magic bytes: leading 0x00 signals binary, remaining bytes are unrecognized. pub const MAGIC: [u8; 4] = [0x00, 0xEA, 0x72, 0x63]; -/// Format version for this specification (v1). -pub const VERSION: u8 = 1; +/// Format version for this specification (v1.1 directory support). +pub const VERSION: u8 = 2; /// Fixed header size in bytes. pub const HEADER_SIZE: u32 = 40; @@ -24,10 +24,12 @@ pub struct Header { pub reserved: [u8; 8], } -/// File table entry (variable length: 101 + name_length bytes). +/// File table entry (variable length: 104 + name_length bytes). #[derive(Debug, Clone)] pub struct TocEntry { pub name: String, + pub entry_type: u8, // 0x00 = file, 0x01 = directory + pub permissions: u16, // Lower 12 bits of POSIX mode_t pub original_size: u32, pub compressed_size: u32, pub encrypted_size: u32, @@ -58,14 +60,17 @@ pub fn write_header(writer: &mut impl Write, header: &Header) -> anyhow::Result< /// Write a single TOC entry to the writer. /// -/// Field order matches FORMAT.md Section 5: -/// name_length(2 LE) | name(N) | original_size(4 LE) | compressed_size(4 LE) | +/// Field order matches FORMAT.md Section 5 (v1.1): +/// name_length(2 LE) | name(N) | entry_type(1) | permissions(2 LE) | +/// original_size(4 LE) | compressed_size(4 LE) | /// encrypted_size(4 LE) | data_offset(4 LE) | iv(16) | hmac(32) | sha256(32) | /// compression_flag(1) | padding_after(2 LE) pub fn write_toc_entry(writer: &mut impl Write, entry: &TocEntry) -> anyhow::Result<()> { let name_bytes = entry.name.as_bytes(); writer.write_all(&(name_bytes.len() as u16).to_le_bytes())?; writer.write_all(name_bytes)?; + writer.write_all(&[entry.entry_type])?; + writer.write_all(&entry.permissions.to_le_bytes())?; writer.write_all(&entry.original_size.to_le_bytes())?; writer.write_all(&entry.compressed_size.to_le_bytes())?; writer.write_all(&entry.encrypted_size.to_le_bytes())?; @@ -107,7 +112,7 @@ pub fn write_header_to_buf(header: &Header) -> [u8; 40] { /// Parse a header from a 40-byte buffer (already validated for magic). /// -/// Verifies: version == 1, reserved flags bits 4-7 are zero. +/// Verifies: version == 2, reserved flags bits 4-7 are zero. fn parse_header_from_buf(buf: &[u8; 40]) -> anyhow::Result
{ let version = buf[4]; anyhow::ensure!(version == VERSION, "Unsupported version: {}", version); @@ -186,7 +191,7 @@ pub fn read_toc_from_buf(buf: &[u8], file_count: u16) -> anyhow::Result anyhow::Result
{ let mut buf = [0u8; 40]; reader.read_exact(&mut buf)?; @@ -245,6 +250,15 @@ pub fn read_toc_entry(reader: &mut impl Read) -> anyhow::Result { let name = String::from_utf8(name_bytes) .map_err(|e| anyhow::anyhow!("Invalid UTF-8 filename: {}", e))?; + // entry_type (u8) + let mut buf1 = [0u8; 1]; + reader.read_exact(&mut buf1)?; + let entry_type = buf1[0]; + + // permissions (u16 LE) + reader.read_exact(&mut buf2)?; + let permissions = u16::from_le_bytes(buf2); + // original_size (u32 LE) let mut buf4 = [0u8; 4]; reader.read_exact(&mut buf4)?; @@ -275,7 +289,6 @@ pub fn read_toc_entry(reader: &mut impl Read) -> anyhow::Result { reader.read_exact(&mut sha256)?; // compression_flag (u8) - let mut buf1 = [0u8; 1]; reader.read_exact(&mut buf1)?; let compression_flag = buf1[0]; @@ -285,6 +298,8 @@ pub fn read_toc_entry(reader: &mut impl Read) -> anyhow::Result { Ok(TocEntry { name, + entry_type, + permissions, original_size, compressed_size, encrypted_size, @@ -308,9 +323,10 @@ pub fn read_toc(reader: &mut impl Read, file_count: u16) -> anyhow::Result u32 { - 101 + entry.name.len() as u32 + 104 + entry.name.len() as u32 } /// Compute the total serialized size of all TOC entries. @@ -326,7 +342,7 @@ mod tests { #[test] fn test_header_write_read_roundtrip() { let header = Header { - version: 1, + version: 2, flags: 0x01, file_count: 3, toc_offset: HEADER_SIZE, @@ -355,6 +371,8 @@ mod tests { fn test_toc_entry_roundtrip_ascii() { let entry = TocEntry { name: "hello.txt".to_string(), + entry_type: 0, + permissions: 0o644, original_size: 5, compressed_size: 25, encrypted_size: 32, @@ -368,12 +386,14 @@ mod tests { let mut buf = Vec::new(); write_toc_entry(&mut buf, &entry).unwrap(); - assert_eq!(buf.len(), 101 + 9); // 101 + "hello.txt".len() + assert_eq!(buf.len(), 104 + 9); // 104 + "hello.txt".len() let mut cursor = Cursor::new(&buf); let read_back = read_toc_entry(&mut cursor).unwrap(); assert_eq!(read_back.name, entry.name); + assert_eq!(read_back.entry_type, entry.entry_type); + assert_eq!(read_back.permissions, entry.permissions); assert_eq!(read_back.original_size, entry.original_size); assert_eq!(read_back.compressed_size, entry.compressed_size); assert_eq!(read_back.encrypted_size, entry.encrypted_size); @@ -390,6 +410,8 @@ mod tests { let name = "\u{0442}\u{0435}\u{0441}\u{0442}\u{043e}\u{0432}\u{044b}\u{0439}_\u{0444}\u{0430}\u{0439}\u{043b}.txt"; let entry = TocEntry { name: name.to_string(), + entry_type: 0, + permissions: 0o644, original_size: 100, compressed_size: 80, encrypted_size: 96, @@ -405,12 +427,14 @@ mod tests { write_toc_entry(&mut buf, &entry).unwrap(); // "тестовый_файл.txt" UTF-8 length let expected_name_len = name.len(); - assert_eq!(buf.len(), 101 + expected_name_len); + assert_eq!(buf.len(), 104 + expected_name_len); let mut cursor = Cursor::new(&buf); let read_back = read_toc_entry(&mut cursor).unwrap(); assert_eq!(read_back.name, name); + assert_eq!(read_back.entry_type, entry.entry_type); + assert_eq!(read_back.permissions, entry.permissions); assert_eq!(read_back.original_size, entry.original_size); assert_eq!(read_back.compressed_size, entry.compressed_size); assert_eq!(read_back.encrypted_size, entry.encrypted_size); @@ -421,6 +445,8 @@ mod tests { fn test_toc_entry_roundtrip_empty_name() { let entry = TocEntry { name: "".to_string(), + entry_type: 0, + permissions: 0o644, original_size: 0, compressed_size: 0, encrypted_size: 16, @@ -449,7 +475,7 @@ mod tests { buf[1] = 0xFF; buf[2] = 0xFF; buf[3] = 0xFF; - buf[4] = 1; // version + buf[4] = 2; // version let mut cursor = Cursor::new(&buf); let result = read_header(&mut cursor); @@ -462,8 +488,8 @@ mod tests { let mut buf = vec![0u8; 40]; // Correct magic buf[0..4].copy_from_slice(&MAGIC); - // Wrong version - buf[4] = 2; + // Wrong version (3 is unsupported) + buf[4] = 3; let mut cursor = Cursor::new(&buf); let result = read_header(&mut cursor); @@ -475,6 +501,8 @@ mod tests { fn test_entry_size_calculation() { let entry_hello = TocEntry { name: "hello.txt".to_string(), // 9 bytes + entry_type: 0, + permissions: 0o644, original_size: 5, compressed_size: 25, encrypted_size: 32, @@ -485,10 +513,12 @@ mod tests { compression_flag: 1, padding_after: 0, }; - assert_eq!(entry_size(&entry_hello), 110); // 101 + 9 + assert_eq!(entry_size(&entry_hello), 113); // 104 + 9 let entry_data = TocEntry { name: "data.bin".to_string(), // 8 bytes + entry_type: 0, + permissions: 0o644, original_size: 32, compressed_size: 22, encrypted_size: 32, @@ -499,16 +529,16 @@ mod tests { compression_flag: 1, padding_after: 0, }; - assert_eq!(entry_size(&entry_data), 109); // 101 + 8 + assert_eq!(entry_size(&entry_data), 112); // 104 + 8 - // FORMAT.md worked example: 110 + 109 = 219 - assert_eq!(compute_toc_size(&[entry_hello, entry_data]), 219); + // FORMAT.md v1.1 worked example: 113 + 112 = 225 + assert_eq!(compute_toc_size(&[entry_hello, entry_data]), 225); } #[test] fn test_xor_roundtrip() { let header = Header { - version: 1, + version: 2, flags: 0x0F, file_count: 2, toc_offset: HEADER_SIZE, @@ -531,7 +561,7 @@ mod tests { #[test] fn test_xor_changes_magic() { let header = Header { - version: 1, + version: 2, flags: 0x0F, file_count: 2, toc_offset: HEADER_SIZE, @@ -554,7 +584,7 @@ mod tests { fn test_read_header_auto_plain() { // Plain (non-XOR'd) header should be parsed correctly let header = Header { - version: 1, + version: 2, flags: 0x01, file_count: 3, toc_offset: HEADER_SIZE, @@ -567,7 +597,7 @@ mod tests { let mut cursor = Cursor::new(buf.as_slice()); let read_back = read_header_auto(&mut cursor).unwrap(); - assert_eq!(read_back.version, 1); + assert_eq!(read_back.version, 2); assert_eq!(read_back.flags, 0x01); assert_eq!(read_back.file_count, 3); } @@ -576,7 +606,7 @@ mod tests { fn test_read_header_auto_xored() { // XOR'd header should be de-obfuscated and parsed correctly let header = Header { - version: 1, + version: 2, flags: 0x0F, file_count: 5, toc_offset: HEADER_SIZE, @@ -591,7 +621,7 @@ mod tests { let mut cursor = Cursor::new(buf.as_slice()); let read_back = read_header_auto(&mut cursor).unwrap(); - assert_eq!(read_back.version, 1); + assert_eq!(read_back.version, 2); assert_eq!(read_back.flags, 0x0F); assert_eq!(read_back.file_count, 5); assert_eq!(read_back.toc_size, 512); @@ -601,11 +631,11 @@ mod tests { #[test] fn test_write_header_to_buf_matches_write_header() { let header = Header { - version: 1, + version: 2, flags: 0x01, file_count: 2, toc_offset: HEADER_SIZE, - toc_size: 219, + toc_size: 225, toc_iv: [0xAA; 16], reserved: [0u8; 8], }; @@ -625,6 +655,8 @@ mod tests { let entries = vec![ TocEntry { name: "file1.txt".to_string(), + entry_type: 0, + permissions: 0o644, original_size: 100, compressed_size: 80, encrypted_size: 96, @@ -637,6 +669,8 @@ mod tests { }, TocEntry { name: "file2.bin".to_string(), + entry_type: 0, + permissions: 0o755, original_size: 200, compressed_size: 180, encrypted_size: 192,