From 472132b1585bef6bec6a296128f2ac72166034ca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 19:44:55 +0100 Subject: [PATCH] math: refuse macro definitions and cap matrix cells RaTeX counts macro expansions (1,000) but not how large they grow, so a definition whose argument doubles on every call keeps the expander busy without bound. User macros have no use in an equation, so check_caps now refuses the definers (def, gdef, edef, xdef, let, futurelet, global, long, newcommand, renewcommand, providecommand) before RaTeX sees the source. The scan reads control words, verbatim text and comments the way RaTeX's lexer does. A matrix is laid out and written as rows times the widest row, so one long row in a tall matrix multiplied the output. check_caps now counts the padded cells of every environment from the source and refuses more than 2,500 in total; to_omml checks the parsed grid as well. Co-Authored-By: Claude Sonnet 5.5 --- crates/math/src/lib.rs | 232 ++++++++++++++++++++++++++++++++++++++-- crates/math/src/omml.rs | 3 + docs/decisions.md | 5 +- 3 files changed, 232 insertions(+), 8 deletions(-) diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs index 5e1e99a41..e1f783dca 100644 --- a/crates/math/src/lib.rs +++ b/crates/math/src/lib.rs @@ -3,7 +3,7 @@ //! without calling RaTeX again. //! //! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`], -//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under +//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32, no macro definitions, at most [`MAX_CELLS`] matrix cells) and runs under //! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in //! `docs/decisions.md` and `tests/gate.rs`. //! @@ -33,6 +33,9 @@ pub const MAX_DEPTH: usize = 32; pub const MAX_EM: f32 = 10_000.0; /// Most items one equation may produce. pub const MAX_ITEMS: usize = 50_000; +/// Most matrix cells one equation may have, counting the empty cells that pad a short row to the +/// width of the widest one. A ragged matrix costs rows times the widest row, not what is typed. +pub const MAX_CELLS: usize = 2_500; /// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can /// answer for such a source by its length alone, without reading it. @@ -223,20 +226,61 @@ fn parse(latex: &str) -> Result, MathError> { }) } -/// The size and nesting caps, checked before RaTeX sees the source. +/// Control words that define or rebind macros. User macros have no use in an equation, and +/// RaTeX caps only how many expansions run, not how large they grow, so a self-referencing +/// definition whose argument doubles on every call keeps the expander busy without bound. They are +/// refused before RaTeX sees the source. +const MACRO_DEFINERS: &[&str] = + &["def", "gdef", "edef", "xdef", "let", "futurelet", "global", "long", "newcommand", "renewcommand", "providecommand"]; + +/// One open `\begin…\end` environment, counted from the source text. +struct Grid { + /// Rows ended so far (by `\\` or `\cr`). + rows: usize, + /// Cells in the current row. + cells: usize, + /// Cells in the widest row so far. + widest: usize, +} + +impl Grid { + fn new() -> Grid { + Grid { rows: 0, cells: 1, widest: 0 } + } + fn end_row(&mut self) { + self.widest = self.widest.max(self.cells); + self.rows += 1; + self.cells = 1; + } + /// The padded grid RaTeX lays out and `to_omml` writes: every row as wide as the widest. + fn padded_cells(&self) -> usize { + (self.rows + 1).saturating_mul(self.widest.max(self.cells)) + } +} + +/// The size, nesting, macro and matrix caps, checked before RaTeX sees the source. fn check_caps(latex: &str) -> Result<(), MathError> { if latex.len() > MAX_SOURCE_BYTES { return Err(SOURCE_TOO_LONG.clone()); } + // `%` starts a comment, except inside the address of `\href` and `\url`, where RaTeX reads it + // as a character. Which one it is decides what the rest of the line means, so the two are not + // allowed together. + if latex.contains('%') && (latex.contains("\\href") || latex.contains("\\url")) { + return Err(MathError::new("A % can't be combined with a link in an equation.", latex.find('%').map(|i| i..i + 1))); + } let mut depth = 0usize; + let mut grids: Vec = Vec::new(); + let mut cells_total = 0usize; let mut chars = latex.char_indices().peekable(); while let Some((i, c)) = chars.next() { let open = match c { '\\' => { - // A control word, or an escaped character (`\{` is not a group). + // A control word (letters and `@`, as RaTeX's lexer reads it), or an escaped + // character (`\{` is not a group). let mut word = String::new(); while let Some(&(_, n)) = chars.peek() { - if n.is_ascii_alphabetic() { + if n.is_ascii_alphabetic() || n == '@' { word.push(n); chars.next(); } else { @@ -244,15 +288,69 @@ fn check_caps(latex: &str) -> Result<(), MathError> { } } if word.is_empty() { - chars.next(); + if let Some((_, n)) = chars.next() + && n == '\\' + && let Some(g) = grids.last_mut() + { + g.end_row(); + } continue; } + if MACRO_DEFINERS.contains(&word.as_str()) { + return Err(MathError::new("Macro definitions are not supported in equations.", Some(i..i + 1 + word.len()))); + } match word.as_str() { - "left" | "begin" => Some(true), - "right" | "end" => Some(false), + "verb" => { + // Verbatim text runs to the next copy of its delimiter, whatever it + // holds: no group, comment, row or definition starts inside it. + if chars.peek().is_some_and(|&(_, n)| n == '*') { + chars.next(); + } + if let Some((_, delim)) = chars.next() { + for (_, n) in chars.by_ref() { + if n == delim { + break; + } + } + } + None + } + "cr" => { + if let Some(g) = grids.last_mut() { + g.end_row(); + } + None + } + "begin" => { + if let Some(n) = alignat_columns(latex.get(i + 6..).unwrap_or_default()) + && n > MAX_ALIGNAT_PAIRS + { + return Err(too_many_cells(i..i + 6)); + } + grids.push(Grid::new()); + Some(true) + } + "end" => { + if let Some(g) = grids.pop() { + cells_total = cells_total.saturating_add(g.padded_cells()); + if cells_total > MAX_CELLS { + return Err(too_many_cells(i..i + 1 + word.len())); + } + } + Some(false) + } + "left" => Some(true), + "right" => Some(false), _ => None, } } + '&' | '@' => { + // `@` makes an arrow cell and an empty cell beside it in a `CD` diagram. + if let Some(g) = grids.last_mut() { + g.cells += if c == '@' { 2 } else { 1 }; + } + None + } '%' => { // A comment runs to the end of the line. for (_, n) in chars.by_ref() { @@ -277,9 +375,39 @@ fn check_caps(latex: &str) -> Result<(), MathError> { None => {} } } + // An environment left open is a syntax error for the parser to report, but it still counts. + for g in grids { + cells_total = cells_total.saturating_add(g.padded_cells()); + } + if cells_total > MAX_CELLS { + return Err(too_many_cells(latex.len()..latex.len())); + } Ok(()) } +/// Most column pairs `\begin{alignedat}{N}` may ask for. RaTeX allocates 2N column specs before +/// it lays anything out. +const MAX_ALIGNAT_PAIRS: usize = 50; + +/// N of `{alignat}{N}`, `{alignat*}{N}` or `{alignedat}{N}` at the start of `rest` (just after +/// `\begin`); `usize::MAX` when the count is present but too long to read. +fn alignat_columns(rest: &str) -> Option { + let rest = rest.trim_start().strip_prefix('{')?; + let (name, after) = rest.split_once('}')?; + if !matches!(name, "alignat" | "alignat*" | "alignedat") { + return None; + } + let digits = after.trim_start().strip_prefix('{')?.split('}').next()?.trim(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(digits.parse().unwrap_or(usize::MAX)) +} + +fn too_many_cells(span: Range) -> MathError { + MathError::new(format!("An equation can have at most {MAX_CELLS} matrix cells (rows times the widest row)."), Some(span)) +} + fn color(c: &Color) -> Option<[u8; 3]> { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let rgb = [q(c.r), q(c.g), q(c.b)]; @@ -509,6 +637,96 @@ mod tests { } } + /// Runs `f` on its own thread and fails if it has not returned within `secs` seconds. + fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || { + let _ = tx.send(f()); + }) + .unwrap(); + match rx.recv_timeout(std::time::Duration::from_secs(secs)) { + Ok(v) => v, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("did not return within {secs} s"), + Err(e) => panic!("the worker thread died: {e}"), + } + } + + #[test] + fn macro_definitions_are_refused_before_the_expander_runs() { + let defs = [ + "\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\gdef\\a#1{\\a{#1#1#1#1}}\\a{x}", + "\\edef\\a#1{\\a{#1#1}}\\a{x}", + "\\xdef\\a#1{{#1}{#1}}\\a{x}", + "\\newcommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\renewcommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\providecommand{\\a}[1]{\\a{#1#1}}\\a{x}", + "\\let\\a\\frac \\a{1}{2}", + "\\futurelet\\a\\frac \\a{1}{2}", + "\\global\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\long\\def\\a#1{\\a{#1#1}}\\a{x}", + "{\\def\\a#1{\\a{#1#1}}}\\a{x}", + ]; + for src in defs { + // `check_caps` alone, so this cannot hang even if the refusal regresses. + let e = check_caps(src).unwrap_err(); + assert!(e.message.contains("Macro definitions"), "{src}: {e}"); + assert_eq!(e.span.as_ref().map(|r| &src[r.clone()]).map(|w| w.starts_with('\\')), Some(true)); + let s = src.to_string(); + let (c, l, o) = within(10, move || (check(&s).is_err(), layout(&s).is_err(), to_omml(&s, 11.0, None).is_err())); + assert!(c && l && o, "{src}"); + } + // Names that merely start with a definer, escaped backslashes and comments are not macros. + for ok in ["\\default", "\\letter", "\\\\def", "x % \\def\\a\n+1", "\\longrightarrow"] { + assert!(check_caps(ok).is_ok(), "{ok}"); + } + assert!(layout("\\longrightarrow x").is_ok()); + // Text the lexer reads differently from a plain scan must not hide a definition: a + // verbatim `%` is no comment, a `%` in a link address is no comment, `@` joins a word. + for src in [ + "\\verb|%|\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\verb\\x\\\\def\\a#1{\\a{#1#1}}\\a{x}", + "%\\verb|\n\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\href{a%b}{x}\\def\\a#1{\\a{#1#1}}\\a{x}", + "\\url{a%}\\def\\a#1{\\a{#1#1}}\\a{x}", + ] { + assert!(check_caps(src).is_err(), "{src}"); + } + assert!(check_caps("\\verb|\\def| x").is_ok(), "verbatim text is not a definition"); + assert!(check_caps("\\def@x").is_ok(), "`\\def@x` is one unknown word to the lexer"); + } + + #[test] + fn ragged_matrices_are_capped_by_their_padded_size() { + let ragged = format!("\\begin{{matrix}}{}{}\\end{{matrix}}", "a\\\\".repeat(650), "a&".repeat(999) + "a"); + assert!(ragged.len() < MAX_SOURCE_BYTES); + let e = check_caps(&ragged).unwrap_err(); + assert!(e.message.contains("matrix cells"), "{e}"); + let r = ragged.clone(); + let (l, o) = within(10, move || (layout(&r).unwrap_err(), to_omml(&r, 11.0, None).unwrap_err())); + assert!(l.message.contains("matrix cells") && o.message.contains("matrix cells")); + // Without the environment words there is no grid to count; the parser rejects it. + // Ordinary and wide-but-regular matrices still work, and so does a trailing `\\`. + let wide = format!("\\begin{{pmatrix}}{}\\end{{pmatrix}}", vec!["a&".repeat(39) + "a"; 40].join("\\\\")); + assert!(wide.len() < MAX_SOURCE_BYTES); + assert!(layout(&wide).is_ok()); + assert!(to_omml(&wide, 11.0, None).is_ok()); + assert!(layout("\\begin{pmatrix} a & b \\\\ c & d \\\\ \\end{pmatrix}").is_ok()); + // Diagram arrows and `alignedat` column counts size a grid without any `&`. + let cd = format!("\\begin{{CD}}{}{}\\end{{CD}}", "A\\\\".repeat(900), "@>>>".repeat(300)); + assert!(cd.len() < MAX_SOURCE_BYTES); + assert!(check_caps(&cd).is_err_and(|e| e.message.contains("matrix cells"))); + let r = "\\begin{alignedat}{99999999}a\\end{alignedat}"; + assert!(check_caps(r).is_err_and(|e| e.message.contains("matrix cells"))); + assert!(check_caps("\\begin{alignedat}{2}a&b&c&d\\end{alignedat}").is_ok()); + assert!(layout("\\begin{CD}A @>f>> B \\\\ @VVV @VVV \\\\ C @>>> D\\end{CD}").is_ok()); + assert!(check_caps("\\operatorname{curl} 50\\%").is_ok()); + let two = format!("{0}{0}", "\\begin{matrix}a&b\\\\c&d\\end{matrix}"); + assert!(check_caps(&two).is_ok()); + } + #[test] fn an_injected_panic_is_an_error_not_a_crash() { INJECT_PANIC.with(|p| p.set(true)); diff --git a/crates/math/src/omml.rs b/crates/math/src/omml.rs index dc0141aed..7d5c66835 100644 --- a/crates/math/src/omml.rs +++ b/crates/math/src/omml.rs @@ -406,6 +406,9 @@ impl Writer { } ParseNode::Array { body, is_cd: None | Some(false), .. } => { let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1); + if body.len().saturating_mul(cols) > crate::MAX_CELLS { + return Err(MathError::new(format!("An equation can have at most {} matrix cells.", crate::MAX_CELLS), None)); + } self.out.push_str(&format!( "" )); diff --git a/docs/decisions.md b/docs/decisions.md index 09bc9b5cc..7ccc445c8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -95,7 +95,10 @@ LaTeX subset: access. Indexing is not counted; the fuzz run covers it. - **Fuzzing.** 70,000 random token strings and 50,000 mutations of valid equations: no panic, no input slower than 5 ms. Self-referencing and exponential macros stop at the parser's - 1,000-expansion limit. Nesting deeper than 32 is refused ("Recursion limit exceeded"), and + 1,000-expansion limit, which counts expansions and not their size: a definition whose + argument doubles on every call never reaches it. The wrapper therefore refuses macro + definitions (`\def`, `\newcommand`, `\let` and the like) before RaTeX sees the source, and + caps the padded size of matrices at 2,500 cells. Nesting deeper than 32 is refused ("Recursion limit exceeded"), and the deepest accepted input needs under 256 KB of stack in a release build, well inside the smallest main-thread stack we run on (1 MB on Windows). Absurd sizes (`\rule{1e308em}{1em}`) lay out with huge dimensions, so the wrapper caps them.