From c9f03cc36a388b27b63bc71bea265c7e7a2fc7e0 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 23:02:07 +0200 Subject: [PATCH] XLSX: build encrypted workbooks in code for tests (test-support feature) (#11) Co-Authored-By: Claude Opus 5.5 --- crates/xlsx/Cargo.toml | 5 + crates/xlsx/src/lib.rs | 6 +- crates/xlsx/src/testing.rs | 133 +++++++++++++++++++++++ crates/xlsx/src/tests/encrypted_tests.rs | 47 ++++++++ crates/xlsx/src/tests/mod.rs | 1 + 5 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 crates/xlsx/src/tests/encrypted_tests.rs diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 3262bd06d..b53a44828 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -8,6 +8,11 @@ description = "GridCraft file formats: XLSX (SpreadsheetML) and CSV import/expor [lints] workspace = true +[features] +# Builds password-encrypted workbooks in code for other crates' tests (a dev-dependency feature; +# never enabled in a shipped build). +test-support = [] + [dependencies] gridcraft-core = { workspace = true } gridcraft-formula = { workspace = true } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index c1cee959d..4dfa588c6 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -1,5 +1,5 @@ //! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import -//! and legacy Excel 97-2003 `.xls` (BIFF8) import. +//! and legacy Excel 97-2003 `.xls` (BIFF8) import, and password-encrypted workbooks (Agile and Standard encryption). //! //! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -30,8 +30,8 @@ mod sheet_read; mod sheet_write; mod styles; mod tables; -#[cfg(test)] -pub(crate) mod testing; +#[cfg(any(test, feature = "test-support"))] +pub mod testing; mod theme; mod write; mod xls; diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index b4dc44cc3..348c08af5 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -13,12 +13,145 @@ use crate::crypto::{ standard_key, }; +/// The spin count `encrypt_workbook` uses: Excel's default. +pub const SPIN_COUNT: u32 = 100_000; + const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]; const SECRET_KEY: [u8; 32] = [64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]; const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]; +/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption +/// (AES-256, SHA-512) inside a compound file. +pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec { + let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT); + compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) +} + +/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it. +pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec { + let (info, encrypted) = encrypt_standard(package, password); + compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) +} + +/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as +/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular +/// sectors. Up to 109 FAT sectors (about 7 MB of streams). +pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec { + const SECTOR: usize = 512; + const MINI: usize = 64; + const CUTOFF: usize = 4096; + const FREE: u32 = 0xFFFF_FFFF; + const END: u32 = 0xFFFF_FFFE; + const FAT_SECTOR: u32 = 0xFFFF_FFFD; + let is_mini = |d: &[u8]| d.len() < CUTOFF; + + // The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT. + let mut container = vec![]; + let mut mini_fat: Vec = vec![]; + let mut mini_starts = vec![]; + for (_, d) in streams { + if !is_mini(d) || d.is_empty() { + mini_starts.push(END); + continue; + } + let first = mini_fat.len(); + let n = d.len().div_ceil(MINI); + for k in 0..n { + mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 }); + } + mini_starts.push(first as u32); + container.extend_from_slice(d); + container.resize((first + n) * MINI, 0); + } + + // Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream. + let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR; + let mut blobs: Vec> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()]; + blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() })); + let counts: Vec = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect(); + let body: usize = counts.iter().sum(); + let mut fat_sectors = 1; + while fat_sectors * 128 < fat_sectors + body { + fat_sectors += 1; + } + assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors"); + let mut fat = vec![FREE; fat_sectors * 128]; + fat[..fat_sectors].fill(FAT_SECTOR); + let mut starts = vec![]; + let mut next = fat_sectors; + for &n in &counts { + if n == 0 { + starts.push(END); + continue; + } + starts.push(next as u32); + for k in 0..n { + fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 }; + } + next += n; + } + + let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes()); + let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + // Directory: the root (owning the mini stream), then the streams chained as right siblings. + let dir = &mut blobs[0]; + for k in 0..dir_len / 128 { + for at in [68, 72, 76] { + put32(dir, k * 128 + at, FREE); + } + } + let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| { + let e = k * 128; + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + dir[e..e + units.len()].copy_from_slice(&units); + put16(dir, e + 64, units.len() as u16 + 2); + dir[e + 66] = kind; + dir[e + 67] = 1; // black + if kind == 5 && !streams.is_empty() { + put32(dir, e + 76, 1); + } + if kind == 2 && k < streams.len() { + put32(dir, e + 72, k as u32 + 1); + } + put32(dir, e + 116, start); + put32(dir, e + 120, size as u32); + }; + entry(0, "Root Entry", 5, starts[2], container.len()); + for (k, (name, data)) in streams.iter().enumerate() { + let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] }; + entry(k + 1, name, 2, start, data.len()); + } + + let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)]; + out[..8].copy_from_slice(&crate::cfb::SIGNATURE); + put16(&mut out, 24, 0x3E); // minor version + put16(&mut out, 26, 3); // major version + put16(&mut out, 28, 0xFFFE); // byte order + put16(&mut out, 30, 9); // 512-byte sectors + put16(&mut out, 32, 6); // 64-byte mini sectors + put32(&mut out, 44, fat_sectors as u32); + put32(&mut out, 48, starts[0]); + put32(&mut out, 56, CUTOFF as u32); + put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none) + put32(&mut out, 64, counts[1] as u32); + put32(&mut out, 68, END); // no DIFAT sectors + for i in 0..109 { + put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE }); + } + for (i, v) in fat.iter().enumerate() { + put32(&mut out, SECTOR + i * 4, *v); + } + for (blob, start) in blobs.iter().zip(&starts) { + if !blob.is_empty() { + let at = SECTOR * (1 + *start as usize); + out[at..at + blob.len()].copy_from_slice(blob); + } + } + out +} + fn encrypt_block(key: &[u8], block: &mut [u8; 16]) { let b = aes::Block::cast_from_core_mut(block); match key.len() { diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs new file mode 100644 index 000000000..5abeefa83 --- /dev/null +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -0,0 +1,47 @@ +//! Encrypted workbooks end to end: compound file → streams → plain package. + +use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard}; +use crate::{DecryptError, decrypt}; + +#[test] +fn the_test_compound_writer_reads_back() { + let big: Vec = (0..3_000_000u32).map(|i| (i % 253) as u8).collect(); + let mid: Vec = (0..4095u32).map(|i| i as u8).collect(); + let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]); + let cf = crate::cfb::Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream"); + assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello"); + assert_eq!(cf.root_stream("big").unwrap().unwrap(), big); + assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b""); + assert_eq!(cf.root_stream("Missing").unwrap(), None); +} + +#[test] +fn encrypted_files_decrypt_to_the_package() { + let package: Vec = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect(); + let agile = encrypt_workbook(&package, "Pass1234"); + assert!(crate::cfb::is_encrypted(&agile)); + assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package); + assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword)); + let standard = encrypt_workbook_standard(&package, "Pass1234"); + assert_eq!(decrypt(&standard, "Pass1234").unwrap(), package); +} + +#[test] +fn a_compound_file_without_the_streams_is_malformed() { + let file = compound_file(&[("EncryptionInfo", &[4, 0, 4, 0])]); + assert!(matches!(decrypt(&file, "x"), Err(DecryptError::Malformed(_)))); + assert!(matches!(decrypt(b"not a compound file", "x"), Err(DecryptError::Malformed(_)))); +} + +#[test] +fn a_gridcraft_workbook_survives_encryption() { + let b2 = gridcraft_core::CellRef::parse("B2").unwrap(); + let mut wb = gridcraft_model::Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(b2, gridcraft_core::Value::Number(42.0)); + let xlsx = crate::write_xlsx(&wb).unwrap(); + let file = encrypt_workbook(&xlsx, "Pass1234"); + assert_eq!(crate::sniff(&file), crate::Format::Encrypted); + let (back, _) = crate::read_xlsx(&decrypt(&file, "Pass1234").unwrap()).unwrap(); + assert_eq!(back.active().unwrap().value(b2), gridcraft_core::Value::Number(42.0)); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index a9ce0bf0f..2da388482 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,6 +2,7 @@ mod array_export; mod csv_tests; +mod encrypted_tests; mod fixtures; mod malformed; mod ods_tests;