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/// Whether `value` looks like a local filesystem path reference in a manifest specifier.
1100///
1101/// Matches a relative path (`./`, `../`), an absolute path (`/`), a home-relative path
1102/// (`~/`), or a Windows drive letter (`C:/`, `C:\`) — rather than a registry name, URL, or VCS
1103/// shorthand.
1104///
1105/// Shared by every ecosystem whose specifier grammar can name a local path with no explicit
1106/// `file:`/`path:`-style prefix at all (npm's bare relative-path dependency value, Go's
1107/// filesystem `replace` target) — previously duplicated near-verbatim between
1108/// `deps-npm`'s and `deps-go`'s parsers (code review #1202).
1109///
1110/// # Examples
1111///
1112/// ```
1113/// use deps_core::parser::looks_like_filesystem_path;
1114///
1115/// assert!(looks_like_filesystem_path("../local-sibling"));
1116/// assert!(looks_like_filesystem_path("~/local/sibling"));
1117/// assert!(looks_like_filesystem_path("C:/local/sibling"));
1118/// assert!(!looks_like_filesystem_path("github.com/acme/pkg"));
1119/// ```
1120#[must_use]
1121pub fn looks_like_filesystem_path(value: &str) -> bool {
1122 value.starts_with("./")
1123 || value.starts_with("../")
1124 || value.starts_with('/')
1125 || value.starts_with("~/")
1126 || (value
1127 .as_bytes()
1128 .first()
1129 .is_some_and(u8::is_ascii_alphabetic)
1130 && matches!(value.as_bytes().get(1), Some(b':')))
1131}
1132
1133/// Loading state for registry data fetching.
1134///
1135/// Tracks the current state of background registry operations to provide
1136/// user feedback about data availability.
1137///
1138/// # State Transitions
1139///
1140/// Complete state machine diagram showing all valid transitions:
1141///
1142/// ```text
1143/// ┌─────┐
1144/// │Idle │ (Initial state: no data loaded, not loading)
1145/// └──┬──┘
1146/// │
1147/// │ didOpen/didChange
1148/// │ (start fetching)
1149/// ▼
1150/// ┌────────┐
1151/// │Loading │ (Fetching registry data)
1152/// └───┬────┘
1153/// │
1154/// ├─────── Success ──────┐
1155/// │ ▼
1156/// │ ┌────────┐
1157/// │ │Loaded │ (Data cached and ready)
1158/// │ └───┬────┘
1159/// │ │
1160/// │ │ didChange/refresh
1161/// │ │ (re-fetch)
1162/// │ │
1163/// │ ▼
1164/// │ ┌────────┐
1165/// │ │Loading │
1166/// │ └────────┘
1167/// │
1168/// └─────── Error ─────────┐
1169/// ▼
1170/// ┌────────┐
1171/// │Failed │ (Fetch failed, old cache may exist)
1172/// └───┬────┘
1173/// │
1174/// │ didChange/retry
1175/// │ (try again)
1176/// │
1177/// ▼
1178/// ┌────────┐
1179/// │Loading │
1180/// └────────┘
1181/// ```
1182///
1183/// # Key Behaviors
1184///
1185/// - **Idle**: Initial state when no data has been fetched yet
1186/// - **Loading**: Actively fetching from registry (may show loading indicator)
1187/// - **Loaded**: Successfully fetched and cached data
1188/// - **Failed**: Network/registry error occurred (falls back to old cache if available)
1189///
1190/// # Thread Safety
1191///
1192/// This enum is `Copy` for efficient passing across thread boundaries in async contexts.
1193///
1194/// **Exhaustive** (issue #769): a closed UI state machine — a wildcard arm at any consuming
1195/// match site would silently render nothing for a new state instead of failing to compile.
1196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1197pub enum LoadingState {
1198 /// No data loaded, not currently loading
1199 #[default]
1200 Idle,
1201 /// Currently fetching registry data
1202 Loading,
1203 /// Data fetched and cached
1204 Loaded,
1205 /// Fetch failed (old cached data may still be available)
1206 Failed,
1207}
1208
1209#[cfg(test)]
1210#[expect(
1211 clippy::cast_possible_truncation,
1212 reason = "#673: fixed test-fixture constants cast to usize never approach truncation range"
1213)]
1214mod tests {
1215 use super::*;
1216
1217 #[test]
1218 fn test_check_toml_nesting_depth_empty_content() {
1219 assert_eq!(check_toml_nesting_depth("", 4), Ok(()));
1220 }
1221
1222 #[test]
1223 fn test_check_toml_nesting_depth_no_brackets() {
1224 assert_eq!(check_toml_nesting_depth("a = 1\nb = \"text\"\n", 4), Ok(()));
1225 }
1226
1227 #[test]
1228 fn test_check_toml_nesting_depth_exactly_at_max() {
1229 let content = format!("a = {}1{}", "[".repeat(4), "]".repeat(4));
1230 assert_eq!(check_toml_nesting_depth(&content, 4), Ok(()));
1231 }
1232
1233 #[test]
1234 fn test_check_toml_nesting_depth_one_over_max() {
1235 let content = format!("a = {}1{}", "[".repeat(5), "]".repeat(5));
1236 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1237 }
1238
1239 #[test]
1240 fn test_check_toml_nesting_depth_double_quoted_string_ignored() {
1241 let content = r#"a = "[[[[[unbalanced brackets]]]]]""#;
1242 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1243 }
1244
1245 #[test]
1246 fn test_check_toml_nesting_depth_single_quoted_string_ignored() {
1247 let content = "a = '[[[[[unbalanced brackets]]]]]'";
1248 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1249 }
1250
1251 #[test]
1252 fn test_check_toml_nesting_depth_escaped_quote_in_string() {
1253 // The escaped quote must not terminate the string early, so the
1254 // brackets that follow stay inside the string and are ignored.
1255 let content = r#"a = "embedded \" quote [[[[[""#;
1256 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1257 }
1258
1259 #[test]
1260 fn test_check_toml_nesting_depth_comment_ignored() {
1261 let content = "# [[[[[unbalanced comment brackets]]]]]\na = 1\n";
1262 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1263 }
1264
1265 #[test]
1266 fn test_check_toml_nesting_depth_mixed_array_and_table_nesting() {
1267 // [ { [ { -> depth 4 at the innermost brace.
1268 let content = "a = [{ b = [{ c = 1 }] }]";
1269 assert_eq!(check_toml_nesting_depth(content, 4), Ok(()));
1270 assert_eq!(check_toml_nesting_depth(content, 3), Err(4));
1271 }
1272
1273 #[test]
1274 fn test_check_toml_nesting_depth_inline_table_at_production_boundary() {
1275 // Nested inline tables (`{a={a=...}}`) are the shape that actually
1276 // exhausts a 2 MiB tokio worker stack before nested arrays do
1277 // (impl-critic C3) — exercise it against the real, shipped
1278 // `MAX_TOML_NESTING_DEPTH`, not just an arbitrary small max_depth.
1279 let depth = MAX_TOML_NESTING_DEPTH;
1280 let at_max = format!("a = {}1{}", "{a=".repeat(depth), "}".repeat(depth));
1281 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1282
1283 let over_max = format!("a = {}1{}", "{a=".repeat(depth + 1), "}".repeat(depth + 1));
1284 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1285 }
1286
1287 #[test]
1288 fn test_check_toml_nesting_depth_dotted_header_at_production_boundary() {
1289 // Regression test for impl-critic C4: `[a.a.a...]` nests one table
1290 // level per `.` segment with zero bracket characters, so a
1291 // bracket-only scanner scores this depth 0 and lets it straight
1292 // through to `toml_span::parse`, which still stack-overflows.
1293 let depth = MAX_TOML_NESTING_DEPTH;
1294 let at_max = format!("[{}a]\ny = 1\n", "a.".repeat(depth - 1));
1295 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1296
1297 let over_max = format!("[{}a]\ny = 1\n", "a.".repeat(depth));
1298 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1299 }
1300
1301 #[test]
1302 fn test_check_toml_nesting_depth_dotted_key_at_production_boundary() {
1303 // Same C4 bypass, via a dotted key (`a.a.a...= 1`) instead of a
1304 // dotted table header.
1305 let depth = MAX_TOML_NESTING_DEPTH;
1306 let at_max = format!("a{} = 1\n", ".a".repeat(depth));
1307 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1308
1309 let over_max = format!("a{} = 1\n", ".a".repeat(depth + 1));
1310 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1311 }
1312
1313 #[test]
1314 fn test_check_toml_nesting_depth_legitimate_dotted_keys_accepted() {
1315 // Positive test: common, shallow legitimate dotted-key patterns must
1316 // never be rejected, and a float/version-like dot in value position
1317 // must never be miscounted as a key segment.
1318 let content = r#"
1319[tool.poetry.dependencies]
1320requests = { version = "^2.28", extras = ["socks"] }
1321
1322[libraries]
1323spring-boot = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring" }
1324
1325[metrics]
1326cpu_load = 3.14
1327"#;
1328 assert_eq!(
1329 check_toml_nesting_depth(content, MAX_TOML_NESTING_DEPTH),
1330 Ok(())
1331 );
1332 }
1333
1334 #[test]
1335 fn test_check_toml_nesting_depth_dotted_array_of_tables_header_at_boundary() {
1336 // `[[a.a...]]` double-bracket header: 2 bracket levels + N dot
1337 // segments must compose into one shared budget, not be tracked
1338 // independently (which would let brackets and dots each stay under
1339 // the cap while their sum exceeds it).
1340 let depth = MAX_TOML_NESTING_DEPTH;
1341 let at_max = format!("[[{}a]]\ny = 1\n", "a.".repeat(depth - 2));
1342 assert_eq!(check_toml_nesting_depth(&at_max, depth), Ok(()));
1343
1344 let over_max = format!("[[{}a]]\ny = 1\n", "a.".repeat(depth - 1));
1345 assert_eq!(check_toml_nesting_depth(&over_max, depth), Err(depth + 1));
1346 }
1347
1348 #[test]
1349 fn test_check_toml_nesting_depth_dotted_key_inside_bracket_header_composes() {
1350 // A `[a.b]` header followed by `c.d.e = {f.g = 1}` exercises bracket/dot accounting
1351 // across adjacent statements — the header's dots must not leak into the next
1352 // statement's budget.
1353 let content = "[a.b]\nc.d.e = {f.g = 1}\n";
1354 // Header peaks at depth 2 (1 bracket + 1 dot), released at its newline; the second
1355 // line peaks at depth 4 (2 dots for c.d.e + 1 for `{` + 1 for f.g's dot).
1356 assert_eq!(check_toml_nesting_depth(content, 4), Ok(()));
1357 assert_eq!(check_toml_nesting_depth(content, 3), Err(4));
1358 }
1359
1360 #[test]
1361 fn test_check_toml_nesting_depth_many_dotted_key_statements_do_not_accumulate() {
1362 // Hundreds of top-level dotted-key statements, each individually
1363 // well under the cap, must never accumulate across statements — a
1364 // per-key release that fired late (or not at all) would eventually
1365 // push a long file over the cap even though no single statement
1366 // does.
1367 let mut content = String::new();
1368 for i in 0..500 {
1369 content.push_str(&format!("k{i}.a.b.c = {i}\n"));
1370 }
1371 assert_eq!(check_toml_nesting_depth(&content, 4), Ok(()));
1372 }
1373
1374 #[test]
1375 fn test_check_toml_nesting_depth_sibling_inline_tables_in_array_do_not_accumulate() {
1376 // Many sibling `{ ... }` entries in one array, each with a short
1377 // dotted key, must not falsely accumulate depth across entries —
1378 // only one entry's dots are ever held at a time, matching how
1379 // `toml_span`'s recursion actually unwinds between array elements.
1380 let mut content = String::from("deps = [\n");
1381 for i in 0..200 {
1382 content.push_str(&format!(
1383 " {{ name = \"pkg{i}\", version.ref = \"v\" }},\n"
1384 ));
1385 }
1386 content.push_str("]\n");
1387 assert_eq!(
1388 check_toml_nesting_depth(&content, MAX_TOML_NESTING_DEPTH),
1389 Ok(())
1390 );
1391 }
1392
1393 #[test]
1394 fn test_check_toml_nesting_depth_multiline_literal_odd_quote_count() {
1395 // A multi-line literal string containing an apostrophe (odd count of
1396 // its own quote char) must not desync the scanner into thinking the
1397 // string never closes.
1398 let content = format!("a = '''don't'''\nb = {}1{}", "[".repeat(5), "]".repeat(5));
1399 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1400 }
1401
1402 #[test]
1403 fn test_check_toml_nesting_depth_rejects_original_sigabrt_payloads() {
1404 // The exact payload shapes that reproduced the #150 SIGABRT even
1405 // after the first version of this guard shipped (impl-critic C1):
1406 // a multi-line string with an odd count of its own quote char,
1407 // followed by 20000-deep nesting.
1408 let n = 20_000;
1409 let payload1 = format!("a = '''don't'''\nb = {}1{}", "[".repeat(n), "]".repeat(n));
1410 let payload2 = format!(
1411 "a = \"\"\"x\"y\"\"\"\nb = {}1{}",
1412 "[".repeat(n),
1413 "]".repeat(n)
1414 );
1415
1416 assert!(check_toml_nesting_depth(&payload1, MAX_TOML_NESTING_DEPTH).is_err());
1417 assert!(check_toml_nesting_depth(&payload2, MAX_TOML_NESTING_DEPTH).is_err());
1418 }
1419
1420 #[test]
1421 fn test_check_toml_nesting_depth_multiline_basic_odd_quote_count() {
1422 let content = format!(
1423 "a = \"\"\"x\"y\"\"\"\nb = {}1{}",
1424 "[".repeat(5),
1425 "]".repeat(5)
1426 );
1427 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1428 }
1429
1430 #[test]
1431 fn test_check_toml_nesting_depth_multiline_basic_trailing_extra_quotes() {
1432 // TOML allows 1-2 literal quotes right before the closing triple
1433 // (here: 2 extra content quotes then the 3-quote closer, 5 in a
1434 // row). The scanner must still be back in normal mode right after,
1435 // so the nested array on the next line is counted correctly.
1436 let content = format!(
1437 "a = \"\"\"ends with two quotes: \"\"\"\"\"\nb = {}1{}",
1438 "[".repeat(5),
1439 "]".repeat(5)
1440 );
1441 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1442 }
1443
1444 #[test]
1445 fn test_check_toml_nesting_depth_brackets_inside_multiline_string_ignored() {
1446 let content = "d = \"\"\"\nsize is 5\" wide [[[unbalanced]]]\n\"\"\"\ne = 1\n";
1447 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1448 }
1449
1450 #[test]
1451 fn test_check_toml_nesting_depth_multiline_literal_brackets_ignored() {
1452 let content = "d = '''\n[[[unbalanced brackets]]]\n'''\ne = 1\n";
1453 assert_eq!(check_toml_nesting_depth(content, 0), Ok(()));
1454 }
1455
1456 #[test]
1457 fn test_check_toml_nesting_depth_multiline_basic_one_extra_trailing_quote() {
1458 // Exactly 1 extra content quote before the closing triple (4-quote
1459 // run total), between the 0-extra and 2-extra cases already covered.
1460 let content = format!(
1461 "a = \"\"\"ends with one quote: \"\"\"\"\nb = {}1{}",
1462 "[".repeat(5),
1463 "]".repeat(5)
1464 );
1465 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1466 }
1467
1468 #[test]
1469 fn test_check_toml_nesting_depth_adjacent_multiline_strings_then_nesting() {
1470 // Two separate multi-line strings (one basic, one literal) back to
1471 // back, each closing normally, must not leave the scanner desynced
1472 // for the real nesting that follows.
1473 let content = format!(
1474 "a = \"\"\"first\"\"\"\nb = '''second'''\nc = {}1{}",
1475 "[".repeat(5),
1476 "]".repeat(5)
1477 );
1478 assert_eq!(check_toml_nesting_depth(&content, 4), Err(5));
1479 }
1480
1481 /// Builds `n` lines of block mapping, each one column deeper than the
1482 /// last (`k:\n k:\n k:\n...`) — the tightest real `yaml-rust2` crash
1483 /// shape (impl-critic/tester finding), and exactly `n` scanner pushes.
1484 fn nested_mapping(n: usize) -> String {
1485 let mut s = String::new();
1486 for i in 0..n {
1487 s.push_str(&" ".repeat(i));
1488 s.push_str("k:\n");
1489 }
1490 s
1491 }
1492
1493 #[test]
1494 fn test_check_yaml_nesting_depth_empty_content() {
1495 assert_eq!(check_yaml_nesting_depth("", 4), Ok(()));
1496 }
1497
1498 #[test]
1499 fn test_check_yaml_nesting_depth_no_nesting() {
1500 assert_eq!(
1501 check_yaml_nesting_depth("name: foo\nversion: 1.0.0\n", 1),
1502 Ok(())
1503 );
1504 }
1505
1506 #[test]
1507 fn test_check_yaml_nesting_depth_dash_chain_exactly_at_max() {
1508 // N dashes push N levels, plus one more for the trailing scalar's
1509 // own column (deeper than the last dash), so N=3 peaks at depth 4.
1510 let content = format!("{}1", "- ".repeat(3));
1511 assert_eq!(check_yaml_nesting_depth(&content, 4), Ok(()));
1512 }
1513
1514 #[test]
1515 fn test_check_yaml_nesting_depth_dash_chain_one_over_max() {
1516 let content = format!("{}1", "- ".repeat(4));
1517 assert_eq!(check_yaml_nesting_depth(&content, 4), Err(5));
1518 }
1519
1520 #[test]
1521 fn test_check_yaml_nesting_depth_block_mapping_exactly_at_max() {
1522 assert_eq!(check_yaml_nesting_depth(&nested_mapping(4), 4), Ok(()));
1523 }
1524
1525 #[test]
1526 fn test_check_yaml_nesting_depth_block_mapping_one_over_max() {
1527 assert_eq!(check_yaml_nesting_depth(&nested_mapping(5), 4), Err(5));
1528 }
1529
1530 #[test]
1531 fn test_check_yaml_nesting_depth_double_quoted_string_ignored() {
1532 let content = r#"a: "[[[[[unbalanced brackets]]]]]""#;
1533 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1534 }
1535
1536 #[test]
1537 fn test_check_yaml_nesting_depth_single_quoted_string_ignored() {
1538 let content = "a: '[[[[[unbalanced brackets]]]]]'";
1539 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1540 }
1541
1542 #[test]
1543 fn test_check_yaml_nesting_depth_escaped_quote_in_string() {
1544 // The escaped quote must not terminate the string early, so the
1545 // brackets that follow stay inside the string and are ignored.
1546 let content = r#"a: "embedded \" quote [[[[[""#;
1547 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1548 }
1549
1550 #[test]
1551 fn test_check_yaml_nesting_depth_comment_ignored() {
1552 let content = "# [[[[[unbalanced comment brackets]]]]]\na: 1\n";
1553 assert_eq!(check_yaml_nesting_depth(content, 1), Ok(()));
1554 }
1555
1556 #[test]
1557 fn test_check_yaml_nesting_depth_mixed_flow_and_block_nesting() {
1558 // a: -> depth 1, " b:" -> depth 2, then [ [ [ inside the value ->
1559 // peaks at depth 5 — flow and block share one budget.
1560 let content = "a:\n b: [c, [d, [e]]]\n";
1561 assert_eq!(check_yaml_nesting_depth(content, 5), Ok(()));
1562 assert_eq!(check_yaml_nesting_depth(content, 4), Err(5));
1563 }
1564
1565 #[test]
1566 fn test_check_yaml_nesting_depth_dash_chain_at_production_boundary() {
1567 // N dashes plus the trailing scalar's own column peak at depth
1568 // N + 1, so N = depth - 1 is the boundary.
1569 let depth = MAX_YAML_NESTING_DEPTH;
1570 let at_max = format!("{}1", "- ".repeat(depth - 1));
1571 assert_eq!(check_yaml_nesting_depth(&at_max, depth), Ok(()));
1572
1573 let over_max = format!("{}1", "- ".repeat(depth));
1574 assert_eq!(check_yaml_nesting_depth(&over_max, depth), Err(depth + 1));
1575 }
1576
1577 #[test]
1578 fn test_check_yaml_nesting_depth_block_mapping_at_production_boundary() {
1579 let depth = MAX_YAML_NESTING_DEPTH;
1580 assert_eq!(
1581 check_yaml_nesting_depth(&nested_mapping(depth), depth),
1582 Ok(())
1583 );
1584 assert_eq!(
1585 check_yaml_nesting_depth(&nested_mapping(depth + 1), depth),
1586 Err(depth + 1)
1587 );
1588 }
1589
1590 #[test]
1591 fn test_check_yaml_nesting_depth_rejects_original_sigabrt_payloads() {
1592 // Depths comfortably past the empirically bisected real `yaml-rust2`
1593 // 0.12 crash thresholds on a 2 MiB debug stack (compact dash chain
1594 // aborts at 4536, growing-indent block mapping aborts at 1994) —
1595 // stays a real regression test even if `MAX_YAML_NESTING_DEPTH`
1596 // changes later, mirroring the TOML sibling test's margin.
1597 let dash_chain = format!("{}1", "- ".repeat(6000));
1598 assert!(check_yaml_nesting_depth(&dash_chain, MAX_YAML_NESTING_DEPTH).is_err());
1599
1600 let block_mapping = nested_mapping(2500);
1601 assert!(check_yaml_nesting_depth(&block_mapping, MAX_YAML_NESTING_DEPTH).is_err());
1602 }
1603
1604 #[test]
1605 fn test_check_yaml_nesting_depth_apostrophe_does_not_blind_scanner() {
1606 // impl-critic C1: an apostrophe mid-plain-scalar (e.g. `doesn't`)
1607 // must not be mistaken for opening a quoted scalar and swallow the
1608 // rest of the file, hiding the real nesting that follows.
1609 let payload = format!(
1610 "name: my_app\ndescription: A package that doesn't panic\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_stray_double_quote_does_not_blind_scanner() {
1618 // Same root cause as above, with a stray `"` (e.g. a dimension
1619 // string like `6" long`) instead of an apostrophe.
1620 let payload = format!(
1621 "size: 6\" long\n{}1",
1622 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1623 );
1624 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1625 }
1626
1627 #[test]
1628 fn test_check_yaml_nesting_depth_unterminated_quote_only_blinds_one_line() {
1629 // A quote that genuinely never closes must resynchronize at the
1630 // next newline rather than scanning to EOF looking for a match.
1631 let payload = format!(
1632 "a: \"unterminated\n{}1",
1633 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1634 );
1635 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1636 }
1637
1638 #[test]
1639 fn test_check_yaml_nesting_depth_backslash_before_newline_does_not_extend_string() {
1640 // A `\` placed right before the line break must not "escape" the
1641 // newline and let an opened double-quoted scalar swallow further
1642 // lines (impl-critic C1 follow-up).
1643 let payload = format!(
1644 "a: \"unterminated\\\n{}1",
1645 "- ".repeat(MAX_YAML_NESTING_DEPTH + 1)
1646 );
1647 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1648 }
1649
1650 #[test]
1651 fn test_check_yaml_nesting_depth_unclosed_bracket_does_not_blind_scanner() {
1652 // impl-critic C2: an unclosed `[`/`{` must not permanently suppress
1653 // block-indentation scanning for the remainder of the file.
1654 let payload = format!("a: [\n{}1", "- ".repeat(MAX_YAML_NESTING_DEPTH + 1));
1655 assert!(check_yaml_nesting_depth(&payload, MAX_YAML_NESTING_DEPTH).is_err());
1656 }
1657
1658 #[test]
1659 fn test_check_yaml_nesting_depth_many_sibling_keys_do_not_accumulate() {
1660 let mut content = String::from("dependencies:\n");
1661 for i in 0..2000 {
1662 content.push_str(&format!(" pkg{i}: ^1.0.0\n"));
1663 }
1664 assert_eq!(check_yaml_nesting_depth(&content, 2), Ok(()));
1665 }
1666
1667 #[test]
1668 fn test_check_yaml_nesting_depth_multiline_flow_list_siblings_do_not_accumulate() {
1669 let mut content = String::from("dependencies: [\n");
1670 for _ in 0..2000 {
1671 content.push_str(" a,\n");
1672 }
1673 content.push_str("]\n");
1674 assert_eq!(check_yaml_nesting_depth(&content, 3), Ok(()));
1675 }
1676
1677 /// Billion-laughs-style doubling chain: depth stays 2, but expanded
1678 /// `Yaml` node count is roughly `2^n` from a source only ~20 bytes/level
1679 /// long — the exact attack shape from issue #175.
1680 fn doubling_chain(n: usize) -> String {
1681 let mut s = String::from("name: app\na0: &a0 [x, x]\n");
1682 for i in 1..=n {
1683 s.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
1684 }
1685 s
1686 }
1687
1688 #[test]
1689 fn test_check_yaml_expansion_empty_content() {
1690 assert_eq!(check_yaml_expansion("", MAX_YAML_EXPANDED_BYTES), Ok(()));
1691 }
1692
1693 #[test]
1694 fn test_check_yaml_expansion_rejects_n30_doubling_chain_attack() {
1695 // The exact #175 payload shape: N=30 expands to over 2^30 nodes,
1696 // far past MAX_YAML_EXPANDED_BYTES, and must be rejected instead of
1697 // handed to `YamlLoader::load_from_str` (which OOMs/SIGKILLs).
1698 assert!(check_yaml_expansion(&doubling_chain(30), MAX_YAML_EXPANDED_BYTES).is_err());
1699 }
1700
1701 #[test]
1702 fn test_check_yaml_expansion_realistic_pubspec_yaml_accepted() {
1703 let yaml = r"
1704name: my_app
1705description: A sample app
1706environment:
1707 sdk: '>=3.0.0 <4.0.0'
1708dependencies:
1709 flutter:
1710 sdk: flutter
1711 http: ^1.0.0
1712 provider: ^6.0.0
1713 my_pkg:
1714 git:
1715 url: https://github.com/user/repo.git
1716 ref: main
1717 path: packages/my_pkg
1718dev_dependencies:
1719 build_runner: ^2.4.0
1720";
1721 assert_eq!(check_yaml_expansion(yaml, MAX_YAML_EXPANDED_BYTES), Ok(()));
1722 }
1723
1724 #[test]
1725 fn test_check_yaml_expansion_few_hundred_package_lockfile_accepted() {
1726 let mut lock = String::from("packages:\n");
1727 for i in 0..300 {
1728 lock.push_str(&format!(
1729 " 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"
1730 ));
1731 }
1732 assert_eq!(check_yaml_expansion(&lock, MAX_YAML_EXPANDED_BYTES), Ok(()));
1733 }
1734
1735 #[test]
1736 fn test_check_yaml_expansion_asterisk_in_plain_scalar_not_misread_as_alias() {
1737 // The raw-text pre-scan approach this algorithm replaced
1738 // false-positived on ordinary prose like this — a real `Event::Alias`
1739 // is never produced for a `*`/`&` inside a plain scalar value.
1740 let cases = [
1741 "description: A widget *multiplier* helper\n",
1742 "description: see *.dart files\n",
1743 "e: text &y more\n",
1744 ];
1745 for content in cases {
1746 assert_eq!(
1747 check_yaml_expansion(content, MAX_YAML_EXPANDED_BYTES),
1748 Ok(()),
1749 "false positive on: {content:?}"
1750 );
1751 }
1752 }
1753
1754 #[test]
1755 fn test_check_yaml_expansion_alias_to_undefined_anchor_accepted() {
1756 // `YamlLoader` itself falls back to `Yaml::BadValue` for an alias id
1757 // it has no anchor recorded for; this guard mirrors that fallback
1758 // (`unwrap_or(YAML_NODE_OVERHEAD_BYTES)`) rather than treating it as
1759 // unbounded.
1760 assert_eq!(
1761 check_yaml_expansion("a: *undefined\n", MAX_YAML_EXPANDED_BYTES),
1762 Ok(())
1763 );
1764 }
1765
1766 #[test]
1767 fn test_check_yaml_expansion_self_referential_alias_accepted() {
1768 // A sequence aliasing its own not-yet-closed anchor: the anchor
1769 // isn't registered yet when the alias event fires, so this hits the
1770 // same `unwrap_or(YAML_NODE_OVERHEAD_BYTES)` fallback as an
1771 // undefined anchor (verified against real `yaml-rust2` 0.12
1772 // behavior, not assumed) rather than recursing.
1773 assert_eq!(
1774 check_yaml_expansion("a: &x [1, *x]\n", MAX_YAML_EXPANDED_BYTES),
1775 Ok(())
1776 );
1777 }
1778
1779 #[test]
1780 fn test_check_yaml_expansion_at_production_boundary() {
1781 // `YAML_NODE_OVERHEAD_BYTES` is platform-dependent (`size_of::<yaml_rust2::Yaml>()`
1782 // shrinks on 32-bit targets), so the level that crosses `MAX_YAML_EXPANDED_BYTES`
1783 // shifts by platform — find the real boundary empirically instead of hardcoding a
1784 // 64-bit-specific N.
1785 let boundary = (1..=25)
1786 .find(|&n| check_yaml_expansion(&doubling_chain(n), MAX_YAML_EXPANDED_BYTES).is_err())
1787 .expect("doubling chain must cross MAX_YAML_EXPANDED_BYTES well within 25 levels");
1788
1789 assert_eq!(
1790 check_yaml_expansion(&doubling_chain(boundary - 1), MAX_YAML_EXPANDED_BYTES),
1791 Ok(()),
1792 "level {} (just before the boundary) should stay under budget",
1793 boundary - 1
1794 );
1795 assert!(check_yaml_expansion(&doubling_chain(boundary), MAX_YAML_EXPANDED_BYTES).is_err());
1796 }
1797
1798 #[test]
1799 fn test_check_yaml_expansion_large_scalar_anchor_aliased_many_times_rejected() {
1800 // Critic CRITICAL 1: a node-count budget accepted a large-scalar anchor aliased many
1801 // times (node growth is linear, but memory grows with anchor size x alias count) — a
1802 // 1 MB anchor aliased 32 times is ~33 MB of real `YamlLoader` allocation, only 34
1803 // nodes.
1804 let anchor_value = "A".repeat(1_000_000);
1805 let mut content = format!("s: &s \"{anchor_value}\"\nl:\n");
1806 for _ in 0..32 {
1807 content.push_str(" - *s\n");
1808 }
1809 assert!(check_yaml_expansion(&content, MAX_YAML_EXPANDED_BYTES).is_err());
1810 }
1811
1812 #[test]
1813 fn test_check_yaml_expansion_exact_max_boundary() {
1814 // `charge` compares with `>`, so a document whose total charge is
1815 // exactly `max_bytes` must be accepted, and one byte more must be
1816 // rejected — pins that this is intentional (an accidental `>=`
1817 // would reject the exact-max case and go uncaught otherwise).
1818 let scalar_len = 1000usize;
1819 let max_bytes = YAML_NODE_OVERHEAD_BYTES as usize + scalar_len;
1820 let content = "a".repeat(scalar_len);
1821
1822 assert_eq!(check_yaml_expansion(&content, max_bytes), Ok(()));
1823 assert!(check_yaml_expansion(&content, max_bytes - 1).is_err());
1824 }
1825
1826 #[test]
1827 fn test_check_yaml_expansion_matches_recursive_byte_weight_oracle() {
1828 // Pins the invariant the whole design rests on: `check_yaml_expansion`'s
1829 // streaming tally equals an independent, recursive byte-weight
1830 // computation over the real parsed `Yaml` tree, for anchor-free
1831 // docs. Catches an accidental algorithm regression, or a
1832 // `yaml-rust2` upgrade that changes what gets allocated, that unit
1833 // tests on fixed payloads alone would not.
1834 fn recursive_weight(y: &yaml_rust2::Yaml) -> u64 {
1835 match y {
1836 yaml_rust2::Yaml::String(s) => YAML_NODE_OVERHEAD_BYTES + s.len() as u64,
1837 yaml_rust2::Yaml::Array(arr) => {
1838 YAML_NODE_OVERHEAD_BYTES + arr.iter().map(recursive_weight).sum::<u64>()
1839 }
1840 yaml_rust2::Yaml::Hash(h) => {
1841 YAML_NODE_OVERHEAD_BYTES
1842 + h.iter()
1843 .map(|(k, v)| recursive_weight(k) + recursive_weight(v))
1844 .sum::<u64>()
1845 }
1846 _ => YAML_NODE_OVERHEAD_BYTES,
1847 }
1848 }
1849
1850 // Binary search for the smallest `max_bytes` that `check_yaml_expansion`
1851 // still accepts — since `charge` uses `>`, this is exactly the real
1852 // total charged.
1853 fn smallest_accepted(content: &str) -> u64 {
1854 let (mut lo, mut hi) = (0u64, 1_000_000u64);
1855 while lo < hi {
1856 let mid = lo + (hi - lo) / 2;
1857 if check_yaml_expansion(content, usize::try_from(mid).unwrap_or(usize::MAX)).is_ok()
1858 {
1859 hi = mid;
1860 } else {
1861 lo = mid + 1;
1862 }
1863 }
1864 lo
1865 }
1866
1867 // All scalars quoted, so every leaf is `Yaml::String` and its
1868 // parsed length matches its source text exactly (an unquoted
1869 // integer/bool/null scalar's `Yaml` variant does not retain its
1870 // source text, which would make the oracle inexact).
1871 let docs = [
1872 r#"a: "hello""#,
1873 r#"a: ["x", "yy", "zzz"]"#,
1874 "a:\n b: \"value\"\n c:\n - \"one\"\n - \"two\"\n",
1875 ];
1876
1877 for content in docs {
1878 let parsed = yaml_rust2::YamlLoader::load_from_str(content).unwrap();
1879 let expected = recursive_weight(&parsed[0]);
1880 assert_eq!(
1881 smallest_accepted(content),
1882 expected,
1883 "oracle mismatch for {content:?}"
1884 );
1885 }
1886 }
1887
1888 #[test]
1889 fn test_dependency_source_registry() {
1890 let source = DependencySource::Registry;
1891 assert_eq!(source, DependencySource::Registry);
1892 assert!(source.is_registry());
1893 assert!(source.is_version_resolvable());
1894 }
1895
1896 #[test]
1897 fn test_dependency_source_git() {
1898 let source = DependencySource::Git {
1899 url: "https://github.com/user/repo".into(),
1900 rev: Some("main".into()),
1901 };
1902
1903 assert!(!source.is_registry());
1904 assert!(!source.is_version_resolvable());
1905
1906 match source {
1907 DependencySource::Git { url, rev } => {
1908 assert_eq!(url, "https://github.com/user/repo");
1909 assert_eq!(rev, Some("main".into()));
1910 }
1911 _ => panic!("Expected Git source"),
1912 }
1913 }
1914
1915 #[test]
1916 fn test_dependency_source_git_no_rev() {
1917 let source = DependencySource::Git {
1918 url: "https://github.com/user/repo".into(),
1919 rev: None,
1920 };
1921
1922 match source {
1923 DependencySource::Git { url, rev } => {
1924 assert_eq!(url, "https://github.com/user/repo");
1925 assert!(rev.is_none());
1926 }
1927 _ => panic!("Expected Git source"),
1928 }
1929 }
1930
1931 #[test]
1932 fn test_dependency_source_path() {
1933 let source = DependencySource::Path {
1934 path: "../local-crate".into(),
1935 };
1936
1937 assert!(!source.is_registry());
1938
1939 match source {
1940 DependencySource::Path { path } => {
1941 assert_eq!(path, "../local-crate");
1942 }
1943 _ => panic!("Expected Path source"),
1944 }
1945 }
1946
1947 #[test]
1948 fn test_dependency_source_url() {
1949 let source = DependencySource::Url {
1950 url: "https://example.com/package.whl".into(),
1951 };
1952 assert!(!source.is_registry());
1953 assert!(!source.is_version_resolvable());
1954 }
1955
1956 #[test]
1957 fn test_dependency_source_sdk() {
1958 let source = DependencySource::Sdk {
1959 sdk: "flutter".into(),
1960 };
1961 assert!(!source.is_registry());
1962 }
1963
1964 #[test]
1965 fn test_dependency_source_workspace() {
1966 let source = DependencySource::Workspace;
1967 assert!(!source.is_registry());
1968 assert!(!source.is_version_resolvable());
1969 }
1970
1971 #[test]
1972 fn test_dependency_source_custom_registry() {
1973 let source = DependencySource::CustomRegistry {
1974 url: "https://gems.example.com".into(),
1975 };
1976 // `is_registry()` stays true (it does name a registry), but this LSP
1977 // has no client for a private/custom registry, so it must not be
1978 // treated as version-resolvable against the public registry (#248).
1979 assert!(source.is_registry());
1980 assert!(!source.is_version_resolvable());
1981 }
1982
1983 #[test]
1984 fn test_dependency_source_debug_redacts_custom_registry_userinfo() {
1985 let source = DependencySource::CustomRegistry {
1986 url: "https://user:hunter2@gems.example.com/simple".into(),
1987 };
1988 let debug_output = format!("{source:?}");
1989 assert!(!debug_output.contains("hunter2"), "{debug_output}");
1990 assert!(debug_output.contains("gems.example.com"), "{debug_output}");
1991 assert!(debug_output.contains("/simple"), "{debug_output}");
1992 }
1993
1994 #[test]
1995 fn test_dependency_source_debug_redacts_alternate_registry_query_string() {
1996 let source = DependencySource::AlternateRegistry {
1997 index: "https://index.mycorp.dev/api?api_key=SECRET".into(),
1998 mirrors_crates_io: false,
1999 };
2000 let debug_output = format!("{source:?}");
2001 assert!(!debug_output.contains("SECRET"), "{debug_output}");
2002 assert!(debug_output.contains("index.mycorp.dev"), "{debug_output}");
2003 assert!(debug_output.contains("/api"), "{debug_output}");
2004 }
2005
2006 #[test]
2007 fn test_dependency_source_alternate_registry() {
2008 let source = DependencySource::AlternateRegistry {
2009 index: "https://index.mycorp.dev".into(),
2010 mirrors_crates_io: false,
2011 };
2012 // `is_registry()` is true (it is a registry, just not the default one), but
2013 // the generic `Registry` trait still can't resolve it — only an ecosystem
2014 // whose `EcosystemFormatter::can_resolve_source` override understands this
2015 // variant (e.g. `deps-cargo`'s `CargoFormatter`) can.
2016 assert!(source.is_registry());
2017 assert!(!source.is_version_resolvable());
2018 }
2019
2020 #[test]
2021 fn test_dependency_source_clone() {
2022 let source1 = DependencySource::Git {
2023 url: "https://example.com/repo".into(),
2024 rev: Some("v1.0".into()),
2025 };
2026 let source2 = source1.clone();
2027
2028 assert_eq!(source1, source2);
2029 }
2030
2031 #[test]
2032 fn test_dependency_source_equality() {
2033 let reg1 = DependencySource::Registry;
2034 let reg2 = DependencySource::Registry;
2035 assert_eq!(reg1, reg2);
2036
2037 let git1 = DependencySource::Git {
2038 url: "https://example.com".into(),
2039 rev: None,
2040 };
2041 let git2 = DependencySource::Git {
2042 url: "https://example.com".into(),
2043 rev: None,
2044 };
2045 assert_eq!(git1, git2);
2046
2047 let git3 = DependencySource::Git {
2048 url: "https://different.com".into(),
2049 rev: None,
2050 };
2051 assert_ne!(git1, git3);
2052 }
2053
2054 #[test]
2055 fn test_check_json_nesting_depth_shallow_accepted() {
2056 let content = br#"{"a":[1,2,{"b":3}],"c":"[not{real}nesting]"}"#;
2057 assert_eq!(check_json_nesting_depth(content, 4), Ok(()));
2058 }
2059
2060 #[test]
2061 fn test_check_json_nesting_depth_string_brackets_ignored() {
2062 // Brackets inside a string literal (including an escaped quote) must
2063 // never be counted as structural nesting.
2064 let content = br#"{"a":"[[[[[\"]]]]]"}"#;
2065 assert_eq!(check_json_nesting_depth(content, 1), Ok(()));
2066 }
2067
2068 #[test]
2069 fn test_check_json_nesting_depth_mixed_array_and_object_nesting() {
2070 let content = br#"[{"a":[{"b":1}]}]"#;
2071 assert_eq!(check_json_nesting_depth(content, 4), Ok(()));
2072 assert_eq!(check_json_nesting_depth(content, 3), Err(4));
2073 }
2074
2075 #[test]
2076 fn test_check_json_nesting_depth_deeply_nested_array_rejected() {
2077 // The #430 attack shape: without this guard, `serde_json::from_slice`
2078 // would still not crash — it independently halts at its own default
2079 // recursion limit (128) with a clean `Err`. This guard rejects the
2080 // same shape earlier, at a stricter depth (64), with a repo-specific
2081 // `check_json_nesting_depth` error rather than a `serde_json::Error`.
2082 let deeply_nested = format!("{}1{}", "[".repeat(10), "]".repeat(10));
2083 assert_eq!(
2084 check_json_nesting_depth(deeply_nested.as_bytes(), 4),
2085 Err(5)
2086 );
2087 }
2088
2089 #[test]
2090 fn test_check_json_nesting_depth_unterminated_string_blinds_scanner_but_serde_json_still_rejects()
2091 {
2092 // An unterminated `"` makes the scanner treat everything after it as
2093 // string content, so it returns `Ok` even with deep nesting past the
2094 // quote. This is safe only because `serde_json` independently halts
2095 // on the same malformed input via its own tokenizer.
2096 let payload = format!("\"unterminated{}1", "[".repeat(MAX_JSON_NESTING_DEPTH + 1));
2097 assert_eq!(
2098 check_json_nesting_depth(payload.as_bytes(), MAX_JSON_NESTING_DEPTH),
2099 Ok(())
2100 );
2101 assert!(serde_json::from_str::<serde_json::Value>(&payload).is_err());
2102 }
2103
2104 #[test]
2105 fn test_check_json_nesting_depth_at_production_boundary() {
2106 let depth = MAX_JSON_NESTING_DEPTH;
2107 let at_max = format!("{}1{}", "[".repeat(depth), "]".repeat(depth));
2108 assert_eq!(check_json_nesting_depth(at_max.as_bytes(), depth), Ok(()));
2109
2110 let over_max = format!("{}1{}", "[".repeat(depth + 1), "]".repeat(depth + 1));
2111 assert_eq!(
2112 check_json_nesting_depth(over_max.as_bytes(), depth),
2113 Err(depth + 1)
2114 );
2115 }
2116
2117 #[test]
2118 fn test_dependency_source_debug() {
2119 let source = DependencySource::Registry;
2120 let debug = format!("{:?}", source);
2121 assert_eq!(debug, "Registry");
2122
2123 let git = DependencySource::Git {
2124 url: "https://example.com".into(),
2125 rev: Some("main".into()),
2126 };
2127 let git_debug = format!("{:?}", git);
2128 assert!(git_debug.contains("https://example.com"));
2129 assert!(git_debug.contains("main"));
2130 }
2131
2132 #[test]
2133 fn test_loading_state_default() {
2134 assert_eq!(LoadingState::default(), LoadingState::Idle);
2135 }
2136
2137 #[test]
2138 fn test_loading_state_copy() {
2139 let state = LoadingState::Loading;
2140 let copied = state;
2141 assert_eq!(state, copied);
2142 }
2143
2144 #[test]
2145 fn test_loading_state_debug() {
2146 let debug_str = format!("{:?}", LoadingState::Loading);
2147 assert_eq!(debug_str, "Loading");
2148 }
2149
2150 #[test]
2151 fn test_loading_state_all_variants() {
2152 let variants = [
2153 LoadingState::Idle,
2154 LoadingState::Loading,
2155 LoadingState::Loaded,
2156 LoadingState::Failed,
2157 ];
2158 for (i, v1) in variants.iter().enumerate() {
2159 for (j, v2) in variants.iter().enumerate() {
2160 if i == j {
2161 assert_eq!(v1, v2);
2162 } else {
2163 assert_ne!(v1, v2);
2164 }
2165 }
2166 }
2167 }
2168
2169 // #673: property tests for the depth/expansion checkers shared by all 14 ecosystem
2170 // crates — complements the `fuzz/` corpus (which explores much longer runs but isn't
2171 // part of `cargo test`) with a fast, CI-covered "never panics" gate over arbitrary
2172 // input, not just the hand-picked cases above.
2173 mod proptests {
2174 use super::*;
2175 use proptest::prelude::*;
2176
2177 proptest! {
2178 // #673 S3: generates `String` directly rather than random `Vec<u8>` gated on
2179 // `str::from_utf8`, which almost never reaches the function under test. Proptest's
2180 // default `\PC*` excludes control chars including `\n`, which would skip the
2181 // newline-only paths (`dot_frames[0]` reset, YAML block-style scanning) — `\n`/`\t`
2182 // are added back into the character class to keep those paths in play.
2183 #[test]
2184 fn check_toml_nesting_depth_never_panics(text in "(?s)[\\PC\\n\\t]{0,256}") {
2185 let _ = check_toml_nesting_depth(&text, MAX_TOML_NESTING_DEPTH);
2186 }
2187
2188 #[test]
2189 fn check_yaml_nesting_depth_never_panics(text in "(?s)[\\PC\\n\\t]{0,256}") {
2190 let _ = check_yaml_nesting_depth(&text, MAX_YAML_NESTING_DEPTH);
2191 }
2192
2193 #[test]
2194 fn check_yaml_expansion_never_panics(text in any::<String>()) {
2195 let _ = check_yaml_expansion(&text, MAX_YAML_EXPANDED_BYTES);
2196 }
2197
2198 #[test]
2199 fn check_json_nesting_depth_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
2200 let _ = check_json_nesting_depth(&bytes, MAX_JSON_NESTING_DEPTH);
2201 }
2202
2203 #[test]
2204 fn parse_json_checked_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
2205 let _ = parse_json_checked::<serde_json::Value>(&bytes);
2206 }
2207 }
2208 }
2209}