Skip to main content

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(&quote) && bytes.get(i + 2) == Some(&quote);
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}