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:
NikitolProject
2026-02-26 21:45:20 +03:00
parent 7be915ff47
commit 4e25d19ff5
2 changed files with 70 additions and 28 deletions

View File

@@ -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,

View File

@@ -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,