Files
gridcraft/crates/xlsx/src/tests/xls_tests.rs
T
Brandon ThomasandClaude Opus 5.5 e78cc6edc9 Fix-up #332: bound .xls formula nesting and share one record budget across sheets
Chained PtgParen/PtgUminus (or operator) tokens built a ~16K-deep Expr whose
printing or drop overflowed the stack; nesting past the formula parser's
MAX_DEPTH (256) is now reported as unsupported. BoundSheet records pointing
into one huge substream each got their own 40M-record allowance; the
allowance is now shared by the whole Workbook stream, and two sheets at the
same offset are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:17 -04:00

479 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Legacy `.xls` (BIFF8) import. Every test file is assembled here from the [MS-CFB] and
//! [MS-XLS] structures; no Excel-produced file is used.
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
use crate::{Format, IoError, read_xls, read_xlsx, sniff, write_xlsx};
const END: u32 = 0xFFFF_FFFE;
const FREE: u32 = 0xFFFF_FFFF;
fn put32(b: &mut [u8], at: usize, v: u32) {
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
fn dir_entry(name: &str, kind: u8, child: u32, start: u32, size: u32) -> [u8; 128] {
let mut e = [0u8; 128];
let units: Vec<u16> = name.encode_utf16().collect();
for (i, u) in units.iter().enumerate() {
e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
}
if !name.is_empty() {
e[64..66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
}
e[66] = kind;
e[67] = 1; // black
put32(&mut e, 68, FREE);
put32(&mut e, 72, FREE);
put32(&mut e, 76, child);
put32(&mut e, 116, start);
put32(&mut e, 120, size);
e
}
/// A version 3 compound file (512-byte sectors) whose root holds one stream. Streams under
/// 4096 bytes go to the mini stream, like real files.
pub(crate) fn compound(name: &str, data: &[u8]) -> Vec<u8> {
let mini = data.len() < 4096;
let pad = |n: usize, to: usize| n.div_ceil(to) * to;
// Sector payloads after the FAT: directory, mini FAT (if any), then data.
let mut payload: Vec<Vec<u8>> = vec![];
let mut chains: Vec<(usize, usize)> = vec![]; // (first payload index, sector count)
let (dir_at, minifat_at, data_at);
let mut dir = vec![];
let mut minifat = vec![];
let stream_sectors;
if mini {
let m = data.len().div_ceil(64).max(1);
for i in 0..m {
minifat.extend_from_slice(&(if i + 1 == m { END } else { i as u32 + 1 }).to_le_bytes());
}
minifat.resize(512, 0xFF);
let mut container = data.to_vec();
container.resize(pad(data.len().max(1), 64), 0);
stream_sectors = container.len().div_ceil(512);
container.resize(stream_sectors * 512, 0);
dir_at = 0;
minifat_at = Some(1);
data_at = 2;
payload.push(vec![]);
payload.push(minifat);
payload.extend(container.chunks(512).map(<[u8]>::to_vec));
chains.push((data_at, stream_sectors));
dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, 0, (stream_sectors * 512) as u32));
dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32));
} else {
let mut body = data.to_vec();
stream_sectors = body.len().div_ceil(512);
body.resize(stream_sectors * 512, 0);
dir_at = 0;
minifat_at = None;
data_at = 1;
payload.push(vec![]);
payload.extend(body.chunks(512).map(<[u8]>::to_vec));
chains.push((data_at, stream_sectors));
dir.extend_from_slice(&dir_entry("Root Entry", 5, 1, END, 0));
dir.extend_from_slice(&dir_entry(name, 2, FREE, 0, data.len() as u32));
}
dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0));
dir.extend_from_slice(&dir_entry("", 0, FREE, FREE, 0));
// FAT sectors come first; their count depends on the total.
let mut fat_n = 1;
while (fat_n + payload.len()).div_ceil(128) > fat_n {
fat_n += 1;
}
let sec = |i: usize| (fat_n + i) as u32;
// Fix the directory's start sectors now that the layout is known.
let first_data = sec(data_at);
if mini {
put32(&mut dir, 116, first_data); // root: mini stream container
} else {
put32(&mut dir, 128 + 116, first_data);
}
payload[dir_at] = dir;
let total = fat_n + payload.len();
let mut fat = vec![FREE; fat_n * 128];
for f in fat.iter_mut().take(fat_n) {
*f = 0xFFFF_FFFD;
}
fat[sec(dir_at) as usize] = END;
if let Some(m) = minifat_at {
fat[sec(m) as usize] = END;
}
for (first, n) in chains {
for k in 0..n {
fat[sec(first + k) as usize] = if k + 1 == n { END } else { sec(first + k + 1) };
}
}
let mut out = vec![0u8; 512];
out[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
out[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
out[26..28].copy_from_slice(&3u16.to_le_bytes());
out[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
out[30..32].copy_from_slice(&9u16.to_le_bytes());
out[32..34].copy_from_slice(&6u16.to_le_bytes());
put32(&mut out, 44, fat_n as u32);
put32(&mut out, 48, sec(dir_at));
put32(&mut out, 56, 4096);
put32(&mut out, 60, minifat_at.map_or(END, sec));
put32(&mut out, 64, u32::from(minifat_at.is_some()));
put32(&mut out, 68, END);
for i in 0..109 {
put32(&mut out, 76 + i * 4, if i < fat_n { i as u32 } else { FREE });
}
for f in &fat {
out.extend_from_slice(&f.to_le_bytes());
}
for p in payload {
let mut p = p;
p.resize(512, 0);
out.extend_from_slice(&p);
}
assert_eq!(out.len(), 512 * (1 + total));
out
}
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
let mut v = id.to_le_bytes().to_vec();
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
v.extend_from_slice(payload);
v
}
fn cat(parts: &[&[u8]]) -> Vec<u8> {
parts.concat()
}
fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// Cell header: row, column, XF index.
fn cell(r: u16, c: u16, xf: u16) -> Vec<u8> {
u16s(&[r, c, xf])
}
fn bof(dt: u16) -> Vec<u8> {
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
}
fn eof() -> Vec<u8> {
rec(0x000A, &[])
}
/// XLUnicodeString with compressed characters.
fn xlstr(s: &str) -> Vec<u8> {
cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()])
}
/// A FORMULA record with a numeric cached result.
fn formula(r: u16, c: u16, cached: [u8; 8], rgce: &[u8]) -> Vec<u8> {
let mut p = cell(r, c, 0);
p.extend_from_slice(&cached);
p.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // flags, chn
p.extend_from_slice(&(rgce.len() as u16).to_le_bytes());
p.extend_from_slice(rgce);
rec(0x0006, &p)
}
fn num(n: f64) -> [u8; 8] {
n.to_le_bytes()
}
fn special(kind: u8, v: u8) -> [u8; 8] {
[kind, 0, v, 0, 0, 0, 0xFF, 0xFF]
}
/// PtgRef (value class) with relative row and column.
fn ptg_ref(r: u16, c: u16) -> Vec<u8> {
cat(&[&[0x44], &u16s(&[r, c | 0xC000])])
}
fn ptg_int(n: u16) -> Vec<u8> {
cat(&[&[0x1E], &n.to_le_bytes()])
}
fn exp(r: u16, c: u16) -> Vec<u8> {
cat(&[&[0x01], &u16s(&[r, c])])
}
/// A workbook with two worksheets and a chart sheet covering the stage-1 features.
fn sample(date1904: bool) -> Vec<u8> {
// SST: "Hello", then "Wörld" as UTF-16 split across a Continue record.
let mut sst = cat(&[&2u32.to_le_bytes(), &2u32.to_le_bytes(), &xlstr("Hello")]);
sst.extend_from_slice(&[5, 0, 0, b'W']);
let sst_rec = cat(&[&rec(0x00FC, &sst), &rec(0x003C, &cat(&[&[1], &u16s(&[0xF6, 'r' as u16, 'l' as u16, 'd' as u16])]))]);
let xf = |ifmt: u16| rec(0x00E0, &cat(&[&u16s(&[0, ifmt, 0]), &[0; 14]]));
// Lbl "Rate" = Data!$A$1 (PtgRef3d, absolute).
let rate = cat(&[&[0x3A], &1u16.to_le_bytes(), &u16s(&[0, 0])]);
let lbl = rec(0x0018, &cat(&[&u16s(&[0]), &[0, 4], &u16s(&[rate.len() as u16, 0, 0]), &[0; 4], &[0], b"Rate", &rate]));
// Print_Area on Sheet1 (built-in name 6, itab 1) = Sheet1!$A$1:$B$2.
let pa = cat(&[&[0x3B], &0u16.to_le_bytes(), &u16s(&[0, 1, 0, 1])]);
let print_area = rec(0x0018, &cat(&[&u16s(&[0x0020]), &[0, 1], &u16s(&[pa.len() as u16, 0, 1]), &[0; 4], &[0, 6], &pa]));
let boundsheet = |pos: u32, kind: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, kind, name.len() as u8, 0], name.as_bytes()]));
let globals = |positions: [u32; 3]| {
cat(&[
&bof(0x0005),
&rec(0x0022, &u16s(&[u16::from(date1904)])),
&rec(0x041E, &cat(&[&164u16.to_le_bytes(), &xlstr("yyyy-mm-dd")])),
&xf(0),
&xf(164),
&xf(10), // built-in 0.00%
&boundsheet(positions[0], 0, "Sheet1"),
&boundsheet(positions[1], 0, "Data"),
&boundsheet(positions[2], 2, "Chart1"),
&rec(0x01AE, &u16s(&[3, 0x0401])),
&rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[0, 1, 1])])),
&lbl,
&print_area,
&sst_rec,
&eof(),
])
};
let mulrk = cat(&[&u16s(&[1, 2]), &u16s(&[0]), &((7u32 << 2) | 2).to_le_bytes(), &u16s(&[2]), &((150u32 << 2) | 3).to_le_bytes(), &u16s(&[3])]);
let shared = cat(&[&[0x4C], &u16s(&[0, 0xFFFF]), &ptg_int(10), &[0x05]]);
let array = cat(&[&[0x65], &u16s(&[4, 5, 0xC000, 0xC000]), &ptg_int(2), &[0x05]]);
let sum = cat(&[&[0x25], &u16s(&[0, 0, 1 | 0xC000, 2 | 0xC000]), &[0x22, 1], &4u16.to_le_bytes()]);
let concat = cat(&[&ptg_ref(0, 0), &[0x17, 1, 0, b'!'], &[0x08]]);
let round = cat(&[&ptg_ref(0, 1), &ptg_int(0), &[0x41], &27u16.to_le_bytes()]);
let name_ref = cat(&[&[0x43], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
let ref3d = cat(&[&[0x5A], &1u16.to_le_bytes(), &u16s(&[0, 0xC000])]);
let sheet1 = cat(&[
&bof(0x0010),
&rec(0x0225, &u16s(&[0, 255])),
&rec(0x007D, &u16s(&[0, 0, 20 * 256, 0, 0x0002, 0])),
&rec(0x0208, &cat(&[&u16s(&[0, 0, 4, 600, 0, 0]), &(0x0140u32).to_le_bytes()])),
&rec(0x00FD, &cat(&[&cell(0, 0, 0), &0u32.to_le_bytes()])),
&rec(0x0203, &cat(&[&cell(0, 1, 0), &1.5f64.to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(0, 2, 0), &((42u32 << 2) | 2).to_le_bytes()])),
&rec(0x00FD, &cat(&[&cell(0, 3, 0), &1u32.to_le_bytes()])),
&rec(0x0205, &cat(&[&cell(1, 0, 0), &[1, 0]])),
&rec(0x0205, &cat(&[&cell(1, 1, 0), &[0x07, 1]])),
&rec(0x00BD, &mulrk),
&formula(2, 0, num(3.0), &cat(&[&ptg_ref(0, 1), &ptg_int(2), &[0x05]])),
&formula(2, 1, num(43.5), &sum),
&formula(2, 2, special(0, 0), &concat),
&rec(0x0207, &xlstr("Hello!")),
&formula(2, 3, special(1, 1), &cat(&[&ptg_ref(0, 1), &ptg_int(1), &[0x0D]])),
&formula(2, 4, num(14.0), &name_ref),
&formula(2, 5, num(2.0), &round),
&formula(2, 6, num(7.0), &ref3d),
&formula(2, 7, special(2, 0x2A), &[0x1C, 0x2A]),
&rec(0x0203, &cat(&[&cell(3, 0, 1), &0.0f64.to_le_bytes()])),
&rec(0x0203, &cat(&[&cell(3, 1, 2), &0.25f64.to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(4, 0, 0), &((1u32 << 2) | 2).to_le_bytes()])),
&rec(0x027E, &cat(&[&cell(5, 0, 0), &((2u32 << 2) | 2).to_le_bytes()])),
&formula(4, 1, num(10.0), &exp(4, 1)),
&rec(0x04BC, &cat(&[&u16s(&[4, 5]), &[1, 1, 0, 2], &(shared.len() as u16).to_le_bytes(), &shared])),
&formula(5, 1, num(20.0), &exp(4, 1)),
&formula(4, 2, num(2.0), &exp(4, 2)),
&rec(0x0221, &cat(&[&u16s(&[4, 5]), &[2, 2], &[0; 6], &(array.len() as u16).to_le_bytes(), &array])),
&formula(5, 2, num(4.0), &exp(4, 2)),
&rec(0x00E5, &cat(&[&u16s(&[1]), &u16s(&[6, 6, 0, 1])])),
// An embedded chart substream: its records are not cells.
&bof(0x0020),
&rec(0x0203, &cat(&[&cell(9, 9, 0), &9.0f64.to_le_bytes()])),
&eof(),
&eof(),
]);
let data = cat(&[&bof(0x0010), &rec(0x0203, &cat(&[&cell(0, 0, 0), &7.0f64.to_le_bytes()])), &eof()]);
let chart = cat(&[&bof(0x0020), &eof()]);
let len = globals([0; 3]).len() as u32;
let p1 = len;
let p2 = p1 + sheet1.len() as u32;
let p3 = p2 + data.len() as u32;
cat(&[&globals([p1, p2, p3]), &sheet1, &data, &chart])
}
fn v(wb: &gridcraft_model::Workbook, sheet: usize, a1: &str) -> Value {
wb.sheets[sheet].value(CellRef::parse(a1).unwrap())
}
fn ftext(wb: &gridcraft_model::Workbook, a1: &str) -> Option<String> {
wb.sheets[0].cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone())
}
#[test]
fn reads_values_formulas_formats_and_layout() {
for big in [false, true] {
let mut stream = sample(false);
if big {
// Past the mini stream cutoff: the stream lives in regular sectors.
stream.extend(std::iter::repeat_n(0u8, 5000));
}
let bytes = compound("Workbook", &stream);
assert_eq!(sniff(&bytes), Format::Xls);
let (wb, report) = read_xls(&bytes).unwrap();
assert!(report.warnings[0].contains("Excel 97-2003"), "{:?}", report.warnings);
assert!(report.warnings.iter().any(|w| w.contains("Chart sheets")));
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Sheet1", "Data"]);
assert_eq!(v(&wb, 0, "A1"), Value::from("Hello"));
assert_eq!(v(&wb, 0, "B1"), Value::Number(1.5));
assert_eq!(v(&wb, 0, "C1"), Value::Number(42.0));
assert_eq!(v(&wb, 0, "D1"), Value::from("Wörld"));
assert_eq!(v(&wb, 0, "A2"), Value::Bool(true));
assert_eq!(v(&wb, 0, "B2"), Value::Error(CellError::Div0));
assert_eq!(v(&wb, 0, "C2"), Value::Number(7.0));
assert_eq!(v(&wb, 0, "D2"), Value::Number(1.5));
// Formulas: text plus the cached result.
assert_eq!(ftext(&wb, "A3").as_deref(), Some("B1*2"));
assert_eq!(v(&wb, 0, "A3"), Value::Number(3.0));
assert_eq!(ftext(&wb, "B3").as_deref(), Some("SUM(B1:C1)"));
assert_eq!(ftext(&wb, "C3").as_deref(), Some("A1&\"!\""));
assert_eq!(v(&wb, 0, "C3"), Value::from("Hello!"));
assert_eq!(ftext(&wb, "D3").as_deref(), Some("B1>1"));
assert_eq!(v(&wb, 0, "D3"), Value::Bool(true));
assert_eq!(ftext(&wb, "E3").as_deref(), Some("Rate*2"));
assert_eq!(ftext(&wb, "F3").as_deref(), Some("ROUND(B1,0)"));
assert_eq!(ftext(&wb, "G3").as_deref(), Some("Data!A1"));
assert_eq!(v(&wb, 0, "H3"), Value::Error(CellError::NA));
// Shared formula: relative offsets resolve per cell.
assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10"));
assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10"));
// Array formula: the anchor holds it, the other cell keeps its value.
let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));
assert_eq!(ftext(&wb, "C6"), None);
assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0));
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
let sh = &wb.sheets[0];
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd");
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%");
assert_eq!(sh.merges, vec![RangeRef::parse("A7:B7").unwrap()]);
assert!(sh.cols.get(&0).unwrap().size.unwrap() > sh.default_col_width);
assert_eq!(sh.rows.get(&0).unwrap().size, Some(40.0));
assert!(!sh.rows.get(&0).unwrap().hidden && sh.rows.get(&0).unwrap().custom);
assert_eq!(sh.print.print_area, RangeRef::parse("A1:B2"));
assert_eq!(v(&wb, 0, "J10"), Value::Empty);
assert_eq!(wb.names.len(), 1);
assert_eq!((wb.names[0].name.as_str(), wb.names[0].formula.as_str()), ("Rate", "Data!$A$1"));
assert_eq!(v(&wb, 1, "A1"), Value::Number(7.0));
assert_eq!(wb.date_system, DateSystem::D1900);
// The import saves as .xlsx and reads back.
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.sheets[0].cell(CellRef::parse("A3").unwrap()).unwrap().formula.as_ref().unwrap().text, "B1*2");
assert_eq!(back.sheets[0].value(CellRef::parse("C3").unwrap()), Value::from("Hello!"));
}
let (wb, _) = read_xls(&compound("Workbook", &sample(true))).unwrap();
assert_eq!(wb.date_system, DateSystem::D1904);
}
#[test]
fn refuses_encrypted_old_and_foreign_files() {
// FILEPASS in the globals: an encrypted workbook.
let mut stream = bof(0x0005);
stream.extend(rec(0x002F, &[1, 0, 1, 0]));
stream.extend(eof());
let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string();
assert!(err.contains("password-protected"), "{err}");
// A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream.
let book = compound("Book", &stream);
assert_eq!(sniff(&book), Format::Xls);
assert!(read_xls(&book).unwrap_err().to_string().contains("BIFF5"));
// Another compound file (a word processor document): not a workbook.
let doc = compound("WordDocument", &[0u8; 100]);
assert_eq!(sniff(&doc), Format::LegacyBinary);
assert!(read_xls(&doc).is_err());
}
#[test]
fn hostile_xls_input_never_panics_or_hangs() {
let stream = sample(false);
let good = compound("Workbook", &stream);
// Truncation anywhere.
for cut in (0..good.len()).step_by(37) {
let _ = read_xls(&good[..cut]);
let _ = sniff(&good[..cut]);
}
// Byte mutations in the container and the stream.
for at in (0..good.len()).step_by(7) {
for x in [0x00u8, 0x01, 0x7F, 0xFF] {
let mut b = good.clone();
b[at] = x;
let _ = read_xls(&b);
}
}
for at in 0..stream.len() {
let mut s = stream.clone();
s[at] ^= 0xA5;
let _ = read_xls(&compound("Workbook", &s));
}
// A FAT chain that loops on itself (big stream in regular sectors).
let mut big = stream.clone();
big.extend(std::iter::repeat_n(0u8, 5000));
let mut b = compound("Workbook", &big);
let fat_at = 512;
let first_data = 2usize; // FAT, directory, then data
put32(&mut b, fat_at + (first_data + 3) * 4, first_data as u32);
assert!(read_xls(&b).is_err());
// Huge declared counts: shared strings, merges, external sheets.
let mut huge = bof(0x0005);
huge.extend(rec(0x00FC, &cat(&[&u32::MAX.to_le_bytes(), &u32::MAX.to_le_bytes()])));
huge.extend(eof());
assert!(matches!(read_xls(&compound("Workbook", &huge)), Err(IoError::TooLarge(_))));
let mut huge = bof(0x0005);
huge.extend(rec(0x00FC, &cat(&[&9u32.to_le_bytes(), &1_000_000u32.to_le_bytes(), &xlstr("x")])));
huge.extend(rec(0x0017, &u16s(&[0xFFFF])));
huge.extend(eof());
assert!(read_xls(&compound("Workbook", &huge)).is_err()); // no worksheets
// A sheet pointing outside the stream and a stream size larger than the file.
let mut bad = bof(0x0005);
bad.extend(rec(0x0085, &cat(&[&u32::MAX.to_le_bytes(), &[0, 0, 1, 0], b"S"])));
bad.extend(eof());
assert!(read_xls(&compound("Workbook", &bad)).is_err());
let mut b = compound("Workbook", &big);
put32(&mut b, 512 * 2 + 128 + 120, 0x3FFF_FFFF);
assert!(read_xls(&b).is_err());
// Random bytes behind a compound-file signature.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
for _ in 0..200 {
let mut b = good[..512].to_vec();
for _ in 0..2048 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
b.push(seed as u8);
}
let _ = read_xls(&b);
}
}
#[test]
fn sheets_share_one_record_budget() {
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
// sheet would read the whole tail. The record allowance is shared, not per sheet.
let n = 300u32;
let tail: Vec<u8> = (0..2000u16).flat_map(|r| rec(0x0203, &cat(&[&cell(r, 0, 0), &1.0f64.to_le_bytes()]))).collect();
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
let globals = |base: u32| {
let mut g = bof(0x0005);
for i in 0..n {
g.extend(boundsheet(base + i * bof(0x0010).len() as u32, &format!("S{i}")));
}
g.extend(eof());
g
};
let base = globals(0).len() as u32;
let mut stream = globals(base);
for _ in 0..n {
stream.extend(bof(0x0010));
}
stream.extend(tail);
stream.extend(eof());
let file = compound("Workbook", &stream);
// About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits.
assert!(matches!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(100_000)), Err(IoError::TooLarge(_))));
assert!(crate::xls::read_xls_with(&file, crate::xls::Budget::with_max_records(10_000_000)).is_ok());
// Two sheets at the same offset are refused outright.
let mut dup = bof(0x0005);
let at = (dup.len() + 2 * boundsheet(0, "A").len() + eof().len()) as u32;
dup.extend(boundsheet(at, "A"));
dup.extend(boundsheet(at, "B"));
dup.extend(eof());
dup.extend(bof(0x0010));
dup.extend(eof());
let err = read_xls(&compound("Workbook", &dup)).unwrap_err().to_string();
assert!(err.contains("same substream"), "{err}");
}