mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
XLSX: build encrypted workbooks in code for tests (test-support feature) (#11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u32> = 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<u8>> = 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<usize> = 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<u8> = 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() {
|
||||
|
||||
@@ -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<u8> = (0..3_000_000u32).map(|i| (i % 253) as u8).collect();
|
||||
let mid: Vec<u8> = (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<u8> = (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));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod array_export;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod malformed;
|
||||
mod ods_tests;
|
||||
|
||||
Reference in New Issue
Block a user