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