feat(08-01): update format.rs for v1.1 TOC entry layout
- Bump VERSION constant from 1 to 2 - Add entry_type (u8) and permissions (u16) fields to TocEntry struct - Update write_toc_entry/read_toc_entry for new field order after name - Update entry_size formula from 101 to 104 + name_length - Update all unit tests for v1.1 layout (new fields, version 2, sizes) - Add placeholder entry_type/permissions to archive.rs ProcessedFile for compilation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<Header> {
|
||||
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<Vec<TocE
|
||||
|
||||
/// Read and parse the 40-byte archive header.
|
||||
///
|
||||
/// Verifies: magic bytes, version == 1, reserved flags bits 4-7 are zero.
|
||||
/// Verifies: magic bytes, version == 2, reserved flags bits 4-7 are zero.
|
||||
pub fn read_header(reader: &mut impl Read) -> anyhow::Result<Header> {
|
||||
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<TocEntry> {
|
||||
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<TocEntry> {
|
||||
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<TocEntry> {
|
||||
|
||||
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<Vec<T
|
||||
|
||||
/// Compute the serialized size of a single TOC entry.
|
||||
///
|
||||
/// Formula from FORMAT.md Section 5: entry_size = 101 + name_length bytes.
|
||||
/// Formula from FORMAT.md Section 5 (v1.1): entry_size = 104 + name_length bytes.
|
||||
/// (101 base + 1 entry_type + 2 permissions = 104)
|
||||
pub fn entry_size(entry: &TocEntry) -> 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,
|
||||
|
||||
Reference in New Issue
Block a user