Skip to main content

deps_core/
parser.rs

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