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.