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