Review fix-up: only CFB files with an EncryptedPackage are encrypted
Every legacy .xls/.doc is a CFB/OLE2 file, so the signature alone isn't encryption. Walk the compound file's directory (bounds-checked, bounded loops; raw UTF-16 scan when the structure is unreadable) and report Encrypted only for an EncryptedPackage/EncryptionInfo stream; other compound files are LegacyBinary with a "save as .xlsx" message. Tests build CFB bytes in code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9365c5a9c6
commit
f041904bf8
|
|
@ -41,6 +41,11 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
|||
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
|
||||
)));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
|
||||
return Err(EngineError::Other(format!(
|
||||
"'{name}' is an Excel 97-2003 (.xls) workbook or another legacy binary file. GridCraft doesn't open these yet; save it as .xlsx in Excel and try again."
|
||||
)));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
|
|
@ -254,13 +259,42 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
|
||||
/// root entry and the given stream names.
|
||||
fn cfb(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(30, &9u16.to_le_bytes());
|
||||
put(44, &1u32.to_le_bytes());
|
||||
put(48, &1u32.to_le_bytes());
|
||||
put(68, &0xFFFF_FFFEu32.to_le_bytes());
|
||||
for i in 1..109 {
|
||||
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
put(512, &0xFFFF_FFFDu32.to_le_bytes());
|
||||
put(516, &0xFFFF_FFFEu32.to_le_bytes());
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() {
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
put(1024 + k * 128, &units);
|
||||
put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_workbook_is_reported_not_misread() {
|
||||
// A CFB/OLE2 header: Excel's password-protected container.
|
||||
let cfb = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0, 0];
|
||||
let err = open_bytes("secret.xlsx", &cfb).unwrap_err();
|
||||
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("password-protected"), "got: {msg}");
|
||||
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_xls_is_not_called_encrypted() {
|
||||
let err = open_bytes("old.xls", &cfb(&["Workbook", "\u{5}SummaryInformation"])).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("97-2003") && msg.contains(".xlsx"), "got: {msg}");
|
||||
assert!(!msg.contains("password"), "got: {msg}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
//! Just enough of the Compound File Binary (CFB/OLE2) container to tell what a non-zip
|
||||
//! spreadsheet file is: Excel wraps password-protected workbooks in a CFB holding an
|
||||
//! `EncryptedPackage` stream, while Excel 97-2003 `.xls` (and other legacy Office files) are
|
||||
//! CFB files without one. Every read is bounds-checked and every loop bounded.
|
||||
|
||||
/// The CFB signature at the start of the file.
|
||||
pub(crate) const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors.
|
||||
const MAX_REG_SECT: u32 = 0xFFFF_FFFA;
|
||||
/// Directory sectors followed at most (4096 entries with 512-byte sectors).
|
||||
const MAX_DIR_SECTORS: usize = 1024;
|
||||
/// DIFAT sectors followed at most.
|
||||
const MAX_DIFAT_SECTORS: usize = 1024;
|
||||
|
||||
/// Stream names that mark an encrypted (password-protected) OOXML package.
|
||||
const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"];
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// Whether the compound file holds an encryption stream. Walks the directory through the FAT;
|
||||
/// when the structure can't be followed (truncated or malformed), falls back to scanning the
|
||||
/// bytes for the UTF-16LE stream names.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
match directory_names(bytes) {
|
||||
Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))),
|
||||
None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_utf16(hay: &[u8], needle: &str) -> bool {
|
||||
let n: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
!n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice())
|
||||
}
|
||||
|
||||
/// Names of every directory entry, or `None` when the directory can't be read.
|
||||
fn directory_names(b: &[u8]) -> Option<Vec<String>> {
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return None;
|
||||
}
|
||||
let shift = u16_at(b, 30)?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return None;
|
||||
}
|
||||
let size = 1usize << shift;
|
||||
let per = size / 4;
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(size)?;
|
||||
b.get(start..start.checked_add(size)?)
|
||||
};
|
||||
// The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors.
|
||||
let mut difat: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68)?;
|
||||
for _ in 0..MAX_DIFAT_SECTORS {
|
||||
if next >= MAX_REG_SECT {
|
||||
break;
|
||||
}
|
||||
let s = sector(next)?;
|
||||
difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4)?;
|
||||
}
|
||||
let fat_next = |id: u32| -> Option<u32> {
|
||||
let idx = id as usize;
|
||||
let fat_sector = *difat.get(idx / per)?;
|
||||
u32_at(sector(fat_sector)?, (idx % per) * 4)
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
let mut id = u32_at(b, 48)?;
|
||||
for _ in 0..MAX_DIR_SECTORS {
|
||||
if id == END_OF_CHAIN {
|
||||
return Some(names);
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return None;
|
||||
}
|
||||
let s = sector(id)?;
|
||||
for e in s.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64)?);
|
||||
if !(2..=64).contains(&len) {
|
||||
continue;
|
||||
}
|
||||
let units: Vec<u16> = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
names.push(String::from_utf16_lossy(&units));
|
||||
}
|
||||
id = fat_next(id)?;
|
||||
}
|
||||
// A directory longer than the bound (or a cyclic chain): report what was read.
|
||||
Some(names)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1)
|
||||
/// whose directory holds a root entry plus the given stream names.
|
||||
pub(crate) fn build(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
b[..8].copy_from_slice(&SIGNATURE);
|
||||
b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version
|
||||
b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version
|
||||
b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order
|
||||
b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift
|
||||
b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift
|
||||
b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors
|
||||
b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector
|
||||
b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT
|
||||
b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0
|
||||
// FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain.
|
||||
for i in 0..128 {
|
||||
b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes());
|
||||
b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
|
||||
// Directory (sector 1, at byte 1024).
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() {
|
||||
let e = 1024 + k * 128;
|
||||
let units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
|
||||
b[e + 66] = if k == 0 { 5 } else { 2 };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_directory_names() {
|
||||
let b = build(&["EncryptionInfo", "EncryptedPackage"]);
|
||||
assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()]));
|
||||
assert!(is_encrypted(&b));
|
||||
assert!(!is_encrypted(&build(&["Workbook", "\u{5}SummaryInformation"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_never_panic() {
|
||||
let mut b = build(&["Workbook"]);
|
||||
for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] {
|
||||
for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] {
|
||||
let mut c = b.clone();
|
||||
c[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
let _ = is_encrypted(&c);
|
||||
for cut in [0usize, 8, 100, 600, 1100] {
|
||||
let _ = is_encrypted(&c[..cut.min(c.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A directory chain that loops back on itself stops at the bound.
|
||||
b[516..520].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(!is_encrypted(&b));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod csv;
|
||||
mod drawing;
|
||||
|
|
@ -93,7 +94,10 @@ impl Default for CsvOptions {
|
|||
pub enum Format {
|
||||
Xlsx,
|
||||
Csv,
|
||||
/// A password-protected (encrypted) workbook: a compound file with an `EncryptedPackage`.
|
||||
Encrypted,
|
||||
/// Another compound (CFB/OLE2) file: an Excel 97-2003 `.xls` or other legacy Office binary.
|
||||
LegacyBinary,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
|
@ -102,9 +106,10 @@ pub fn sniff(bytes: &[u8]) -> Format {
|
|||
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
|
||||
return Format::Xlsx;
|
||||
}
|
||||
// CFB/OLE2 compound file: Excel password-protected files.
|
||||
if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
|
||||
return Format::Encrypted;
|
||||
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
|
||||
// stream, otherwise a legacy binary (.xls, .doc…).
|
||||
if bytes.starts_with(&cfb::SIGNATURE) {
|
||||
return if cfb::is_encrypted(bytes) { Format::Encrypted } else { Format::LegacyBinary };
|
||||
}
|
||||
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
return Format::Csv;
|
||||
|
|
|
|||
|
|
@ -105,8 +105,11 @@ fn writing() {
|
|||
fn sniffing_formats() {
|
||||
assert_eq!(sniff(b"PK\x03\x04rest"), Format::Xlsx);
|
||||
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
|
||||
// An Excel password-protected file is a CFB/OLE2 compound file, not a zip.
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::Encrypted);
|
||||
// An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage
|
||||
// stream; any other compound file is a legacy binary (.xls), not an encrypted workbook.
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted);
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown);
|
||||
assert_eq!(sniff(b""), Format::Unknown);
|
||||
assert_eq!(sniff(&[0xFF, 0xFE, b'a', 0]), Format::Csv);
|
||||
|
|
|
|||
Loading…
Reference in New Issue