deps_core/parser.rs
1use std::collections::BTreeMap;
2use yaml_rust2::Event;
3use yaml_rust2::parser::{MarkedEventReceiver, Parser};
4use yaml_rust2::scanner::Marker;
5
6/// Maximum allowed nesting depth for TOML table/array recursion before
7/// [`check_toml_nesting_depth`] rejects the input.
8///
9/// Counts both `[`/`{` bracket depth and dotted-key/table-header segment
10/// count (e.g. `a.b.c` or `[a.b.c]`), since both drive `toml-span`'s
11/// recursive descent.
12///
13/// `toml-span` 0.7.1's recursive-descent parser has no recursion limit, so
14/// either a deeply nested `[[[...]]]` array/`{{{...}}}` inline-table
15/// literal, or a dotted key/header with many `.`-separated segments, can
16/// overflow the native thread stack and abort the whole process (SIGABRT)
17/// before `toml_span::parse` ever returns an error. As a library, `deps-core`
18/// cannot rely on its consumers raising their stack size, so this constant
19/// deliberately assumes the smallest stack any caller is likely to run on: a
20/// `tokio` worker thread's 2 MiB default (relevant since lock file parsing
21/// runs inside `tokio::spawn`), not the platform's larger 8 MiB main-thread
22/// default. `deps-lsp`, the one consumer in this workspace, additionally
23/// raises its `tokio` worker stacks to 8 MiB as defense-in-depth on top of
24/// this guard (see `WORKER_THREAD_STACK_SIZE` in `deps-lsp`'s `main.rs`), but
25/// the constant itself stays sized for the 2 MiB floor. Stack cost per level
26/// is also shape-dependent: nested inline tables (`{a={a=...}}`) cost
27/// noticeably more per level than nested arrays in a debug build.
28///
29/// Bisected against the real `toml_span` 0.7.1 recursion on a 2 MiB stack:
30/// a debug build survives depth 220 for inline tables / 305 for arrays; a
31/// release build survives roughly 2485 / 1805. 64 leaves a >3x margin under
32/// the tightest of these (debug inline tables, 220) while still being far
33/// deeper than any real manifest needs — across a corpus of thousands of
34/// real-world `.toml` files, the deepest observed bracket nesting was 5 and
35/// the deepest dotted-key path was 6 segments.
36pub const MAX_TOML_NESTING_DEPTH: usize = 64;
37
38/// Scans raw TOML text for table/array recursion deeper than `max_depth`.
39///
40/// `toml-span::parse` has no public option to cap recursion, so callers must
41/// reject pathological input before handing it to the parser. This performs a
42/// single-pass structural scan — no actual parsing, so it cannot itself
43/// recurse or overflow — that bounds the two independent ways TOML content
44/// drives `toml-span`'s table/array recursion:
45///
46/// - **Bracket nesting**: `[`/`{` and `]`/`}` pairs, as in `[[[1]]]` or
47/// `{a={a=1}}`.
48/// - **Dotted-key/header segments**: each `.` in a dotted key (`a.b.c = 1`)
49/// or dotted table header (`[a.b.c]`) creates one level of table nesting
50/// with zero bracket characters, so bracket-only counting alone is not
51/// sufficient. Dots are only counted in *key* position (start of a
52/// top-level statement, inside a `[...]`/`[[...]]` header, or right after
53/// `{`/`,` while the innermost open bracket is `{`) — never in *value*
54/// position, so `a = 3.14` and multi-segment version/date values are not
55/// miscounted.
56///
57/// Both counts accumulate into one shared depth budget bounded by
58/// `max_depth`, since both are ways `toml-span` recurses. Bracket characters
59/// and dots inside string literals or line comments are skipped, so this
60/// does not misfire on values like `"flask[async]>=3.0"` or `# example:
61/// [1, 2]`. Both single-line (`"..."`, `'...'`, honoring `\"` escapes) and
62/// multi-line (`"""..."""`, `'''...'''`, including a body that legally ends
63/// with 1-2 extra literal quote characters before the closing delimiter, per
64/// the TOML spec) string forms are recognized, so brackets and dots inside a
65/// multi-line string body are never miscounted.
66///
67/// # Errors
68///
69/// Returns `Err(depth)` with the depth reached the instant nesting exceeds
70/// `max_depth`.
71///
72/// # Examples
73///
74/// ```
75/// use deps_core::parser::check_toml_nesting_depth;
76///
77/// assert!(check_toml_nesting_depth(r#"a = [1, 2, [3, 4]]"#, 4).is_ok());
78/// assert!(check_toml_nesting_depth("a = '''don't'''\nb = [1]", 4).is_ok());
79/// assert!(check_toml_nesting_depth("a = 3.14\nb.c = 1", 4).is_ok());
80///
81/// let deeply_nested = format!("a = {}1{}", "[".repeat(10), "]".repeat(10));
82/// assert_eq!(check_toml_nesting_depth(&deeply_nested, 4), Err(5));
83///
84/// let deep_dotted_key = format!("a{} = 1", ".a".repeat(10));
85/// assert_eq!(check_toml_nesting_depth(&deep_dotted_key, 4), Err(5));
86/// ```
87pub fn check_toml_nesting_depth(content: &str, max_depth: usize) -> std::result::Result<(), usize> {
88 let bytes = content.as_bytes();
89 let len = bytes.len();
90 let mut depth: usize = 0;
91 let mut i = 0;
92
93 // Bracket kinds currently open, used only to tell whether a `,` is
94 // inside an inline table (`{`, next token is a key) or an array (`[`,
95 // next token is a value).
96 let mut bracket_stack: Vec<u8> = Vec::new();
97 // Dot-segment counts not yet released, one frame per currently-open key
98 // context: index 0 is the persistent top-level-statement frame (reset at
99 // each top-level newline); further frames are pushed per open `{`.
100 let mut dot_frames: Vec<usize> = vec![0];
101 let mut in_key = true;
102
103 while i < len {
104 match bytes[i] {
105 b'#' => {
106 while i < len && bytes[i] != b'\n' {
107 i += 1;
108 }
109 }
110 quote @ (b'"' | b'\'') => {
111 let is_multiline =
112 bytes.get(i + 1) == Some("e) && bytes.get(i + 2) == Some("e);
113 i = if is_multiline {
114 skip_multiline_string(bytes, i + 3, quote)
115 } else {
116 skip_single_line_string(bytes, i + 1, quote)
117 };
118 }
119 b'{' => {
120 depth += 1;
121 if depth > max_depth {
122 return Err(depth);
123 }
124 bracket_stack.push(b'{');
125 dot_frames.push(0);
126 in_key = true;
127 i += 1;
128 }
129 b'[' => {
130 depth += 1;
131 if depth > max_depth {
132 return Err(depth);
133 }
134 bracket_stack.push(b'[');
135 i += 1;
136 }
137 b'}' => {
138 if dot_frames.len() > 1 {
139 depth = depth.saturating_sub(dot_frames.pop().unwrap_or(0));
140 }
141 depth = depth.saturating_sub(1);
142 bracket_stack.pop();
143 in_key = false;
144 i += 1;
145 }
146 b']' => {
147 depth = depth.saturating_sub(1);
148 bracket_stack.pop();
149 i += 1;
150 }
151 b'.' if in_key => {
152 depth += 1;
153 if let Some(top) = dot_frames.last_mut() {
154 *top += 1;
155 }
156 if depth > max_depth {
157 return Err(depth);
158 }
159 i += 1;
160 }
161 b'=' if in_key => {
162 in_key = false;
163 i += 1;
164 }
165 b',' => {
166 match bracket_stack.last() {
167 Some(b'{') => {
168 if dot_frames.len() > 1 {
169 depth = depth.saturating_sub(dot_frames.pop().unwrap_or(0));
170 }
171 dot_frames.push(0);
172 in_key = true;
173 }
174 Some(b'[') => in_key = false,
175 _ => {}
176 }
177 i += 1;
178 }
179 b'\n' => {
180 if bracket_stack.is_empty() {
181 depth = depth.saturating_sub(dot_frames[0]);
182 dot_frames[0] = 0;
183 in_key = true;
184 }
185 i += 1;
186 }
187 _ => i += 1,
188 }
189 }
190
191 Ok(())
192}
193
194/// Advances past a single-line TOML string (`"..."` or `'...'`), returning
195/// the index just past its closing quote (or `bytes.len()` if unterminated —
196/// `toml_span` reports the real syntax error in that case, so it is safe for
197/// the rest of the file to be treated as string content here).
198fn skip_single_line_string(bytes: &[u8], mut i: usize, quote: u8) -> usize {
199 let len = bytes.len();
200 while i < len {
201 if bytes[i] == b'\\' && quote == b'"' {
202 i += 2;
203 continue;
204 }
205 if bytes[i] == quote {
206 return i + 1;
207 }
208 i += 1;
209 }
210 i
211}
212
213/// Advances past a multi-line TOML string body (after its opening `"""`/`'''`),
214/// returning the index just past the closing delimiter.
215///
216/// Per the TOML spec, a multi-line basic string body may end with 1-2 literal
217/// quote characters immediately before the closing triple quote (e.g.
218/// `"""ends with "".""""` — content `ends with ""."`, then the closer). Any
219/// run of 3+ consecutive unescaped quote characters is therefore treated as
220/// the closing delimiter, regardless of how many of those quotes are "extra"
221/// literal content versus the delimiter itself — the distinction does not
222/// matter here since the whole run is consumed either way.
223fn skip_multiline_string(bytes: &[u8], mut i: usize, quote: u8) -> usize {
224 let len = bytes.len();
225 while i < len {
226 if bytes[i] == b'\\' && quote == b'"' {
227 i += 2;
228 continue;
229 }
230 if bytes[i] == quote {
231 let run_start = i;
232 while i < len && bytes[i] == quote {
233 i += 1;
234 }
235 if i - run_start >= 3 {
236 return i;
237 }
238 continue;
239 }
240 i += 1;
241 }
242 i
243}
244
245/// Maximum allowed nesting depth for YAML block/flow recursion before
246/// [`check_yaml_nesting_depth`] rejects the input.
247///
248/// `yaml-rust2` 0.12's block-style (indentation-driven) sequence/mapping
249/// parser recurses once per nesting level with no depth limit (its flow-style
250/// `[[[...]]]` array parser already caps recursion, but block style and flow
251/// objects do not). A deeply nested `pubspec.yaml`/`pubspec.lock` can overflow
252/// the native thread stack and abort the whole process (SIGABRT) before
253/// `YamlLoader::load_from_str` ever returns an error. As with
254/// [`MAX_TOML_NESTING_DEPTH`], this constant assumes the smallest stack any
255/// caller is likely to run on: a `tokio` worker thread's 2 MiB default, not
256/// `deps-lsp`'s own 8 MiB `WORKER_THREAD_STACK_SIZE` (defense-in-depth on top
257/// of this guard).
258///
259/// Bisected against the real `yaml-rust2` 0.12 recursion on a 2 MiB debug
260/// stack: the cheapest attack — compact block-sequence chaining
261/// (`- - - - 1`, 2 bytes per level) — survives depth 4535 and aborts at 4536;
262/// growing-indent block mappings (`k:\n k:\n k:\n...`), the tightest case,
263/// survive depth 1993 and abort at 1994. 64 leaves a >30x margin under the
264/// tightest of these while still being far deeper than any real manifest
265/// needs — `pubspec.yaml`/`pubspec.lock` structures bottom out around 4-5
266/// levels (e.g. `packages.<name>.description.<field>`).
267pub const MAX_YAML_NESTING_DEPTH: usize = 64;
268
269/// Scans raw YAML text for block/flow recursion deeper than `max_depth`.
270///
271/// `YamlLoader::load_from_str` has no public option to cap recursion, so
272/// callers must reject pathological input before handing it to the parser.
273/// This performs a single-pass structural scan — no actual parsing, so it
274/// cannot itself recurse or overflow — that bounds the two independent ways
275/// YAML content drives `yaml-rust2`'s recursion:
276///
277/// - **Flow-style bracket nesting**: `[`/`{` and `]`/`}` pairs, as in
278/// `[[[1]]]` or `{a: {a: 1}}`.
279/// - **Block-style indentation**: each line whose leading indentation is
280/// deeper than the enclosing block context opens one nesting level (e.g. a
281/// mapping key or sequence item indented under its parent); each `-` in a
282/// compact chained sequence item (`- - - 1`) opens one level per dash,
283/// since it is equivalent to one nested single-item sequence per level.
284///
285/// Both counts accumulate into one shared depth budget bounded by
286/// `max_depth`. Line-start block indentation is scanned unconditionally on
287/// every line, even one that looks like a continuation of a still-open flow
288/// bracket from a previous line — an unclosed `[`/`{` must never be able to
289/// suppress scanning for the rest of the file (impl-critic C2), so this
290/// guard accepts occasionally over-counting a multi-line flow collection's
291/// continuation lines as extra block levels in exchange for never being able
292/// to go blind. A quote character is only treated as opening a quoted
293/// scalar when it sits at a token-start position (line start, or right
294/// after `: `, `- `, `[`, `{`, `,`) — never mid-token — so an apostrophe
295/// inside a plain scalar like `doesn't` is left alone rather than
296/// mistaken for the start of a string (impl-critic C1). Once a quoted
297/// scalar is opened, it is only ever trusted to close on the *same* line:
298/// hitting an unescaped `\n` before the matching quote resynchronizes the
299/// scanner at that newline unconditionally (including across a `\` right
300/// before it, which cannot extend the string past the line), rather than
301/// scanning forward indefinitely looking for a close — so neither a stray
302/// unquoted apostrophe nor a genuinely unterminated quoted scalar can ever
303/// blind the scanner to more than the remainder of one line. `#` outside a
304/// quoted scalar always starts a comment to end of line. Content indented
305/// under a literal/folded block scalar (`|`/`>`) is not specially exempted
306/// and is scanned like any other indentation, which can only make this
307/// guard *more* conservative, never less. Only ASCII space counts as
308/// indentation — a tab-indented line reads as indent 0, an assumption that
309/// currently holds only because `yaml-rust2` itself rejects tabs used for
310/// block indentation before recursing deep enough to matter.
311///
312/// # Errors
313///
314/// Returns `Err(depth)` with the depth reached the instant nesting exceeds
315/// `max_depth`.
316///
317/// # Examples
318///
319/// ```
320/// use deps_core::parser::check_yaml_nesting_depth;
321///
322/// assert!(check_yaml_nesting_depth("a:\n b:\n c: 1\n", 4).is_ok());
323///
324/// let deeply_nested = format!("{}1", "- ".repeat(10));
325/// assert!(check_yaml_nesting_depth(&deeply_nested, 4).is_err());
326///
327/// // An apostrophe mid-scalar must not blind the scanner to nesting later
328/// // in the file (impl-critic C1).
329/// let content = format!("a: it doesn't panic\n{}1", "- ".repeat(10));
330/// assert!(check_yaml_nesting_depth(&content, 4).is_err());
331/// ```
332pub fn check_yaml_nesting_depth(content: &str, max_depth: usize) -> std::result::Result<(), usize> {
333 let bytes = content.as_bytes();
334 let len = bytes.len();
335 let mut depth: usize = 0;
336 let mut indent_stack: Vec<usize> = Vec::new();
337 let mut bracket_stack: Vec<u8> = Vec::new();
338
339 let mut i = 0;
340 while i < len {
341 let mut indent = 0;
342 while i < len && bytes[i] == b' ' {
343 indent += 1;
344 i += 1;
345 }
346 if i >= len || bytes[i] == b'\n' || bytes[i] == b'#' {
347 i = skip_to_eol(bytes, i);
348 if i < len && bytes[i] == b'\n' {
349 i += 1;
350 }
351 continue;
352 }
353
354 while indent_stack.last().is_some_and(|&top| top > indent) {
355 indent_stack.pop();
356 depth = depth.saturating_sub(1);
357 }
358
359 let mut col = indent;
360 while i < len && bytes[i] == b'-' && (i + 1 == len || matches!(bytes[i + 1], b' ' | b'\n'))
361 {
362 if indent_stack.last() != Some(&col) {
363 depth += 1;
364 if depth > max_depth {
365 return Err(depth);
366 }
367 indent_stack.push(col);
368 }
369 i += 1;
370 col += 1;
371 while i < len && bytes[i] == b' ' {
372 i += 1;
373 col += 1;
374 }
375 }
376
377 if i < len && !matches!(bytes[i], b'\n' | b'#') && indent_stack.last() != Some(&col) {
378 depth += 1;
379 if depth > max_depth {
380 return Err(depth);
381 }
382 indent_stack.push(col);
383 }
384
385 // `prev` tracks whether the byte at `i` sits at a token-start
386 // position; starts `b' '` since we just consumed leading
387 // whitespace/dash-chain separators above.
388 let mut prev: u8 = b' ';
389 while i < len && bytes[i] != b'\n' {
390 match bytes[i] {
391 b'#' => {
392 i = skip_to_eol(bytes, i);
393 break;
394 }
395 quote @ (b'"' | b'\'')
396 if matches!(prev, b' ' | b'\t' | b':' | b',' | b'[' | b'{' | b'-') =>
397 {
398 i = skip_yaml_string(bytes, i + 1, quote);
399 prev = quote;
400 }
401 b'[' | b'{' => {
402 depth += 1;
403 if depth > max_depth {
404 return Err(depth);
405 }
406 bracket_stack.push(bytes[i]);
407 prev = bytes[i];
408 i += 1;
409 }
410 b']' | b'}' => {
411 if bracket_stack.pop().is_some() {
412 depth = depth.saturating_sub(1);
413 }
414 prev = bytes[i];
415 i += 1;
416 }
417 b => {
418 prev = b;
419 i += 1;
420 }
421 }
422 }
423 if i < len && bytes[i] == b'\n' {
424 i += 1;
425 }
426 }
427
428 Ok(())
429}
430
431/// Advances past the rest of the current line (used for blank and comment
432/// lines), returning the index of the `\n` or `bytes.len()`.
433fn skip_to_eol(bytes: &[u8], mut i: usize) -> usize {
434 let len = bytes.len();
435 while i < len && bytes[i] != b'\n' {
436 i += 1;
437 }
438 i
439}
440
441/// Advances past a YAML quoted scalar opened at a token-start position
442/// (`"..."` or `'...'`), returning the index just past its closing quote.
443///
444/// Only ever trusts a close on the *same* line: hits an unescaped `\n`
445/// before finding the matching quote, this returns the index of that
446/// newline unconsumed rather than continuing to search — so neither a
447/// genuinely unterminated quoted scalar nor a `\` placed right before the
448/// newline (which would otherwise "escape" it and extend the scan) can
449/// blind the caller's line-oriented scan to more than the current line
450/// (impl-critic C1). `yaml-rust2` reports the real syntax error for content
451/// this treats as unterminated. Handles double-quote backslash escapes and
452/// single-quote `''` escapes.
453fn skip_yaml_string(bytes: &[u8], mut i: usize, quote: u8) -> usize {
454 let len = bytes.len();
455 while i < len && bytes[i] != b'\n' {
456 if bytes[i] == b'\\' && quote == b'"' {
457 if bytes.get(i + 1) == Some(&b'\n') {
458 break;
459 }
460 i += 2;
461 continue;
462 }
463 if bytes[i] == quote {
464 if quote == b'\'' && bytes.get(i + 1) == Some(&b'\'') {
465 i += 2;
466 continue;
467 }
468 return i + 1;
469 }
470 i += 1;
471 }
472 i
473}
474
475/// Fixed per-node byte floor [`check_yaml_expansion`] charges for every
476/// `Yaml` node, on top of any heap content (e.g. a scalar's string bytes) it
477/// owns.
478///
479/// Derived from `size_of::<yaml_rust2::Yaml>()` itself (64 bytes on a 64-bit
480/// target as of `yaml-rust2` 0.12, dominated by the `String`/`Array`/`Hash`
481/// variants' inline pointer+len+cap fields plus the enum discriminant)
482/// rather than hardcoded, so a `yaml-rust2` layout change or a non-64-bit
483/// target cannot silently drift this out of sync with reality — the size of
484/// the value every `Yaml` node occupies wherever it is stored (a
485/// `Vec<Yaml>` element, a `Hash` entry, or a clone inside `anchor_map`),
486/// independent of its variant. This floor does *not* separately model every
487/// real cost `YamlLoader` incurs beyond it: `Hash`'s `LinkedHashMap`
488/// prev/next link pointers and hash-table slots, and `Vec`'s
489/// capacity-doubling slack, both add further real allocation on top of what
490/// this constant (and thus [`MAX_YAML_EXPANDED_BYTES`]) charges for — see
491/// that constant's doc for the measured size of that gap.
492const YAML_NODE_OVERHEAD_BYTES: u64 = size_of::<yaml_rust2::Yaml>() as u64;
493
494/// Maximum total byte weight [`check_yaml_expansion`] allows a document to
495/// expand to (counting anchor/alias-driven duplication) before rejecting it.
496///
497/// `yaml-rust2` 0.12's `YamlLoader::on_event_impl` deep-clones the whole
498/// anchored subtree once per `Event::Alias` reference (`anchor_map.get(&id)
499/// => v.clone()`), and again into `anchor_map` itself for every anchored
500/// node. Nesting depth (bounded by [`MAX_YAML_NESTING_DEPTH`]) is irrelevant
501/// to this: a shallow document with a handful of anchors, each aliased a
502/// handful of times, expands exponentially in the memory actually
503/// allocated. Critically, this must be a **byte** budget, not a node-count
504/// budget: a single large scalar anchor (e.g. a 1 MB string) aliased many
505/// times allocates megabytes per alias while costing only one node each, so
506/// a node-count budget lets it through cheaply — a document under 3 MB can
507/// exhaust hundreds of gigabytes this way. `YamlLoader` exposes no
508/// budget/config hook, so callers must reject pathological input before
509/// handing it to the loader.
510///
511/// `32 MiB` (`32 * 1024 * 1024` = 33,554,432) is the *charged* byte budget —
512/// not an exact bound on `YamlLoader`'s real peak allocation. Charged bytes
513/// track `YAML_NODE_OVERHEAD_BYTES`'s per-node floor plus scalar content,
514/// which undercounts two real costs that floor doesn't model: `Hash`'s
515/// `LinkedHashMap` prev/next link pointers and hash-table slots (hash-heavy
516/// documents, e.g. a `pubspec.lock`), and `String`/`Vec` capacity-doubling
517/// slack — the scanner builds every scalar via `String::new()` + repeated
518/// `push`, so a single large scalar whose length lands just past a
519/// power-of-two capacity boundary (e.g. 1,048,577 bytes) wastes nearly its
520/// own length again in unused capacity, and the same growth pattern applies
521/// to a `Vec` backing a long sequence. Measured with a counting allocator:
522/// real peak allocation runs about 1.16x-1.74x the charged total depending
523/// on document shape (steadier ~1.56x for hash-heavy lockfiles, up to ~2x
524/// for a large scalar or long sequence whose length lands right past a
525/// capacity-doubling boundary), so an accepted document charged right at
526/// this limit really allocates roughly 50-65 MB, not 32 MB. Bytes
527/// charged do not compound with nesting, so ~2x is the ceiling on that
528/// ratio, not a growing multiplier — the budget stays a bounded, linear
529/// function of input size either way, which is what actually matters for
530/// this guard (an *unbounded* multiplier, as with the pre-fix node-count
531/// budget's `O(2^depth)` blowup, is the failure mode this guards against).
532///
533/// Measured against real payloads (exact charged totals, reproducible
534/// against the shape scaled up from
535/// `test_check_yaml_expansion_few_hundred_package_lockfile_accepted`): a
536/// 1.9 MB / 12,500-package synthetic `pubspec.lock` charges 12,416,870 bytes
537/// (2.70x headroom); a 757 KB / 5,000-package one charges 4,961,870 bytes
538/// (6.76x headroom); the doubling-chain attack payload (see
539/// [`check_yaml_expansion`]'s doctest) charges 16,907,046 bytes at N=14
540/// (accepted) and 33,815,273 at N=15 (rejected, in well under a
541/// millisecond); and a single 1 MB anchor aliased 31 times (33,002,370
542/// bytes charged, ~1 MB source) is accepted while 32 times (34,002,434
543/// bytes) is rejected rather than allocating unboundedly.
544pub const MAX_YAML_EXPANDED_BYTES: usize = 32 * 1024 * 1024;
545
546/// Streams `content` through `yaml-rust2`'s own parser event stream and
547/// tallies the total bytes the `Yaml` nodes `YamlLoader::load_from_str`
548/// would allocate, rejecting once the tally exceeds `max_bytes`.
549///
550/// This is a pre-pass driven by the same `Parser`/event stream
551/// `YamlLoader::load_from_str` itself uses (`Parser::new(content.chars())`,
552/// `multi = true`), so anchor ids and event order are identical to the real
553/// load — unlike a raw-text `&anchor`/`*alias` scan, which was tried and
554/// rejected: ordinary prose such as `description: A widget *multiplier*
555/// helper` sits at exactly the position a text scanner treats as a token
556/// boundary, so it false-positives as an alias reference.
557///
558/// The accounting model mirrors `YamlLoader::on_event_impl` exactly, in
559/// bytes rather than node count: a `Scalar` charges
560/// `YAML_NODE_OVERHEAD_BYTES` plus its own string content length; a closed
561/// `Sequence`/`Mapping` charges `YAML_NODE_OVERHEAD_BYTES` for itself,
562/// plus the byte weight of its already-charged descendants. An anchored
563/// node (`SequenceStart`/`MappingStart`/`Scalar` anchor id `> 0`) charges
564/// its own subtree's byte weight a second time, mirroring
565/// `insert_new_node`'s `anchor_map.insert` clone; an `Alias` charges the
566/// referenced anchor's recorded byte weight (or
567/// `YAML_NODE_OVERHEAD_BYTES` for an unknown anchor id, matching the
568/// loader's own `Yaml::BadValue` fallback, which owns no heap content),
569/// mirroring the `v.clone()` in the `Event::Alias` arm. All counting uses
570/// `u64` with `saturating_add`, since the counter itself — not just the
571/// input — is the attack surface.
572///
573/// This pre-pass is not itself free relative to `max_bytes`: its own
574/// `anchors: BTreeMap<usize, u64>` grows by one entry per distinct anchor
575/// id seen, so a document built almost entirely of many tiny anchors (e.g.
576/// ~262,000 one-byte-scalar anchors, ~3.6 MB source) can transiently grow
577/// this map to roughly the same order of magnitude as `max_bytes` itself
578/// before the tally crosses it and rejection kicks in. This is bounded and
579/// transient, not unbounded like the vulnerability this guard closes, but
580/// callers should not assume the pre-pass's own peak memory is negligible
581/// next to the budget it enforces.
582///
583/// Any `ScanError` from this pre-pass is ignored: the real
584/// `YamlLoader::load_from_str` call that follows reports the authoritative
585/// syntax error. If the budget was already exceeded before the scan error,
586/// this still returns `Err`.
587///
588/// This pre-pass is, like the real load, driven by `Parser::load`'s mutually
589/// recursive `load_node`/`load_mapping`/`load_sequence` — callers must run
590/// [`check_yaml_nesting_depth`] first so this never recurses on input deep
591/// enough to overflow the stack itself.
592///
593/// # Errors
594///
595/// Returns `Err(bytes)` with the byte tally reached the instant it exceeds
596/// `max_bytes`.
597///
598/// # Examples
599///
600/// ```
601/// use deps_core::parser::check_yaml_expansion;
602///
603/// assert!(check_yaml_expansion("a: 1\nb: [2, 3]\n", 1000).is_ok());
604///
605/// // A widget *multiplier* helper is a plain scalar, not an alias.
606/// assert!(check_yaml_expansion("description: A widget *multiplier* helper", 1000).is_ok());
607///
608/// // Each anchor doubles the next one's alias count, so N levels expand to
609/// // roughly 2^N nodes from a source only ~2N bytes long.
610/// let mut doubling_chain = String::from("a0: &a0 [x, x]\n");
611/// for i in 1..20 {
612/// doubling_chain.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
613/// }
614/// assert!(check_yaml_expansion(&doubling_chain, 1000).is_err());
615/// ```
616pub fn check_yaml_expansion(content: &str, max_bytes: usize) -> std::result::Result<(), usize> {
617 struct Receiver {
618 max: u64,
619 consumed: u64,
620 exceeded: bool,
621 stack: Vec<(usize, u64)>,
622 anchors: BTreeMap<usize, u64>,
623 }
624
625 impl Receiver {
626 fn charge(&mut self, n: u64) {
627 if self.exceeded {
628 return;
629 }
630 self.consumed = self.consumed.saturating_add(n);
631 if self.consumed > self.max {
632 self.exceeded = true;
633 }
634 }
635
636 /// Records a just-finished node's total subtree byte weight (`size`,
637 /// including itself): charges the anchor-clone cost and remembers
638 /// it for future aliases when `aid > 0`, then adds it to the
639 /// enclosing container's running subtree weight, if any.
640 fn finish(&mut self, size: u64, aid: usize) {
641 if self.exceeded {
642 return;
643 }
644 if aid > 0 {
645 self.charge(size);
646 self.anchors.insert(aid, size);
647 }
648 if let Some((_, parent_size)) = self.stack.last_mut() {
649 *parent_size = parent_size.saturating_add(size);
650 }
651 }
652 }
653
654 impl MarkedEventReceiver for Receiver {
655 fn on_event(&mut self, ev: Event, _mark: Marker) {
656 if self.exceeded {
657 return;
658 }
659 match ev {
660 Event::SequenceStart(aid, _) | Event::MappingStart(aid, _) => {
661 self.stack.push((aid, 0));
662 }
663 Event::SequenceEnd | Event::MappingEnd => {
664 if let Some((aid, children_size)) = self.stack.pop() {
665 self.charge(YAML_NODE_OVERHEAD_BYTES);
666 self.finish(children_size.saturating_add(YAML_NODE_OVERHEAD_BYTES), aid);
667 }
668 }
669 Event::Scalar(ref v, _, aid, _) => {
670 let size = YAML_NODE_OVERHEAD_BYTES.saturating_add(v.len() as u64);
671 self.charge(size);
672 self.finish(size, aid);
673 }
674 Event::Alias(id) => {
675 let size = self
676 .anchors
677 .get(&id)
678 .copied()
679 .unwrap_or(YAML_NODE_OVERHEAD_BYTES);
680 self.charge(size);
681 self.finish(size, 0);
682 }
683 _ => {}
684 }
685 }
686 }
687
688 let mut recv = Receiver {
689 max: max_bytes as u64,
690 consumed: 0,
691 exceeded: false,
692 stack: Vec::new(),
693 anchors: BTreeMap::new(),
694 };
695
696 let _ = Parser::new(content.chars()).load(&mut recv, true);
697
698 if recv.exceeded {
699 Err(usize::try_from(recv.consumed).unwrap_or(usize::MAX))
700 } else {
701 Ok(())
702 }
703}
704
705/// Maximum allowed nesting depth for JSON array/object recursion before
706/// [`check_json_nesting_depth`] rejects the input.
707///
708/// `serde_json` itself already caps recursion at a default depth of 128 for
709/// every container it enters — `deserialize_any`/`Value`, but equally
710/// `deserialize_seq`/`deserialize_map` (`check_recursion!` in its `de.rs`,
711/// guarding all three), so ordinary typed struct/`Vec` deserialization is
712/// covered exactly the same as parsing into a bare `Value`. This workspace
713/// never enables the `unbounded_depth` feature or calls
714/// `disable_recursion_limit`, so pathologically nested JSON cannot crash
715/// this workspace via stack overflow regardless of which of these paths a
716/// given call site uses. This guard is defense-in-depth, not a
717/// vulnerability fix: an early, cheap, byte-level rejection that fails
718/// faster and with a repo-specific error type than waiting for
719/// `serde_json`'s own limit, and keeps every untrusted-JSON parse site
720/// consistent with the [`MAX_TOML_NESTING_DEPTH`]/[`MAX_YAML_NESTING_DEPTH`]
721/// guards already applied to manifests of those formats. The depth is
722/// intentionally set narrower than `serde_json`'s built-in 128 — real
723/// payloads (OSV `database_specific`/`ecosystem_specific`, npm's `time` map,
724/// Packagist's `abandoned` field, ordinary `package.json`/`composer.json`
725/// manifests and lockfiles) never approach double digits of nesting, so 64
726/// is an arbitrary but generous ceiling chosen to match the existing
727/// TOML/YAML constants' value, not a stack-size bisection.
728pub const MAX_JSON_NESTING_DEPTH: usize = 64;
729
730/// Scans raw JSON bytes for `[`/`{` nesting deeper than `max_depth`, before
731/// handing the bytes to `serde_json::from_slice`/`from_str`.
732///
733/// A single-pass structural scan — no actual parsing, so it cannot itself
734/// recurse or overflow. String contents (JSON's only escaping construct) are
735/// tracked so bracket characters inside string literals are never
736/// miscounted as structural nesting. Multi-byte UTF-8 sequences are safe to
737/// scan byte-by-byte here: none of their continuation bytes collide with the
738/// ASCII structural characters this function looks for.
739///
740/// An unterminated (or truncated) string literal makes the scanner treat the
741/// rest of the buffer as string content and return `Ok`, undercounting any
742/// nesting that follows. This is safe: `serde_json` tokenizes the same bytes
743/// and will independently reject the identical malformed/truncated string
744/// (an EOF-while-parsing-string or similar syntax error) before its own
745/// recursive descent could ever reach nesting beyond what this scanner
746/// already counted up to the unterminated quote.
747///
748/// # Errors
749///
750/// Returns `Err(depth)` with the depth reached the instant nesting exceeds
751/// `max_depth`.
752///
753/// # Examples
754///
755/// ```
756/// use deps_core::parser::check_json_nesting_depth;
757///
758/// assert!(check_json_nesting_depth(br#"{"a":[1,2,{"b":3}]}"#, 4).is_ok());
759///
760/// let deeply_nested = format!("{}1{}", "[".repeat(10), "]".repeat(10));
761/// assert_eq!(check_json_nesting_depth(deeply_nested.as_bytes(), 4), Err(5));
762/// ```
763pub fn check_json_nesting_depth(
764 content: &[u8],
765 max_depth: usize,
766) -> std::result::Result<(), usize> {
767 let mut depth: usize = 0;
768 let mut in_string = false;
769 let mut escaped = false;
770
771 for &b in content {
772 if in_string {
773 if escaped {
774 escaped = false;
775 } else if b == b'\\' {
776 escaped = true;
777 } else if b == b'"' {
778 in_string = false;
779 }
780 continue;
781 }
782 match b {
783 b'"' => in_string = true,
784 b'[' | b'{' => {
785 depth += 1;
786 if depth > max_depth {
787 return Err(depth);
788 }
789 }
790 b']' | b'}' => depth = depth.saturating_sub(1),
791 _ => {}
792 }
793 }
794
795 Ok(())
796}
797
798/// Builds the `serde_json::Error` reporting a too-deep payload.
799///
800/// Shared internally by [`parse_json_checked`]'s two failure paths (too-deep vs. genuinely
801/// malformed) so both produce the exact same error type. Synthesized via
802/// `serde::de::Error::custom` so it is indistinguishable, to a caller's existing
803/// malformed-JSON handling, from an error `serde_json` itself would have produced.
804#[must_use]
805fn json_depth_error(depth: usize) -> serde_json::Error {
806 serde::de::Error::custom(format!(
807 "JSON nesting depth {depth} exceeds maximum of {MAX_JSON_NESTING_DEPTH}"
808 ))
809}
810
811/// Deserializes `bytes` into `T`, first rejecting payloads whose JSON nesting exceeds
812/// [`MAX_JSON_NESTING_DEPTH`] (see that constant's doc for why).
813///
814/// The single shared entry point for every untrusted-JSON parse site in this workspace —
815/// collapses what would otherwise be a per-crate copy of [`check_json_nesting_depth`] +
816/// [`serde_json::from_slice`] into one call, and returns a `serde_json::Error` so a
817/// too-deep payload converts into a caller's `DepsError` exactly like any other
818/// malformed-JSON failure (via `?`, `.map_err(..)`, or `.ok()`).
819///
820/// # Errors
821///
822/// Returns an error if `bytes` nests deeper than [`MAX_JSON_NESTING_DEPTH`], or
823/// `serde_json`'s own error if `bytes` is not valid JSON matching `T`.
824///
825/// # Examples
826///
827/// ```
828/// use deps_core::parser::parse_json_checked;
829///
830/// let value: serde_json::Value = parse_json_checked(br#"{"a":1}"#).unwrap();
831/// assert_eq!(value["a"], 1);
832///
833/// let deeply_nested = format!("{}1{}", "[".repeat(100), "]".repeat(100));
834/// assert!(parse_json_checked::<serde_json::Value>(deeply_nested.as_bytes()).is_err());
835/// ```
836pub fn parse_json_checked<T: serde::de::DeserializeOwned>(
837 bytes: &[u8],
838) -> std::result::Result<T, serde_json::Error> {
839 if let Err(depth) = check_json_nesting_depth(bytes, MAX_JSON_NESTING_DEPTH) {
840 return Err(json_depth_error(depth));
841 }
842 serde_json::from_slice(bytes)
843}
844
845/// Finds the byte range of a top-level JSON object section's value (e.g. the `{...}`
846/// following `"dependencies":`).
847///
848/// This lets per-entry position search within that section (name, version, or similar) be
849/// scoped to just that range instead of the whole file, which is what lets a key repeated
850/// across sibling sections (e.g. a dependency name present in both `dependencies` and
851/// `devDependencies`, or `require` and `require-dev`) resolve each occurrence to its own
852/// section's position, rather than a shared, monotonically-advancing search cursor skipping
853/// past — or landing on — the wrong occurrence. `serde_json::Map` iteration order
854/// (alphabetical without the `preserve_order` feature, insertion order with it) cannot be
855/// relied on to match source-text order, so this scans the raw text directly instead of
856/// trusting `Map` traversal to visit entries top-to-bottom.
857///
858/// Runs a single forward pass tracking brace depth with the same string/escape-aware state
859/// machine as `find_matching_json_brace_end`, and only accepts a `"<key>":` match at depth 1
860/// (i.e. a direct child of the root object) — a same-named key nested inside another
861/// section's value (e.g. npm's `packageExtensions`/`overrides`) sits at a deeper depth and is
862/// skipped, so it cannot be mistaken for the real top-level section.
863///
864/// Returns `None` if `section_key` cannot be located as a top-level JSON object value (e.g.
865/// malformed JSON) — callers should fall back to whole-file position search for that section
866/// rather than dropping its entries entirely.
867///
868/// # Examples
869///
870/// ```
871/// use deps_core::parser::find_json_section_byte_range;
872///
873/// let content = r#"{"dependencies": {"a": "1.0"}, "devDependencies": {"b": "2.0"}}"#;
874/// let (start, end) = find_json_section_byte_range(content, "devDependencies").unwrap();
875/// assert_eq!(&content[start..end], r#"{"b": "2.0"}"#);
876/// ```
877pub fn find_json_section_byte_range(content: &str, section_key: &str) -> Option<(usize, usize)> {
878 let key_pattern = format!("\"{section_key}\"");
879 let mut depth = 0u32;
880 let mut in_string = false;
881 let mut escape = false;
882 let mut idx = 0;
883
884 while idx < content.len() {
885 let rest = &content[idx..];
886 let ch = rest.chars().next().unwrap_or_default();
887 let ch_len = ch.len_utf8();
888
889 if in_string {
890 match ch {
891 _ if escape => escape = false,
892 '\\' => escape = true,
893 '"' => in_string = false,
894 _ => {}
895 }
896 idx += ch_len;
897 continue;
898 }
899
900 if ch == '"' {
901 if depth == 1 && rest.starts_with(&key_pattern) {
902 let after_key = &content[idx + key_pattern.len()..];
903 let trimmed = after_key.trim_start();
904 if let Some(after_colon) = trimmed.strip_prefix(':') {
905 let value = after_colon.trim_start();
906 if let Some(object_body) = value.strip_prefix('{') {
907 let open_brace_idx = content.len() - value.len();
908 if let Some(end) = find_matching_json_brace_end(object_body) {
909 return Some((open_brace_idx, open_brace_idx + 1 + end));
910 }
911 // Unbalanced braces — malformed JSON for this candidate; keep
912 // scanning rather than giving up on the whole search.
913 }
914 }
915 }
916 in_string = true;
917 idx += ch_len;
918 continue;
919 }
920
921 match ch {
922 '{' => depth += 1,
923 '}' => depth = depth.saturating_sub(1),
924 _ => {}
925 }
926 idx += ch_len;
927 }
928
929 None
930}
931
932/// Given the text right after an opening `{`, finds the byte offset (relative to that text)
933/// of the matching closing `}`, skipping over brace characters that appear inside string
934/// literals (so a `{`/`}` in a value, e.g. a git URL, is not miscounted). Returns the offset
935/// just past the matching `}`.
936fn find_matching_json_brace_end(object_body: &str) -> Option<usize> {
937 let mut depth = 1u32;
938 let mut in_string = false;
939 let mut escape = false;
940
941 for (offset, ch) in object_body.char_indices() {
942 if in_string {
943 match ch {
944 _ if escape => escape = false,
945 '\\' => escape = true,
946 '"' => in_string = false,
947 _ => {}
948 }
949 continue;
950 }
951
952 match ch {
953 '"' => in_string = true,
954 '{' => depth += 1,
955 '}' => {
956 depth -= 1;
957 if depth == 0 {
958 return Some(offset + 1);
959 }
960 }
961 _ => {}
962 }
963 }
964
965 None
966}
967
968/// Dependency source location (shared across all ecosystems).
969///
970/// Covers the union of all source types across Cargo, npm, PyPI, Go,
971/// Dart, Bundler, Maven, and Gradle ecosystems.
972#[derive(Debug, Clone, PartialEq, Eq)]
973#[non_exhaustive]
974pub enum DependencySource {
975 /// Default package registry (crates.io, npm, PyPI, pub.dev, rubygems.org, Maven Central).
976 Registry,
977
978 /// Git repository dependency.
979 Git {
980 url: String,
981 /// Git ref: commit SHA, tag, or branch name (ecosystem-specific semantics).
982 rev: Option<String>,
983 },
984
985 /// Local filesystem path dependency.
986 Path { path: String },
987
988 /// Direct URL to artifact (PyPI wheels, npm tarballs).
989 Url { url: String },
990
991 /// SDK-provided dependency (Dart: `sdk: flutter`).
992 Sdk { sdk: String },
993
994 /// Workspace-inherited dependency (Cargo: `workspace = true`).
995 Workspace,
996
997 /// Custom/alternative registry, named by an unresolved alias or raw index URL
998 /// (Bundler custom sources, an unresolved Cargo `registry = "my-corp"`).
999 ///
1000 /// This variant's meaning is unchanged by [`AlternateRegistry`](Self::AlternateRegistry)'s
1001 /// addition: it always means "not yet resolved to a concrete index this LSP can query" —
1002 /// `url` may hold a bare alias (`"my-corp"`) or a URL string, but never a value this LSP
1003 /// has validated and can fetch against. See [`AlternateRegistry`](Self::AlternateRegistry)
1004 /// for the resolved counterpart.
1005 ///
1006 /// `url` is never redacted (unlike every `tracing::warn!` naming the same raw value —
1007 /// see `deps_core::net_policy::redact_userinfo`'s doc, #536): a literal `registry-index`
1008 /// carrying `user:pass@` userinfo that fails to resolve lands here verbatim. Currently
1009 /// latent — nothing renders `CustomRegistry::url` in hover/diagnostics text today — but a
1010 /// future caller surfacing it must redact first, matching every logging call site.
1011 CustomRegistry { url: String },
1012
1013 /// A custom/alternative registry resolved to a concrete, fetchable index URL.
1014 ///
1015 /// Distinct from [`CustomRegistry`](Self::CustomRegistry) so "resolved" is a type-level
1016 /// state instead of string-sniffing an unresolved alias vs. a URL. Produced only by a
1017 /// parser that validated `index` against its own registry-configuration source (e.g.
1018 /// `deps-cargo`'s `.cargo/config.toml` resolution) — `deps-core` itself never constructs
1019 /// this variant. `index` is the `sparse+` prefix-stripped, https-only index URL; it
1020 /// carries no credential and is not itself an authorization decision — see the
1021 /// originating crate's config-resolution module for how (and whether) a request against
1022 /// it is authenticated.
1023 AlternateRegistry {
1024 /// The resolved index URL, validated and normalized by the originating parser.
1025 index: String,
1026 /// `true` exactly when this source was reached via a `[source.crates-io]
1027 /// replace-with` chain (Cargo `[source]` mirroring, spec
1028 /// `.local/specs/023-cargo-custom-registries/plan-1b.md` §1.3) — as opposed to an
1029 /// explicit `registry`/`registry-index` naming a genuinely different, private
1030 /// registry.
1031 ///
1032 /// Affects **presentation and advisory gating only, never routing**: Cargo verifies
1033 /// per-version checksum equality against crates.io for a mirror, so its content is
1034 /// exactly as trustworthy as crates.io's own for vulnerability-scanning and hover-link
1035 /// purposes, even though the fetch itself still goes to `index`, not to crates.io.
1036 /// See [`crate::lsp_helpers::SourcePolicy::source_is_public_registry_content`].
1037 mirrors_crates_io: bool,
1038 },
1039}
1040
1041impl DependencySource {
1042 /// Returns true if this dependency comes from any registry (default or custom).
1043 ///
1044 /// Registry dependencies support version fetching and update checks.
1045 /// Git, Path, Url, Sdk, and Workspace dependencies do not.
1046 pub fn is_registry(&self) -> bool {
1047 matches!(
1048 self,
1049 Self::Registry | Self::CustomRegistry { .. } | Self::AlternateRegistry { .. }
1050 )
1051 }
1052
1053 /// Returns true if this LSP can resolve version data for this source
1054 /// against the registry client it actually queries.
1055 ///
1056 /// `Registry` resolves to the ecosystem's default public registry
1057 /// (crates.io, npm, PyPI, ...), which every `deps-*` crate implements a
1058 /// client for. `CustomRegistry` names a private/alternative registry
1059 /// (e.g. Bundler `source "https://gems.mycorp.com"`, Cargo
1060 /// `registry = "my-corp"`) that this LSP has no client for — known
1061 /// limitation, tracked until private-registry client support exists.
1062 /// Diagnostics and hover must not silently fall back to checking a
1063 /// `CustomRegistry` dependency's name against the *public* registry, so
1064 /// this deliberately diverges from `is_registry()` and returns `false`
1065 /// for it, alongside Git/Path/Url/Sdk/Workspace sources.
1066 ///
1067 /// Also `false` for `AlternateRegistry`, even though it is resolved: this method answers
1068 /// "does the generic `Registry` trait (crates.io-shaped, one client per ecosystem)
1069 /// resolve this", not "is version data reachable at all". An ecosystem whose registry
1070 /// implements per-source routing (`deps-cargo`'s `CargoRegistry`) must use
1071 /// [`crate::lsp_helpers::SourcePolicy::can_resolve_source`] instead, which defaults
1072 /// to this method and is the only override point — see that method's docs.
1073 pub fn is_version_resolvable(&self) -> bool {
1074 matches!(self, Self::Registry)
1075 }
1076}
1077
1078/// Loading state for registry data fetching.
1079///
1080/// Tracks the current state of background registry operations to provide
1081/// user feedback about data availability.
1082///
1083/// # State Transitions
1084///
1085/// Complete state machine diagram showing all valid transitions:
1086///
1087/// ```text
1088/// ┌─────┐
1089/// │Idle │ (Initial state: no data loaded, not loading)
1090/// └──┬──┘
1091/// │
1092/// │ didOpen/didChange
1093/// │ (start fetching)
1094/// ▼
1095/// ┌────────┐
1096/// │Loading │ (Fetching registry data)
1097/// └───┬────┘
1098/// │
1099/// ├─────── Success ──────┐
1100/// │ ▼
1101/// │ ┌────────┐
1102/// │ │Loaded │ (Data cached and ready)
1103/// │ └───┬────┘
1104/// │ │
1105/// │ │ didChange/refresh
1106/// │ │ (re-fetch)
1107/// │ │
1108/// │ ▼
1109/// │ ┌────────┐
1110/// │ │Loading │
1111/// │ └────────┘
1112/// │
1113/// └─────── Error ─────────┐
1114/// ▼
1115/// ┌────────┐
1116/// │Failed │ (Fetch failed, old cache may exist)
1117/// └───┬────┘
1118/// │
1119/// │ didChange/retry
1120/// │ (try again)
1121/// │
1122/// ▼
1123/// ┌────────┐
1124/// │Loading │
1125/// └────────┘
1126/// ```
1127///
1128/// # Key Behaviors
1129///
1130/// - **Idle**: Initial state when no data has been fetched yet
1131/// - **Loading**: Actively fetching from registry (may show loading indicator)
1132/// - **Loaded**: Successfully fetched and cached data
1133/// - **Failed**: Network/registry error occurred (falls back to old cache if available)
1134///
1135/// # Thread Safety
1136///
1137/// This enum is `Copy` for efficient passing across thread boundaries in async contexts.
1138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1139pub enum LoadingState {
1140 /// No data loaded, not currently loading
1141 #[default]
1142 Idle,
1143 /// Currently fetching registry data
1144 Loading,
1145 /// Data fetched and cached
1146 Loaded,
1147 /// Fetch failed (old cached data may still be available)
1148 Failed,
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154
1155 #[test]
1156 fn test_check_toml_nesting_depth_empty_content() {
1157 assert_eq!(check_toml_nesting_depth("", 4), Ok(()));
1158 }
1159
1160 #[test]
1161 fn test_check_toml_nesting_depth_no_brackets() {
1162 assert_eq!(check_toml_nesting_depth("a = 1\nb = \"text\"\n", 4), Ok(()));
1163 }
1164
1165 #[test]
1166 fn test_check_toml_nesting_depth_exactly_at_max() {
1167 let content = format!("a = {}1{}", "[".repeat(4), "]".repeat(4));
1168 assert_eq!(check_toml_nesting_depth(&content, 4), Ok(()));
1169 }
1170
1171 #[test]
1172 fn test_check_toml_nesting_depth_one_over_max() {
1173 let content = format!("a = {}1{}", "[".repeat(5), "]".repeat(5));
1174 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1175 }
1176
1177 #[test]
1178 fn test_check_toml_nesting_depth_double_quoted_string_ignored() {
1179 let content = r#"a = "[[[[[unbalanced brackets]]]]]""#;
1180 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1181 }
1182
1183 #[test]
1184 fn test_check_toml_nesting_depth_single_quoted_string_ignored() {
1185 let content = "a = '[[[[[unbalanced brackets]]]]]'";
1186 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1187 }
1188
1189 #[test]
1190 fn test_check_toml_nesting_depth_escaped_quote_in_string() {
1191 // The escaped quote must not terminate the string early, so the
1192 // brackets that follow stay inside the string and are ignored.
1193 let content = r#"a = "embedded \" quote [[[[[""#;
1194 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1195 }
1196
1197 #[test]
1198 fn test_check_toml_nesting_depth_comment_ignored() {
1199 let content = "# [[[[[unbalanced comment brackets]]]]]\na = 1\n";
1200 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1201 }
1202
1203 #[test]
1204 fn test_check_toml_nesting_depth_mixed_array_and_table_nesting() {
1205 // [ { [ { -> depth 4 at the innermost brace.
1206 let content = "a = [{ b = [{ c = 1 }] }]";
1207 assert_eq!(check_toml_nesting_depth(content, 4), Ok(()));
1208 assert_eq!(check_toml_nesting_depth(content, 3), Err(4));
1209 }
1210
1211 #[test]
1212 fn test_check_toml_nesting_depth_inline_table_at_production_boundary() {
1213 // Nested inline tables (`{a={a=...}}`) are the shape that actually
1214 // exhausts a 2 MiB tokio worker stack before nested arrays do
1215 // (impl-critic C3) — exercise it against the real, shipped
1216 // `MAX_TOML_NESTING_DEPTH`, not just an arbitrary small max_depth.
1217 let depth = MAX_TOML_NESTING_DEPTH;
1218 let at_max = format!("a = {}1{}", "{a=".repeat(depth), "}".repeat(depth));
1219 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1220
1221 let over_max = format!("a = {}1{}", "{a=".repeat(depth + 1), "}".repeat(depth + 1));
1222 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1223 }
1224
1225 #[test]
1226 fn test_check_toml_nesting_depth_dotted_header_at_production_boundary() {
1227 // Regression test for impl-critic C4: `[a.a.a...]` nests one table
1228 // level per `.` segment with zero bracket characters, so a
1229 // bracket-only scanner scores this depth 0 and lets it straight
1230 // through to `toml_span::parse`, which still stack-overflows.
1231 let depth = MAX_TOML_NESTING_DEPTH;
1232 let at_max = format!("[{}a]\ny = 1\n", "a.".repeat(depth - 1));
1233 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1234
1235 let over_max = format!("[{}a]\ny = 1\n", "a.".repeat(depth));
1236 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1237 }
1238
1239 #[test]
1240 fn test_check_toml_nesting_depth_dotted_key_at_production_boundary() {
1241 // Same C4 bypass, via a dotted key (`a.a.a...= 1`) instead of a
1242 // dotted table header.
1243 let depth = MAX_TOML_NESTING_DEPTH;
1244 let at_max = format!("a{} = 1\n", ".a".repeat(depth));
1245 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1246
1247 let over_max = format!("a{} = 1\n", ".a".repeat(depth + 1));
1248 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1249 }
1250
1251 #[test]
1252 fn test_check_toml_nesting_depth_legitimate_dotted_keys_accepted() {
1253 // Positive test: common, shallow legitimate dotted-key patterns must
1254 // never be rejected, and a float/version-like dot in value position
1255 // must never be miscounted as a key segment.
1256 let content = r#"
1257[tool.poetry.dependencies]
1258requests = { version = "^2.28", extras = ["socks"] }
1259
1260[libraries]
1261spring-boot = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring" }
1262
1263[metrics]
1264cpu_load = 3.14
1265"#;
1266 assert_eq!(
1267 check_toml_nesting_depth(content, MAX_TOML_NESTING_DEPTH),
1268 Ok(())
1269 );
1270 }
1271
1272 #[test]
1273 fn test_check_toml_nesting_depth_dotted_array_of_tables_header_at_boundary() {
1274 // `[[a.a...]]` double-bracket header: 2 bracket levels + N dot
1275 // segments must compose into one shared budget, not be tracked
1276 // independently (which would let brackets and dots each stay under
1277 // the cap while their sum exceeds it).
1278 let depth = MAX_TOML_NESTING_DEPTH;
1279 let at_max = format!("[[{}a]]\ny = 1\n", "a.".repeat(depth - 2));
1280 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1281
1282 let over_max = format!("[[{}a]]\ny = 1\n", "a.".repeat(depth - 1));
1283 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1284 }
1285
1286 #[test]
1287 fn test_check_toml_nesting_depth_dotted_key_inside_bracket_header_composes() {
1288 // A `[a.b]` header (dots released at bracket depth 0 on the header's
1289 // own newline) followed by a dotted key whose value is an inline
1290 // table with its own dotted key (`c.d.e = {f.g = 1}`) exercises
1291 // bracket-depth and dot-segment accounting interleaving on adjacent
1292 // statements — the header's dots must not leak into the next
1293 // statement's budget, and the inline table's dots must still stack
1294 // on top of its own bracket depth correctly.
1295 let content = "[a.b]\nc.d.e = {f.g = 1}\n";
1296 // Real accounting: header contributes max transient depth 2 (1
1297 // bracket + 1 dot), fully released at its newline; the second line
1298 // peaks at depth 4 (2 dots for c.d.e's key, +1 for the `{`, +1 for
1299 // f.g's dot) — never higher, and never carrying over the header's
1300 // released dots.
1301 assert_eq!(check_toml_nesting_depth(content, 4), Ok(()));
1302 assert_eq!(check_toml_nesting_depth(content, 3), Err(4));
1303 }
1304
1305 #[test]
1306 fn test_check_toml_nesting_depth_many_dotted_key_statements_do_not_accumulate() {
1307 // Hundreds of top-level dotted-key statements, each individually
1308 // well under the cap, must never accumulate across statements — a
1309 // per-key release that fired late (or not at all) would eventually
1310 // push a long file over the cap even though no single statement
1311 // does.
1312 let mut content = String::new();
1313 for i in 0..500 {
1314 content.push_str(&format!("k{i}.a.b.c = {i}\n"));
1315 }
1316 assert_eq!(check_toml_nesting_depth(&content, 4), Ok(()));
1317 }
1318
1319 #[test]
1320 fn test_check_toml_nesting_depth_sibling_inline_tables_in_array_do_not_accumulate() {
1321 // Many sibling `{ ... }` entries in one array, each with a short
1322 // dotted key, must not falsely accumulate depth across entries —
1323 // only one entry's dots are ever held at a time, matching how
1324 // `toml_span`'s recursion actually unwinds between array elements.
1325 let mut content = String::from("deps = [\n");
1326 for i in 0..200 {
1327 content.push_str(&format!(
1328 " {{ name = \"pkg{i}\", version.ref = \"v\" }},\n"
1329 ));
1330 }
1331 content.push_str("]\n");
1332 assert_eq!(
1333 check_toml_nesting_depth(&content, MAX_TOML_NESTING_DEPTH),
1334 Ok(())
1335 );
1336 }
1337
1338 #[test]
1339 fn test_check_toml_nesting_depth_multiline_literal_odd_quote_count() {
1340 // A multi-line literal string containing an apostrophe (odd count of
1341 // its own quote char) must not desync the scanner into thinking the
1342 // string never closes.
1343 let content = format!("a = '''don't'''\nb = {}1{}", "[".repeat(5), "]".repeat(5));
1344 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1345 }
1346
1347 #[test]
1348 fn test_check_toml_nesting_depth_rejects_original_sigabrt_payloads() {
1349 // The exact payload shapes that reproduced the #150 SIGABRT even
1350 // after the first version of this guard shipped (impl-critic C1):
1351 // a multi-line string with an odd count of its own quote char,
1352 // followed by 20000-deep nesting.
1353 let n = 20_000;
1354 let payload1 = format!("a = '''don't'''\nb = {}1{}", "[".repeat(n), "]".repeat(n));
1355 let payload2 = format!(
1356 "a = \"\"\"x\"y\"\"\"\nb = {}1{}",
1357 "[".repeat(n),
1358 "]".repeat(n)
1359 );
1360
1361 assert!(check_toml_nesting_depth(&payload1, MAX_TOML_NESTING_DEPTH).is_err());
1362 assert!(check_toml_nesting_depth(&payload2, MAX_TOML_NESTING_DEPTH).is_err());
1363 }
1364
1365 #[test]
1366 fn test_check_toml_nesting_depth_multiline_basic_odd_quote_count() {
1367 let content = format!(
1368 "a = \"\"\"x\"y\"\"\"\nb = {}1{}",
1369 "[".repeat(5),
1370 "]".repeat(5)
1371 );
1372 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1373 }
1374
1375 #[test]
1376 fn test_check_toml_nesting_depth_multiline_basic_trailing_extra_quotes() {
1377 // TOML allows 1-2 literal quotes right before the closing triple
1378 // (here: 2 extra content quotes then the 3-quote closer, 5 in a
1379 // row). The scanner must still be back in normal mode right after,
1380 // so the nested array on the next line is counted correctly.
1381 let content = format!(
1382 "a = \"\"\"ends with two quotes: \"\"\"\"\"\nb = {}1{}",
1383 "[".repeat(5),
1384 "]".repeat(5)
1385 );
1386 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1387 }
1388
1389 #[test]
1390 fn test_check_toml_nesting_depth_brackets_inside_multiline_string_ignored() {
1391 let content = "d = \"\"\"\nsize is 5\" wide [[[unbalanced]]]\n\"\"\"\ne = 1\n";
1392 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1393 }
1394
1395 #[test]
1396 fn test_check_toml_nesting_depth_multiline_literal_brackets_ignored() {
1397 let content = "d = '''\n[[[unbalanced brackets]]]\n'''\ne = 1\n";
1398 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1399 }
1400
1401 #[test]
1402 fn test_check_toml_nesting_depth_multiline_basic_one_extra_trailing_quote() {
1403 // Exactly 1 extra content quote before the closing triple (4-quote
1404 // run total), between the 0-extra and 2-extra cases already covered.
1405 let content = format!(
1406 "a = \"\"\"ends with one quote: \"\"\"\"\nb = {}1{}",
1407 "[".repeat(5),
1408 "]".repeat(5)
1409 );
1410 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1411 }
1412
1413 #[test]
1414 fn test_check_toml_nesting_depth_adjacent_multiline_strings_then_nesting() {
1415 // Two separate multi-line strings (one basic, one literal) back to
1416 // back, each closing normally, must not leave the scanner desynced
1417 // for the real nesting that follows.
1418 let content = format!(
1419 "a = \"\"\"first\"\"\"\nb = '''second'''\nc = {}1{}",
1420 "[".repeat(5),
1421 "]".repeat(5)
1422 );
1423 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1424 }
1425
1426 /// Builds `n` lines of block mapping, each one column deeper than the
1427 /// last (`k:\n k:\n k:\n...`) — the tightest real `yaml-rust2` crash
1428 /// shape (impl-critic/tester finding), and exactly `n` scanner pushes.
1429 fn nested_mapping(n: usize) -> String {
1430 let mut s = String::new();
1431 for i in 0..n {
1432 s.push_str(&" ".repeat(i));
1433 s.push_str("k:\n");
1434 }
1435 s
1436 }
1437
1438 #[test]
1439 fn test_check_yaml_nesting_depth_empty_content() {
1440 assert_eq!(check_yaml_nesting_depth("", 4), Ok(()));
1441 }
1442
1443 #[test]
1444 fn test_check_yaml_nesting_depth_no_nesting() {
1445 assert_eq!(
1446 check_yaml_nesting_depth("name: foo\nversion: 1.0.0\n", 1),
1447 Ok(())
1448 );
1449 }
1450
1451 #[test]
1452 fn test_check_yaml_nesting_depth_dash_chain_exactly_at_max() {
1453 // N dashes push N levels, plus one more for the trailing scalar's
1454 // own column (deeper than the last dash), so N=3 peaks at depth 4.
1455 let content = format!("{}1", "- ".repeat(3));
1456 assert_eq!(check_yaml_nesting_depth(&content, 4), Ok(()));
1457 }
1458
1459 #[test]
1460 fn test_check_yaml_nesting_depth_dash_chain_one_over_max() {
1461 let content = format!("{}1", "- ".repeat(4));
1462 assert_eq!(check_yaml_nesting_depth(&content, 4), Err(5));
1463 }
1464
1465 #[test]
1466 fn test_check_yaml_nesting_depth_block_mapping_exactly_at_max() {
1467 assert_eq!(check_yaml_nesting_depth(&nested_mapping(4), 4), Ok(()));
1468 }
1469
1470 #[test]
1471 fn test_check_yaml_nesting_depth_block_mapping_one_over_max() {
1472 assert_eq!(check_yaml_nesting_depth(&nested_mapping(5), 4), Err(5));
1473 }
1474
1475 #[test]
1476 fn test_check_yaml_nesting_depth_double_quoted_string_ignored() {
1477 let content = r#"a: "[[[[[unbalanced brackets]]]]]""#;
1478 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1479 }
1480
1481 #[test]
1482 fn test_check_yaml_nesting_depth_single_quoted_string_ignored() {
1483 let content = "a: '[[[[[unbalanced brackets]]]]]'";
1484 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1485 }
1486
1487 #[test]
1488 fn test_check_yaml_nesting_depth_escaped_quote_in_string() {
1489 // The escaped quote must not terminate the string early, so the
1490 // brackets that follow stay inside the string and are ignored.
1491 let content = r#"a: "embedded \" quote [[[[[""#;
1492 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1493 }
1494
1495 #[test]
1496 fn test_check_yaml_nesting_depth_comment_ignored() {
1497 let content = "# [[[[[unbalanced comment brackets]]]]]\na: 1\n";
1498 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1499 }
1500
1501 #[test]
1502 fn test_check_yaml_nesting_depth_mixed_flow_and_block_nesting() {
1503 // a: -> depth 1, " b:" -> depth 2, then [ [ [ inside the value ->
1504 // peaks at depth 5 — flow and block share one budget.
1505 let content = "a:\n b: [c, [d, [e]]]\n";
1506 assert_eq!(check_yaml_nesting_depth(content, 5), Ok(()));
1507 assert_eq!(check_yaml_nesting_depth(content, 4), Err(5));
1508 }
1509
1510 #[test]
1511 fn test_check_yaml_nesting_depth_dash_chain_at_production_boundary() {
1512 // N dashes plus the trailing scalar's own column peak at depth
1513 // N + 1, so N = depth - 1 is the boundary.
1514 let depth = MAX_YAML_NESTING_DEPTH;
1515 let at_max = format!("{}1", "- ".repeat(depth - 1));
1516 assert_eq!(check_yaml_nesting_depth(&at_max, depth), Ok(()));
1517
1518 let over_max = format!("{}1", "- ".repeat(depth));
1519 assert_eq!(check_yaml_nesting_depth(&over_max, depth), Err(depth + 1));
1520 }
1521
1522 #[test]
1523 fn test_check_yaml_nesting_depth_block_mapping_at_production_boundary() {
1524 let depth = MAX_YAML_NESTING_DEPTH;
1525 assert_eq!(
1526 check_yaml_nesting_depth(&nested_mapping(depth), depth),
1527 Ok(())
1528 );
1529 assert_eq!(
1530 check_yaml_nesting_depth(&nested_mapping(depth + 1), depth),
1531 Err(depth + 1)
1532 );
1533 }
1534
1535 #[test]
1536 fn test_check_yaml_nesting_depth_rejects_original_sigabrt_payloads() {
1537 // Depths comfortably past the empirically bisected real `yaml-rust2`
1538 // 0.12 crash thresholds on a 2 MiB debug stack (compact dash chain
1539 // aborts at 4536, growing-indent block mapping aborts at 1994) —
1540 // stays a real regression test even if `MAX_YAML_NESTING_DEPTH`
1541 // changes later, mirroring the TOML sibling test's margin.
1542 let dash_chain = format!("{}1", "- ".repeat(6000));
1543 assert!(check_yaml_nesting_depth(&dash_chain, MAX_YAML_NESTING_DEPTH).is_err());
1544
1545 let block_mapping = nested_mapping(2500);
1546 assert!(check_yaml_nesting_depth(&block_mapping, MAX_YAML_NESTING_DEPTH).is_err());
1547 }
1548
1549 #[test]
1550 fn test_check_yaml_nesting_depth_apostrophe_does_not_blind_scanner() {
1551 // impl-critic C1: an apostrophe mid-plain-scalar (e.g. `doesn't`)
1552 // must not be mistaken for opening a quoted scalar and swallow the
1553 // rest of the file, hiding the real nesting that follows.
1554 let payload = format!(
1555 "name: my_app\ndescription: A package that doesn't panic\n{}1",
1556 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1557 );
1558 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1559 }
1560
1561 #[test]
1562 fn test_check_yaml_nesting_depth_stray_double_quote_does_not_blind_scanner() {
1563 // Same root cause as above, with a stray `"` (e.g. a dimension
1564 // string like `6" long`) instead of an apostrophe.
1565 let payload = format!(
1566 "size: 6\" long\n{}1",
1567 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1568 );
1569 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1570 }
1571
1572 #[test]
1573 fn test_check_yaml_nesting_depth_unterminated_quote_only_blinds_one_line() {
1574 // A quote that genuinely never closes must resynchronize at the
1575 // next newline rather than scanning to EOF looking for a match.
1576 let payload = format!(
1577 "a: \"unterminated\n{}1",
1578 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1579 );
1580 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1581 }
1582
1583 #[test]
1584 fn test_check_yaml_nesting_depth_backslash_before_newline_does_not_extend_string() {
1585 // A `\` placed right before the line break must not "escape" the
1586 // newline and let an opened double-quoted scalar swallow further
1587 // lines (impl-critic C1 follow-up).
1588 let payload = format!(
1589 "a: \"unterminated\\\n{}1",
1590 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1591 );
1592 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1593 }
1594
1595 #[test]
1596 fn test_check_yaml_nesting_depth_unclosed_bracket_does_not_blind_scanner() {
1597 // impl-critic C2: an unclosed `[`/`{` must not permanently suppress
1598 // block-indentation scanning for the remainder of the file.
1599 let payload = format!("a: [\n{}1", "- ".repeat(MAX_YAML_NESTING_DEPTH + 1));
1600 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1601 }
1602
1603 #[test]
1604 fn test_check_yaml_nesting_depth_many_sibling_keys_do_not_accumulate() {
1605 let mut content = String::from("dependencies:\n");
1606 for i in 0..2000 {
1607 content.push_str(&format!(" pkg{i}: ^1.0.0\n"));
1608 }
1609 assert_eq!(check_yaml_nesting_depth(&content, 2), Ok(()));
1610 }
1611
1612 #[test]
1613 fn test_check_yaml_nesting_depth_multiline_flow_list_siblings_do_not_accumulate() {
1614 let mut content = String::from("dependencies: [\n");
1615 for _ in 0..2000 {
1616 content.push_str(" a,\n");
1617 }
1618 content.push_str("]\n");
1619 assert_eq!(check_yaml_nesting_depth(&content, 3), Ok(()));
1620 }
1621
1622 /// Billion-laughs-style doubling chain: depth stays 2, but expanded
1623 /// `Yaml` node count is roughly `2^n` from a source only ~20 bytes/level
1624 /// long — the exact attack shape from issue #175.
1625 fn doubling_chain(n: usize) -> String {
1626 let mut s = String::from("name: app\na0: &a0 [x, x]\n");
1627 for i in 1..=n {
1628 s.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
1629 }
1630 s
1631 }
1632
1633 #[test]
1634 fn test_check_yaml_expansion_empty_content() {
1635 assert_eq!(check_yaml_expansion("", MAX_YAML_EXPANDED_BYTES), Ok(()));
1636 }
1637
1638 #[test]
1639 fn test_check_yaml_expansion_rejects_n30_doubling_chain_attack() {
1640 // The exact #175 payload shape: N=30 expands to over 2^30 nodes,
1641 // far past MAX_YAML_EXPANDED_BYTES, and must be rejected instead of
1642 // handed to `YamlLoader::load_from_str` (which OOMs/SIGKILLs).
1643 assert!(check_yaml_expansion(&doubling_chain(30), MAX_YAML_EXPANDED_BYTES).is_err());
1644 }
1645
1646 #[test]
1647 fn test_check_yaml_expansion_realistic_pubspec_yaml_accepted() {
1648 let yaml = r"
1649name: my_app
1650description: A sample app
1651environment:
1652 sdk: '>=3.0.0 <4.0.0'
1653dependencies:
1654 flutter:
1655 sdk: flutter
1656 http: ^1.0.0
1657 provider: ^6.0.0
1658 my_pkg:
1659 git:
1660 url: https://github.com/user/repo.git
1661 ref: main
1662 path: packages/my_pkg
1663dev_dependencies:
1664 build_runner: ^2.4.0
1665";
1666 assert_eq!(check_yaml_expansion(yaml, MAX_YAML_EXPANDED_BYTES), Ok(()));
1667 }
1668
1669 #[test]
1670 fn test_check_yaml_expansion_few_hundred_package_lockfile_accepted() {
1671 let mut lock = String::from("packages:\n");
1672 for i in 0..300 {
1673 lock.push_str(&format!(
1674 " pkg_{i}:\n dependency: \"direct main\"\n description:\n name: pkg_{i}\n url: \"https://pub.dev\"\n source: hosted\n version: \"1.{i}.0\"\n"
1675 ));
1676 }
1677 assert_eq!(check_yaml_expansion(&lock, MAX_YAML_EXPANDED_BYTES), Ok(()));
1678 }
1679
1680 #[test]
1681 fn test_check_yaml_expansion_asterisk_in_plain_scalar_not_misread_as_alias() {
1682 // The raw-text pre-scan approach this algorithm replaced
1683 // false-positived on ordinary prose like this — a real `Event::Alias`
1684 // is never produced for a `*`/`&` inside a plain scalar value.
1685 let cases = [
1686 "description: A widget *multiplier* helper\n",
1687 "description: see *.dart files\n",
1688 "e: text &y more\n",
1689 ];
1690 for content in cases {
1691 assert_eq!(
1692 check_yaml_expansion(content, MAX_YAML_EXPANDED_BYTES),
1693 Ok(()),
1694 "false positive on: {content:?}"
1695 );
1696 }
1697 }
1698
1699 #[test]
1700 fn test_check_yaml_expansion_alias_to_undefined_anchor_accepted() {
1701 // `YamlLoader` itself falls back to `Yaml::BadValue` for an alias id
1702 // it has no anchor recorded for; this guard mirrors that fallback
1703 // (`unwrap_or(YAML_NODE_OVERHEAD_BYTES)`) rather than treating it as
1704 // unbounded.
1705 assert_eq!(
1706 check_yaml_expansion("a: *undefined\n", MAX_YAML_EXPANDED_BYTES),
1707 Ok(())
1708 );
1709 }
1710
1711 #[test]
1712 fn test_check_yaml_expansion_self_referential_alias_accepted() {
1713 // A sequence aliasing its own not-yet-closed anchor: the anchor
1714 // isn't registered yet when the alias event fires, so this hits the
1715 // same `unwrap_or(YAML_NODE_OVERHEAD_BYTES)` fallback as an
1716 // undefined anchor (verified against real `yaml-rust2` 0.12
1717 // behavior, not assumed) rather than recursing.
1718 assert_eq!(
1719 check_yaml_expansion("a: &x [1, *x]\n", MAX_YAML_EXPANDED_BYTES),
1720 Ok(())
1721 );
1722 }
1723
1724 #[test]
1725 fn test_check_yaml_expansion_at_production_boundary() {
1726 // N=14 (16,907,046 bytes charged) stays under the byte budget,
1727 // N=15 (33,815,273 bytes) crosses it — empirically verified against
1728 // the real `MAX_YAML_EXPANDED_BYTES`, not assumed.
1729 assert_eq!(
1730 check_yaml_expansion(&doubling_chain(14), MAX_YAML_EXPANDED_BYTES),
1731 Ok(())
1732 );
1733 assert!(check_yaml_expansion(&doubling_chain(15), MAX_YAML_EXPANDED_BYTES).is_err());
1734 }
1735
1736 #[test]
1737 fn test_check_yaml_expansion_large_scalar_anchor_aliased_many_times_rejected() {
1738 // Regression test for the critic's CRITICAL 1 finding: a node-count
1739 // budget accepted a large-scalar anchor aliased many times (linear
1740 // node growth, but memory grows with anchor size x alias count).
1741 // A 1 MB anchor aliased 32 times is ~33 MB of real `YamlLoader`
1742 // allocation from a ~1 MB source — must be rejected under the byte
1743 // budget even though it would cost only 34 nodes under a node
1744 // budget.
1745 let anchor_value = "A".repeat(1_000_000);
1746 let mut content = format!("s: &s \"{anchor_value}\"\nl:\n");
1747 for _ in 0..32 {
1748 content.push_str(" - *s\n");
1749 }
1750 assert!(check_yaml_expansion(&content, MAX_YAML_EXPANDED_BYTES).is_err());
1751 }
1752
1753 #[test]
1754 fn test_check_yaml_expansion_exact_max_boundary() {
1755 // `charge` compares with `>`, so a document whose total charge is
1756 // exactly `max_bytes` must be accepted, and one byte more must be
1757 // rejected — pins that this is intentional (an accidental `>=`
1758 // would reject the exact-max case and go uncaught otherwise).
1759 let scalar_len = 1000usize;
1760 let max_bytes = YAML_NODE_OVERHEAD_BYTES as usize + scalar_len;
1761 let content = "a".repeat(scalar_len);
1762
1763 assert_eq!(check_yaml_expansion(&content, max_bytes), Ok(()));
1764 assert!(check_yaml_expansion(&content, max_bytes - 1).is_err());
1765 }
1766
1767 #[test]
1768 fn test_check_yaml_expansion_matches_recursive_byte_weight_oracle() {
1769 // Pins the invariant the whole design rests on: `check_yaml_expansion`'s
1770 // streaming tally equals an independent, recursive byte-weight
1771 // computation over the real parsed `Yaml` tree, for anchor-free
1772 // docs. Catches an accidental algorithm regression, or a
1773 // `yaml-rust2` upgrade that changes what gets allocated, that unit
1774 // tests on fixed payloads alone would not.
1775 fn recursive_weight(y: &yaml_rust2::Yaml) -> u64 {
1776 match y {
1777 yaml_rust2::Yaml::String(s) => YAML_NODE_OVERHEAD_BYTES + s.len() as u64,
1778 yaml_rust2::Yaml::Array(arr) => {
1779 YAML_NODE_OVERHEAD_BYTES + arr.iter().map(recursive_weight).sum::<u64>()
1780 }
1781 yaml_rust2::Yaml::Hash(h) => {
1782 YAML_NODE_OVERHEAD_BYTES
1783 + h.iter()
1784 .map(|(k, v)| recursive_weight(k) + recursive_weight(v))
1785 .sum::<u64>()
1786 }
1787 _ => YAML_NODE_OVERHEAD_BYTES,
1788 }
1789 }
1790
1791 // Binary search for the smallest `max_bytes` that `check_yaml_expansion`
1792 // still accepts — since `charge` uses `>`, this is exactly the real
1793 // total charged.
1794 fn smallest_accepted(content: &str) -> u64 {
1795 let (mut lo, mut hi) = (0u64, 1_000_000u64);
1796 while lo < hi {
1797 let mid = lo + (hi - lo) / 2;
1798 if check_yaml_expansion(content, usize::try_from(mid).unwrap_or(usize::MAX)).is_ok()
1799 {
1800 hi = mid;
1801 } else {
1802 lo = mid + 1;
1803 }
1804 }
1805 lo
1806 }
1807
1808 // All scalars quoted, so every leaf is `Yaml::String` and its
1809 // parsed length matches its source text exactly (an unquoted
1810 // integer/bool/null scalar's `Yaml` variant does not retain its
1811 // source text, which would make the oracle inexact).
1812 let docs = [
1813 r#"a: "hello""#,
1814 r#"a: ["x", "yy", "zzz"]"#,
1815 "a:\n b: \"value\"\n c:\n - \"one\"\n - \"two\"\n",
1816 ];
1817
1818 for content in docs {
1819 let parsed = yaml_rust2::YamlLoader::load_from_str(content).unwrap();
1820 let expected = recursive_weight(&parsed[0]);
1821 assert_eq!(
1822 smallest_accepted(content),
1823 expected,
1824 "oracle mismatch for {content:?}"
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn test_dependency_source_registry() {
1831 let source = DependencySource::Registry;
1832 assert_eq!(source, DependencySource::Registry);
1833 assert!(source.is_registry());
1834 assert!(source.is_version_resolvable());
1835 }
1836
1837 #[test]
1838 fn test_dependency_source_git() {
1839 let source = DependencySource::Git {
1840 url: "https://github.com/user/repo".into(),
1841 rev: Some("main".into()),
1842 };
1843
1844 assert!(!source.is_registry());
1845 assert!(!source.is_version_resolvable());
1846
1847 match source {
1848 DependencySource::Git { url, rev } => {
1849 assert_eq!(url, "https://github.com/user/repo");
1850 assert_eq!(rev, Some("main".into()));
1851 }
1852 _ => panic!("Expected Git source"),
1853 }
1854 }
1855
1856 #[test]
1857 fn test_dependency_source_git_no_rev() {
1858 let source = DependencySource::Git {
1859 url: "https://github.com/user/repo".into(),
1860 rev: None,
1861 };
1862
1863 match source {
1864 DependencySource::Git { url, rev } => {
1865 assert_eq!(url, "https://github.com/user/repo");
1866 assert!(rev.is_none());
1867 }
1868 _ => panic!("Expected Git source"),
1869 }
1870 }
1871
1872 #[test]
1873 fn test_dependency_source_path() {
1874 let source = DependencySource::Path {
1875 path: "../local-crate".into(),
1876 };
1877
1878 assert!(!source.is_registry());
1879
1880 match source {
1881 DependencySource::Path { path } => {
1882 assert_eq!(path, "../local-crate");
1883 }
1884 _ => panic!("Expected Path source"),
1885 }
1886 }
1887
1888 #[test]
1889 fn test_dependency_source_url() {
1890 let source = DependencySource::Url {
1891 url: "https://example.com/package.whl".into(),
1892 };
1893 assert!(!source.is_registry());
1894 assert!(!source.is_version_resolvable());
1895 }
1896
1897 #[test]
1898 fn test_dependency_source_sdk() {
1899 let source = DependencySource::Sdk {
1900 sdk: "flutter".into(),
1901 };
1902 assert!(!source.is_registry());
1903 }
1904
1905 #[test]
1906 fn test_dependency_source_workspace() {
1907 let source = DependencySource::Workspace;
1908 assert!(!source.is_registry());
1909 assert!(!source.is_version_resolvable());
1910 }
1911
1912 #[test]
1913 fn test_dependency_source_custom_registry() {
1914 let source = DependencySource::CustomRegistry {
1915 url: "https://gems.example.com".into(),
1916 };
1917 // `is_registry()` stays true (it does name a registry), but this LSP
1918 // has no client for a private/custom registry, so it must not be
1919 // treated as version-resolvable against the public registry (#248).
1920 assert!(source.is_registry());
1921 assert!(!source.is_version_resolvable());
1922 }
1923
1924 #[test]
1925 fn test_dependency_source_alternate_registry() {
1926 let source = DependencySource::AlternateRegistry {
1927 index: "https://index.mycorp.dev".into(),
1928 mirrors_crates_io: false,
1929 };
1930 // `is_registry()` is true (it is a registry, just not the default one), but
1931 // the generic `Registry` trait still can't resolve it — only an ecosystem
1932 // whose `EcosystemFormatter::can_resolve_source` override understands this
1933 // variant (e.g. `deps-cargo`'s `CargoFormatter`) can.
1934 assert!(source.is_registry());
1935 assert!(!source.is_version_resolvable());
1936 }
1937
1938 #[test]
1939 fn test_dependency_source_clone() {
1940 let source1 = DependencySource::Git {
1941 url: "https://example.com/repo".into(),
1942 rev: Some("v1.0".into()),
1943 };
1944 let source2 = source1.clone();
1945
1946 assert_eq!(source1, source2);
1947 }
1948
1949 #[test]
1950 fn test_dependency_source_equality() {
1951 let reg1 = DependencySource::Registry;
1952 let reg2 = DependencySource::Registry;
1953 assert_eq!(reg1, reg2);
1954
1955 let git1 = DependencySource::Git {
1956 url: "https://example.com".into(),
1957 rev: None,
1958 };
1959 let git2 = DependencySource::Git {
1960 url: "https://example.com".into(),
1961 rev: None,
1962 };
1963 assert_eq!(git1, git2);
1964
1965 let git3 = DependencySource::Git {
1966 url: "https://different.com".into(),
1967 rev: None,
1968 };
1969 assert_ne!(git1, git3);
1970 }
1971
1972 #[test]
1973 fn test_check_json_nesting_depth_shallow_accepted() {
1974 let content = br#"{"a":[1,2,{"b":3}],"c":"[not{real}nesting]"}"#;
1975 assert_eq!(check_json_nesting_depth(content, 4), Ok(()));
1976 }
1977
1978 #[test]
1979 fn test_check_json_nesting_depth_string_brackets_ignored() {
1980 // Brackets inside a string literal (including an escaped quote) must
1981 // never be counted as structural nesting.
1982 let content = br#"{"a":"[[[[[\"]]]]]"}"#;
1983 assert_eq!(check_json_nesting_depth(content, 1), Ok(()));
1984 }
1985
1986 #[test]
1987 fn test_check_json_nesting_depth_mixed_array_and_object_nesting() {
1988 let content = br#"[{"a":[{"b":1}]}]"#;
1989 assert_eq!(check_json_nesting_depth(content, 4), Ok(()));
1990 assert_eq!(check_json_nesting_depth(content, 3), Err(4));
1991 }
1992
1993 #[test]
1994 fn test_check_json_nesting_depth_deeply_nested_array_rejected() {
1995 // The #430 attack shape: without this guard, `serde_json::from_slice`
1996 // would still not crash — it independently halts at its own default
1997 // recursion limit (128) with a clean `Err`. This guard rejects the
1998 // same shape earlier, at a stricter depth (64), with a repo-specific
1999 // `check_json_nesting_depth` error rather than a `serde_json::Error`.
2000 let deeply_nested = format!("{}1{}", "[".repeat(10), "]".repeat(10));
2001 assert_eq!(
2002 check_json_nesting_depth(deeply_nested.as_bytes(), 4),
2003 Err(5)
2004 );
2005 }
2006
2007 #[test]
2008 fn test_check_json_nesting_depth_unterminated_string_blinds_scanner_but_serde_json_still_rejects()
2009 {
2010 // An unterminated `"` makes the scanner treat everything after it as
2011 // string content, so it returns `Ok` even with deep nesting past the
2012 // quote. This is safe only because `serde_json` independently halts
2013 // on the same malformed input via its own tokenizer.
2014 let payload = format!("\"unterminated{}1", "[".repeat(MAX_JSON_NESTING_DEPTH + 1));
2015 assert_eq!(
2016 check_json_nesting_depth(payload.as_bytes(), MAX_JSON_NESTING_DEPTH),
2017 Ok(())
2018 );
2019 assert!(serde_json::from_str::<serde_json::Value>(&payload).is_err());
2020 }
2021
2022 #[test]
2023 fn test_check_json_nesting_depth_at_production_boundary() {
2024 let depth = MAX_JSON_NESTING_DEPTH;
2025 let at_max = format!("{}1{}", "[".repeat(depth), "]".repeat(depth));
2026 assert_eq!(check_json_nesting_depth(at_max.as_bytes(), depth), Ok(()));
2027
2028 let over_max = format!("{}1{}", "[".repeat(depth + 1), "]".repeat(depth + 1));
2029 assert_eq!(
2030 check_json_nesting_depth(over_max.as_bytes(), depth),
2031 Err(depth + 1)
2032 );
2033 }
2034
2035 #[test]
2036 fn test_dependency_source_debug() {
2037 let source = DependencySource::Registry;
2038 let debug = format!("{:?}", source);
2039 assert_eq!(debug, "Registry");
2040
2041 let git = DependencySource::Git {
2042 url: "https://example.com".into(),
2043 rev: Some("main".into()),
2044 };
2045 let git_debug = format!("{:?}", git);
2046 assert!(git_debug.contains("https://example.com"));
2047 assert!(git_debug.contains("main"));
2048 }
2049
2050 #[test]
2051 fn test_loading_state_default() {
2052 assert_eq!(LoadingState::default(), LoadingState::Idle);
2053 }
2054
2055 #[test]
2056 fn test_loading_state_copy() {
2057 let state = LoadingState::Loading;
2058 let copied = state;
2059 assert_eq!(state, copied);
2060 }
2061
2062 #[test]
2063 fn test_loading_state_debug() {
2064 let debug_str = format!("{:?}", LoadingState::Loading);
2065 assert_eq!(debug_str, "Loading");
2066 }
2067
2068 #[test]
2069 fn test_loading_state_all_variants() {
2070 let variants = [
2071 LoadingState::Idle,
2072 LoadingState::Loading,
2073 LoadingState::Loaded,
2074 LoadingState::Failed,
2075 ];
2076 for (i, v1) in variants.iter().enumerate() {
2077 for (j, v2) in variants.iter().enumerate() {
2078 if i == j {
2079 assert_eq!(v1, v2);
2080 } else {
2081 assert_ne!(v1, v2);
2082 }
2083 }
2084 }
2085 }
2086}