common/parser_tools/djot_depth.rs
1//! A ceiling on how deeply nested a Djot document may be before it is parsed.
2//!
3//! # The failure this prevents
4//!
5//! `jotdown` descends once per nested block container and has no depth limit of
6//! its own. A few kilobytes of prose — on the order of two thousand nested
7//! blockquote markers — exhausts the stack.
8//!
9//! That is not a panic. **A stack overflow aborts the process**: it cannot be
10//! caught by `catch_unwind`, a panic hook does not run, and every unsaved
11//! document in every window of the embedding application dies with it. So it
12//! cannot be handled by the caller after the fact; it has to be refused before
13//! `jotdown` is handed the text at all.
14//!
15//! The input is not always the author's own. A `.skrib` bundle is mailed,
16//! shared on a drive and restored from someone else's backup; an imported
17//! `.docx` comes from an editor. Any of those can carry prose this crate then
18//! parses.
19//!
20//! # Why a scan rather than a limit inside the parser
21//!
22//! A depth limit belongs in the recursive descent itself, and this is not that.
23//! `jotdown` is an external crate and its recursion is not reachable from here,
24//! so what this module does instead is bound the *input*: nesting cannot exceed
25//! the number of nesting markers the text actually contains, so counting them is
26//! a conservative upper bound on how deep the parser can go.
27//!
28//! It deliberately over-estimates. Every construct counted here *may* open a
29//! container and some will not — a `>` inside a code fence is prose. Over-counting
30//! is the safe direction: it can only flag a document that was closer to the
31//! ceiling than it looked, and the ceiling sits two orders of magnitude above
32//! anything a person writes.
33//!
34//! # What callers do with it
35//!
36//! [`parse_djot`](super::content_parser::parse_djot) keeps its signature and
37//! **degrades rather than refusing**: over-deep input comes back as a single
38//! plain paragraph holding the source verbatim. Nothing is lost — the text is
39//! all still there — it is simply not given a structure, which is the honest
40//! answer for a document whose structure cannot be computed without ending the
41//! process.
42//!
43//! # The limit
44//!
45//! [`MAX_NESTING_DEPTH`] is 96. For scale, a blockquote inside a list inside a
46//! footnote inside a div is 4; CommonMark's own reference implementations cap
47//! list nesting far below this. No real document reaches 96, and 96 is far below
48//! the ~2000 that overflows a debug build.
49
50/// The most nested block containers a document may declare before
51/// [`is_too_deep`] reports it.
52pub const MAX_NESTING_DEPTH: usize = 96;
53
54/// A conservative upper bound on the block nesting `text` can produce.
55///
56/// Counts, per line: the run of blockquote markers opening it, the number of
57/// `:::` div fences currently open, and one level per two columns of leading
58/// indentation (the coarsest list-nesting unit Djot admits).
59pub fn nesting_depth(text: &str) -> usize {
60 let mut open_divs = 0usize;
61 let mut deepest = 0usize;
62
63 for line in text.lines() {
64 let trimmed = line.trim_start();
65
66 if let Some(rest) = trimmed.strip_prefix(":::") {
67 // A **closing** fence is colons and nothing else. Djot lets an outer
68 // fence be longer than three so a div can nest inside another, so
69 // stripping `:::` leaves `":"` on a `::::` line — reading any
70 // non-empty remainder as a class would count a closing `::::` as a
71 // second opener, and the count would never come back down.
72 let rest = rest.trim();
73 if rest.is_empty() || rest.chars().all(|c| c == ':') {
74 open_divs = open_divs.saturating_sub(1);
75 } else {
76 open_divs += 1;
77 deepest = deepest.max(open_divs);
78 }
79 continue;
80 }
81
82 let indent = line.len() - trimmed.len();
83 let mut quotes = 0usize;
84 for ch in trimmed.chars() {
85 match ch {
86 '>' => quotes += 1,
87 ' ' | '\t' => {}
88 _ => break,
89 }
90 }
91
92 deepest = deepest.max(quotes + open_divs + indent / 2);
93 }
94
95 deepest
96}
97
98/// Whether `text` nests deeply enough to risk exhausting the stack.
99pub fn is_too_deep(text: &str) -> bool {
100 nesting_depth(text) > MAX_NESTING_DEPTH
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn ordinary_prose_is_shallow() {
109 for text in [
110 "The ferry was late.\n\nShe waited.\n",
111 "> He said it plainly.\n>\n> Then he left.\n",
112 "- one\n - two\n - three\n",
113 "::: note\nA note.\n:::\n",
114 "> - a quoted list\n> - nested once\n",
115 "",
116 ] {
117 assert!(!is_too_deep(text), "should accept: {text:?}");
118 }
119 }
120
121 /// The shape measured aborting the process: a blockquote marker run is the
122 /// cheapest way to reach the recursion.
123 #[test]
124 fn a_deep_blockquote_run_is_flagged() {
125 assert!(is_too_deep(&format!("{}deep\n", ">".repeat(2_000))));
126 }
127
128 #[test]
129 fn deeply_stacked_divs_are_flagged() {
130 assert!(is_too_deep(&"::: a\n".repeat(500)));
131 }
132
133 #[test]
134 fn runaway_indentation_is_flagged() {
135 assert!(is_too_deep(&format!("{}item\n", " ".repeat(1_000))));
136 }
137
138 /// Sibling divs close each other, so a long document made of many of them
139 /// has the nesting of one — not of all of them.
140 #[test]
141 fn sibling_divs_do_not_accumulate() {
142 assert!(!is_too_deep(&"::: note\nbody\n:::\n".repeat(500)));
143 }
144
145 #[test]
146 fn a_longer_closing_fence_closes_rather_than_opens() {
147 let text = ":::: outer\n::: inner\nbody\n:::\n::::\n".repeat(200);
148 assert!(!is_too_deep(&text), "nested fences must not accumulate");
149 }
150
151 /// The one that matters: run the **real** parser on input that used to
152 /// abort the process, and check it comes back.
153 ///
154 /// A stack overflow is not a panic, so this test cannot be written with
155 /// `#[should_panic]` or `catch_unwind` — if the guard regresses, the test
156 /// binary dies and takes the whole run with it. That is the intended
157 /// signal, and it is why the assertion is about the *content* coming back
158 /// whole rather than merely about not crashing.
159 #[test]
160 fn the_real_parser_survives_input_that_used_to_abort_the_process() {
161 use crate::parser_tools::content_parser::{ParsedElement, parse_djot};
162 use crate::parser_tools::djot_options::DjotImportOptions;
163
164 let hostile = format!("{}deep\n", ">".repeat(4_000));
165 let elements = parse_djot(&hostile, &DjotImportOptions::default());
166
167 assert_eq!(elements.len(), 1, "degrades to a single block");
168 let ParsedElement::Block(block) = &elements[0] else {
169 panic!("expected a plain paragraph");
170 };
171 let text: String = block.spans.iter().map(|s| s.text.as_str()).collect();
172 assert_eq!(
173 text, hostile,
174 "the source must come back verbatim — degrading may not lose prose"
175 );
176 }
177
178 /// And a document just under the ceiling still parses normally, so the
179 /// guard is not quietly flattening real prose.
180 #[test]
181 fn prose_below_the_ceiling_still_gets_its_structure() {
182 use crate::parser_tools::content_parser::parse_djot;
183 use crate::parser_tools::djot_options::DjotImportOptions;
184
185 let ok = "> > > a quoted quote\n\nand a paragraph\n";
186 let elements = parse_djot(ok, &DjotImportOptions::default());
187 assert!(
188 elements.len() > 1,
189 "a two-block document must still parse as two blocks: {elements:#?}"
190 );
191 }
192}