mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f630f043 |
+74
-3
@@ -22,6 +22,11 @@ const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// range, so that formulas over them stay fast.
|
||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
|
||||
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
|
||||
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
||||
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
||||
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Area {
|
||||
@@ -88,6 +93,11 @@ pub struct Evaluator<'h> {
|
||||
pub at: CellRef,
|
||||
env: Vec<(String, Ev)>,
|
||||
depth: usize,
|
||||
/// Lambda invocations this cell still has left. Every call spends one charge, so a
|
||||
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
|
||||
/// runs out of work after the same number of calls that a legitimately wide one could
|
||||
/// make, instead of an amount of work that squares with array nesting (#2382).
|
||||
fuel: u64,
|
||||
}
|
||||
|
||||
struct FnCtx<'a, 'h> {
|
||||
@@ -127,8 +137,11 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, fuel: self.ev.fuel };
|
||||
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
||||
// The child's spend counts against this cell's budget.
|
||||
self.ev.fuel = ev.fuel;
|
||||
Some(v)
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
@@ -147,7 +160,7 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, fuel: LAMBDA_FUEL }
|
||||
}
|
||||
|
||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||
@@ -983,6 +996,13 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
||||
// #2382: one charge per invocation, so a cell's total work stays bounded however the
|
||||
// lambdas nest. Foregrounding the charge here means every path into a body (direct
|
||||
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
|
||||
self.fuel = match self.fuel.checked_sub(1) {
|
||||
Some(f) => f,
|
||||
None => return err(CellError::Calc),
|
||||
};
|
||||
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
||||
self.env.extend(bound);
|
||||
let r = self.eval(&l.body);
|
||||
@@ -1984,3 +2004,54 @@ pub fn is_known_function(n: &str) -> bool {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: RefKind) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fuel_tests {
|
||||
use super::*;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
struct Empty {
|
||||
wb: Workbook,
|
||||
}
|
||||
impl Host for Empty {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
&self.wb
|
||||
}
|
||||
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
||||
Value::Empty
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn random(&mut self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_with(fuel: u64, src: &str) -> Value {
|
||||
let mut host = Empty { wb: Workbook::default() };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||
ev.fuel = fuel;
|
||||
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
||||
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
||||
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
|
||||
// which happens well before the budget does.)
|
||||
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert_eq!(r, Value::number(4.0), "{r:?}");
|
||||
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
|
||||
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
// One charge short of a single application: #CALC! with a zero budget.
|
||||
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
|
||||
assert_eq!(r, Value::number(3.0));
|
||||
// A flat helper map keeps working (3 of 3 invokes inside the budget).
|
||||
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
|
||||
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,24 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
|
||||
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
|
||||
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
|
||||
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
|
||||
let mut t = T::new();
|
||||
t.set(
|
||||
"A1",
|
||||
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
|
||||
);
|
||||
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
|
||||
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
|
||||
assert_eq!(t.num("A1"), 45.0);
|
||||
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
|
||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
|
||||
@@ -350,21 +350,6 @@ mod tests {
|
||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||
}
|
||||
|
||||
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
|
||||
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
|
||||
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
|
||||
/// reference.
|
||||
#[test]
|
||||
fn split_sheet_leaves_device_names_inert_in_the_body() {
|
||||
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
|
||||
let (sheet, rest) = split_sheet(s);
|
||||
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
|
||||
let _ = sheet;
|
||||
}
|
||||
// A plain single-letter sheet is still split, not mistaken for a body.
|
||||
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
|
||||
@@ -371,16 +371,10 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
|
||||
if let Some((d, f)) = parse_date(t, sys) {
|
||||
return Some((d, Some(f)));
|
||||
}
|
||||
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
|
||||
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
|
||||
// instead of reparsing every prefix for every space (one long text field quadratic).
|
||||
// date + time: split at the last space(s) before a time-looking tail.
|
||||
for (i, _) in t.match_indices(' ') {
|
||||
let Some(b) = t.get(i + 1..) else { continue };
|
||||
let bytes = b.as_bytes();
|
||||
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
|
||||
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
|
||||
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
||||
}
|
||||
}
|
||||
@@ -406,20 +400,6 @@ mod tests {
|
||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_time_fields_stay_text() {
|
||||
// #2168: all-digit field lengths are gated before allocation/derivation,
|
||||
// so hostile strings never spin the time-derivation machinery.
|
||||
let big = "9".repeat(500);
|
||||
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
||||
}
|
||||
// Within the gate, times still work.
|
||||
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
|
||||
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
|
||||
@@ -264,15 +264,27 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
|
||||
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
match_from(&p, &t, true).is_some()
|
||||
let mut budget = MAX_STEPS;
|
||||
match_from(&p, &t, true, &mut budget).is_some()
|
||||
}
|
||||
|
||||
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
|
||||
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
|
||||
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
|
||||
/// like the other work caps on far-past-realistic input.
|
||||
const MAX_STEPS: u64 = 1 << 26;
|
||||
|
||||
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
|
||||
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
|
||||
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
|
||||
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
|
||||
/// cell ceiling costs a few hundred thousand steps).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if pi == p.len() && (!whole || ti == t.len()) {
|
||||
return Some(ti);
|
||||
}
|
||||
@@ -307,10 +319,32 @@ fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
// A leading run of stars never matters for a prefix match (it consumes nothing), so
|
||||
// dropping it lets the anchor below skip linearly through the text (#2382).
|
||||
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
|
||||
let p = &p[skip.min(p.len())..];
|
||||
if p.is_empty() {
|
||||
return if start <= t.len() { Some(start) } else { None };
|
||||
}
|
||||
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
|
||||
// The first token that consumes a character anchors the scan: a literal skips straight
|
||||
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
|
||||
// one suffix try. Every attempt shares one budget, so the total work stays flat.
|
||||
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
|
||||
let anchor = p.get(anys);
|
||||
let mut budget = MAX_STEPS;
|
||||
let mut i = start;
|
||||
while i < t.len() {
|
||||
let hit = match (anchor, t.get(i + anys)) {
|
||||
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
|
||||
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
};
|
||||
if hit {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||
|
||||
@@ -74,24 +74,14 @@ impl<'a> Package<'a> {
|
||||
let remaining = MAX_TOTAL.saturating_sub(self.total);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
|
||||
// A read that fails partway has already spent decompression work and buffered data
|
||||
// the package budget never sees: count the allowance as spent, so a loop over many
|
||||
// broken parts can't decompress it over and over.
|
||||
self.total = MAX_TOTAL;
|
||||
IoError::Zip(format!("{part}: {e}"))
|
||||
});
|
||||
(&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
if out.len() as u64 > limit {
|
||||
// The allowance was spent getting here; make the exhaustion terminal instead of
|
||||
// letting later reads pay the same decompression cost again.
|
||||
self.total = MAX_TOTAL;
|
||||
return Err(IoError::TooLarge(if remaining < maximum {
|
||||
"package expands to more than 1 GB".into()
|
||||
} else {
|
||||
format!("{part} expands to more than {maximum} bytes")
|
||||
}));
|
||||
}
|
||||
read?;
|
||||
self.total += out.len() as u64;
|
||||
Ok(Some(out))
|
||||
}
|
||||
@@ -211,20 +201,4 @@ mod tests {
|
||||
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
|
||||
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
|
||||
}
|
||||
|
||||
/// An over-budget read must spend the package's allowance once and for all: loops over many
|
||||
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
|
||||
/// remaining allowance again and again.
|
||||
#[test]
|
||||
fn a_failed_part_ends_the_package_budget() {
|
||||
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(pkg.read("a.xml").unwrap().is_some());
|
||||
// Nothing left to spend within the per-part limit either.
|
||||
pkg.total = MAX_TOTAL - 1;
|
||||
assert!(pkg.read("b.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
|
||||
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
|
||||
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,62 +33,12 @@ struct RowState {
|
||||
/// Dynamic-array spill ranges and legacy array ranges: cached values inside them (other than
|
||||
/// the anchor) are not stored, so the formula can fill them again.
|
||||
dynamic: Vec<RangeRef>,
|
||||
/// Cells inside the ranges above, for a cap before a pathological sheet proves it itself.
|
||||
dynamic_cells: u64,
|
||||
/// Row-membership checks served so far, for the work cap above.
|
||||
dynamic_checks: u64,
|
||||
dropped: u64,
|
||||
rows: u64,
|
||||
}
|
||||
|
||||
/// Whether `pos` lies in a known dynamic range that isn't its anchor: the cell's cached value
|
||||
/// gives way to the formula that will fill it. Charges every scanned range against one work
|
||||
/// budget, then gives up consulting the ranges at all: the worst kind of file may then import
|
||||
/// a few cached spill values, but it never stalls the whole import.
|
||||
fn in_dynamic(st: &mut RowState, pos: CellRef) -> bool {
|
||||
if st.dynamic_checks > MAX_DYNAMIC_CHECKS || st.dynamic.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let ranges = st.dynamic.len();
|
||||
let hit = st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos);
|
||||
st.dynamic_checks = st.dynamic_checks.saturating_add(ranges as u64);
|
||||
hit
|
||||
}
|
||||
|
||||
/// Records a dynamic-array range, so its cells outside the anchor import as empty and the
|
||||
/// formula fills them again. Repeated identical anchors (a damaged or hostile sheet may repeat
|
||||
/// the same record) are recorded once, and count and area are capped: every later cell inside
|
||||
/// known range territory pays a scan proportional to what is recorded here.
|
||||
fn push_dynamic(st: &mut RowState, range: RangeRef) {
|
||||
if st.dynamic.contains(&range) {
|
||||
return;
|
||||
}
|
||||
let cells = range.count();
|
||||
if st.dynamic.len() >= MAX_DYNAMIC_RANGES || st.dynamic_cells.saturating_add(cells) > MAX_DYNAMIC_CELLS {
|
||||
return;
|
||||
}
|
||||
st.dynamic_cells = st.dynamic_cells.saturating_add(cells);
|
||||
st.dynamic.push(range);
|
||||
}
|
||||
|
||||
const MAX_HYPERLINK_CELLS: u64 = 10_000;
|
||||
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
|
||||
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
|
||||
const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
|
||||
/// Most dynamic-array and legacy-array ranges of one sheet.
|
||||
const MAX_DYNAMIC_RANGES: usize = 65_536;
|
||||
/// Worst-case cells inside the dynamic ranges the sheet declares. Beyond it further ranges are
|
||||
/// dropped: they would cost their storage in every membership lookup that follows.
|
||||
const MAX_DYNAMIC_CELLS: u64 = 16_000_000;
|
||||
/// Most row-membership checks the sheet's ranges may serve. Beyond it the remaining cells are
|
||||
/// imported with their cached values instead of being blanked for a spill that may still come,
|
||||
/// so adversarial range counts can't make each cell scan a long list (a real workbook has a
|
||||
/// handful of ranges).
|
||||
const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
|
||||
/// Most conditional-format range cells one sheet keeps, over all rules of an element: every
|
||||
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
|
||||
const MAX_COND_CELLS: u64 = 4_000_000;
|
||||
|
||||
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
|
||||
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
|
||||
@@ -511,7 +461,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
||||
}
|
||||
let dynamic = c.attr("cm").is_some();
|
||||
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
|
||||
let value = if formula.is_none() && in_dynamic(st, pos) { Value::Empty } else { value };
|
||||
let value = if formula.is_none() && st.dynamic.iter().any(|r| r.contains(pos) && r.start != pos) { Value::Empty } else { value };
|
||||
sheet.cells.set(pos, Cell { value, formula: formula.map(Arc::new), style });
|
||||
}
|
||||
|
||||
@@ -552,7 +502,7 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
|
||||
let mut fm = Formula::new(text);
|
||||
let range = f.attr("ref").and_then(RangeRef::parse).unwrap_or(RangeRef::cell(pos));
|
||||
if !range.is_single() {
|
||||
push_dynamic(st, range);
|
||||
st.dynamic.push(range);
|
||||
}
|
||||
if !dynamic {
|
||||
fm.array = Some(range);
|
||||
@@ -722,12 +672,6 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
||||
if let Some(id) = ext_id {
|
||||
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
|
||||
}
|
||||
let rules = sheet.cond_formats.len();
|
||||
let area = ranges.iter().map(RangeRef::count).sum::<u64>().saturating_mul(1 + rules as u64);
|
||||
if area > MAX_COND_CELLS {
|
||||
cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped"));
|
||||
break;
|
||||
}
|
||||
sheet.cond_formats.push(CondFormat {
|
||||
ranges: ranges.clone(),
|
||||
rule,
|
||||
@@ -790,7 +734,6 @@ fn read_validation(v: &El) -> Option<Validation> {
|
||||
|
||||
fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
|
||||
let mut cells = 0u64;
|
||||
let mut bytes = 0u64;
|
||||
for h in e.kids("hyperlink") {
|
||||
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
|
||||
let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
|
||||
@@ -806,19 +749,13 @@ fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &m
|
||||
if target.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
|
||||
let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
|
||||
let link = Hyperlink { target, tooltip: h.attr("tooltip").map(str::to_string), external };
|
||||
for c in range.iter() {
|
||||
if cells >= MAX_HYPERLINK_CELLS {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
|
||||
return;
|
||||
}
|
||||
if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
|
||||
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
|
||||
return;
|
||||
}
|
||||
cells += 1;
|
||||
bytes += link_bytes;
|
||||
sheet.hyperlinks.insert(c, link.clone());
|
||||
}
|
||||
}
|
||||
|
||||
+22
-61
@@ -13,7 +13,7 @@ mod formula;
|
||||
mod ftab;
|
||||
mod records;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
@@ -59,14 +59,6 @@ pub(crate) fn is_xls(bytes: &[u8]) -> bool {
|
||||
cf.has_root_stream("Workbook") || cf.has_root_stream("Book")
|
||||
}
|
||||
|
||||
/// Most shared formulas one sheet expands, and the longest template text one attach may
|
||||
/// produce: a template shared by its followers was costing every one of them a full decode and
|
||||
/// print however often that could never succeed, so each anchor is tried once and failed ones
|
||||
/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their
|
||||
/// cached values.
|
||||
const MAX_SHARED_ATTACHES: usize = 262_144;
|
||||
const MAX_SHARED_TEMPLATE: usize = 8192;
|
||||
|
||||
pub(crate) struct Budget {
|
||||
cells: usize,
|
||||
text: usize,
|
||||
@@ -486,56 +478,6 @@ fn rk(v: u32) -> Value {
|
||||
Value::number(if v & 1 != 0 { n / 100.0 } else { n })
|
||||
}
|
||||
|
||||
/// Attaches every pending formula to its anchor's template, returning how many shared
|
||||
/// templates were expanded. An anchor whose template can't be imported is refused once:
|
||||
/// its followers re-decode the same template for nothing, so they are skipped instead (they
|
||||
/// keep their cached values).
|
||||
fn resolve_pending(
|
||||
st: &mut SheetState<'_>,
|
||||
arrays: &HashMap<CellRef, (RangeRef, Parsed)>,
|
||||
shared: &HashMap<CellRef, Parsed>,
|
||||
pending: &[Pending],
|
||||
) -> usize {
|
||||
let mut failed: HashSet<CellRef> = HashSet::new();
|
||||
let mut shared_attaches = 0usize;
|
||||
for p in pending {
|
||||
if let Some((range, f)) = arrays.get(&p.anchor) {
|
||||
if p.pos == p.anchor {
|
||||
st.attach(p.pos, f, Mode::Cell, Some(*range));
|
||||
}
|
||||
} else if let Some(f) = shared.get(&p.anchor) {
|
||||
if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES {
|
||||
continue;
|
||||
}
|
||||
shared_attaches += 1;
|
||||
st.attach(p.pos, f, Mode::Shared, None);
|
||||
let imported = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE);
|
||||
if !imported {
|
||||
failed.insert(p.anchor);
|
||||
}
|
||||
} else {
|
||||
st.unsupported("a shared formula that is missing from the file");
|
||||
}
|
||||
}
|
||||
if shared_attaches >= MAX_SHARED_ATTACHES {
|
||||
st.unsupported("more shared formulas than the expansion cap; some formulas kept their values");
|
||||
}
|
||||
shared_attaches
|
||||
}
|
||||
|
||||
/// The other cells of a legacy array formula hold only cached results: dropped, as when an
|
||||
/// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
|
||||
/// couldn't be imported, they keep their values.
|
||||
fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) {
|
||||
for p in pending {
|
||||
let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
|
||||
if p.pos != p.anchor && filled {
|
||||
let style = st.sheet.style_id(p.pos);
|
||||
st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worksheet(
|
||||
stream: &[u8],
|
||||
bs: &BoundSheet,
|
||||
@@ -591,8 +533,27 @@ fn worksheet(
|
||||
// Shared and array formulas: their definitions follow the first cell that uses them.
|
||||
let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared));
|
||||
let pending = std::mem::take(&mut st.pending);
|
||||
resolve_pending(&mut st, &arrays, &shared, &pending);
|
||||
blank_array_followers(&mut st, &pending);
|
||||
for p in &pending {
|
||||
if let Some((range, f)) = arrays.get(&p.anchor) {
|
||||
if p.pos == p.anchor {
|
||||
st.attach(p.pos, f, Mode::Cell, Some(*range));
|
||||
}
|
||||
} else if let Some(f) = shared.get(&p.anchor) {
|
||||
st.attach(p.pos, f, Mode::Shared, None);
|
||||
} else {
|
||||
st.unsupported("a shared formula that is missing from the file");
|
||||
}
|
||||
}
|
||||
// The other cells of a legacy array formula hold only cached results: dropped, as when an
|
||||
// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
|
||||
// couldn't be imported, they keep their values.
|
||||
for p in &pending {
|
||||
let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
|
||||
if p.pos != p.anchor && filled {
|
||||
let style = st.sheet.style_id(p.pos);
|
||||
st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style });
|
||||
}
|
||||
}
|
||||
if st.dropped > 0 {
|
||||
report.warn(format!("{} cells outside the grid were skipped.", st.dropped));
|
||||
}
|
||||
|
||||
@@ -64,22 +64,17 @@ pub(super) struct Cursor<'a, 'b> {
|
||||
parts: &'b [&'a [u8]],
|
||||
part: usize,
|
||||
pos: usize,
|
||||
/// Bytes in every part after the current one, kept up to date as parts advance, so
|
||||
/// `remaining()` is constant-time however many `Continue` records a string session crosses.
|
||||
after: usize,
|
||||
}
|
||||
|
||||
impl<'a, 'b> Cursor<'a, 'b> {
|
||||
pub fn new(parts: &'b [&'a [u8]]) -> Self {
|
||||
let after = parts.iter().skip(1).map(|p| p.len()).sum::<usize>();
|
||||
Cursor { parts, part: 0, pos: 0, after }
|
||||
Cursor { parts, part: 0, pos: 0 }
|
||||
}
|
||||
|
||||
/// Skips to the next part when the current one is used up. Returns whether a new part began.
|
||||
fn settle(&mut self) -> bool {
|
||||
let mut moved = false;
|
||||
while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() {
|
||||
self.after = self.after.saturating_sub(self.parts[self.part + 1].len());
|
||||
self.part += 1;
|
||||
self.pos = 0;
|
||||
moved = true;
|
||||
@@ -117,10 +112,10 @@ impl<'a, 'b> Cursor<'a, 'b> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bytes left in the record (all parts), constant-time in the part count.
|
||||
/// Bytes left in the record (all parts).
|
||||
pub fn remaining(&self) -> usize {
|
||||
let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos));
|
||||
here.saturating_add(self.after)
|
||||
here + self.parts.iter().skip(self.part + 1).map(|p| p.len()).sum::<usize>()
|
||||
}
|
||||
|
||||
/// The rest of the record as one buffer.
|
||||
@@ -134,7 +129,6 @@ impl<'a, 'b> Cursor<'a, 'b> {
|
||||
}
|
||||
self.part = self.parts.len();
|
||||
self.pos = 0;
|
||||
self.after = 0;
|
||||
out
|
||||
}
|
||||
|
||||
@@ -201,23 +195,4 @@ mod tests {
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert!(c.chars(MAX_CHARS + 1, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_every_part_wherever_the_cursor_is() {
|
||||
let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]];
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(c.remaining(), 13);
|
||||
for b in 1..=3u64 {
|
||||
assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order");
|
||||
}
|
||||
assert_eq!(c.remaining(), 10, "after the first part is used up");
|
||||
for _ in 0..4 {
|
||||
c.u8().unwrap();
|
||||
}
|
||||
assert_eq!(c.remaining(), 6, "into the Continue parts");
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(u64::from(c.u8().unwrap()), 1);
|
||||
assert_eq!(c.rest().len(), 12);
|
||||
assert_eq!(c.remaining(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-32
@@ -13,12 +13,6 @@ use crate::IoError;
|
||||
/// Maximum element nesting accepted in any part.
|
||||
pub const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Most elements kept in one part's parsed XML tree: an emptied `El` costs well over a hundred
|
||||
/// bytes, so a highly-compressible part of endless empty elements could fit the package byte
|
||||
/// limit and still take gigabytes of tree before any semantic check ran. Parts whose big runs
|
||||
/// matter (worksheet rows, shared strings) are streamed instead and never hold this tree.
|
||||
pub const MAX_XML_NODES: usize = 1_000_000;
|
||||
|
||||
/// An element: local name (namespace prefix dropped), attributes by local name, children and
|
||||
/// its direct text content.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -229,27 +223,13 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
let mut stack: Vec<El> = Vec::new();
|
||||
let mut root: Option<El> = None;
|
||||
let mut buf = Vec::new();
|
||||
// Elements kept in the tree so far, so a pathological part that passes the byte limits can
|
||||
// still be refused before the tree itself outgrows memory.
|
||||
let mut kept = 0usize;
|
||||
// Attach a finished element to its parent (or emit it / make it the root).
|
||||
fn finish(
|
||||
stack: &mut [El],
|
||||
root: &mut Option<El>,
|
||||
el: El,
|
||||
split: &[&str],
|
||||
cb: &mut dyn FnMut(El) -> Result<(), IoError>,
|
||||
kept: &mut usize,
|
||||
) -> Result<(), IoError> {
|
||||
fn finish(stack: &mut [El], root: &mut Option<El>, el: El, split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result<(), IoError> {
|
||||
match stack.last_mut() {
|
||||
Some(parent) => {
|
||||
if split.contains(&el.name.as_str()) {
|
||||
cb(el)?;
|
||||
} else {
|
||||
*kept = kept.saturating_add(1);
|
||||
if *kept > MAX_XML_NODES {
|
||||
return Err(IoError::Xml(format!("the part holds more than {MAX_XML_NODES} elements")));
|
||||
}
|
||||
parent.children.push(el);
|
||||
}
|
||||
}
|
||||
@@ -272,11 +252,11 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
}
|
||||
Event::Empty(e) => {
|
||||
let el = start_el(&e);
|
||||
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
|
||||
finish(&mut stack, &mut root, el, split, cb)?;
|
||||
}
|
||||
Event::End(_) => {
|
||||
if let Some(el) = stack.pop() {
|
||||
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
|
||||
finish(&mut stack, &mut root, el, split, cb)?;
|
||||
}
|
||||
}
|
||||
Event::Text(t) => {
|
||||
@@ -315,7 +295,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
}
|
||||
// Unclosed elements (truncated part): close them so we keep what we have.
|
||||
while let Some(el) = stack.pop() {
|
||||
finish(&mut stack, &mut root, el, split, cb, &mut kept)?;
|
||||
finish(&mut stack, &mut root, el, split, cb)?;
|
||||
}
|
||||
root.ok_or_else(|| IoError::Xml("no root element".into()))
|
||||
}
|
||||
@@ -495,14 +475,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_of_endless_empty_elements_is_refused() {
|
||||
assert!(parse(b"<r><x/><y t=\"1\"/><x/></r>").is_ok());
|
||||
let xml = format!("<r>{}<y/></r>", "<x/>".repeat(MAX_XML_NODES + 1));
|
||||
let err = parse(xml.as_bytes()).unwrap_err();
|
||||
assert!(err.to_string().contains("elements"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phonetic_data_validates_offsets() {
|
||||
let x = r#"<si><t>課きく</t><rPh sb="0" eb="1"><t>カ</t></rPh><rPh sb="2" eb="9"><t>ク</t></rPh><rPh sb="2" eb="2"><t>x</t></rPh><rPh sb="1"/></si>"#;
|
||||
|
||||
Reference in New Issue
Block a user