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