Skip to main content

stellar_agent_toolsets/
parse.rs

1//! `TOOLSET.md` parse pipeline.
2//!
3//! The public entry point is [`parse_toolset`].  It reads `TOOLSET.md` from the given
4//! toolset directory, splits the YAML frontmatter, drives the bounded iterative-event
5//! parse, validates all fields, and returns a typed [`Toolset`].
6//!
7//! ## Parse pipeline
8//!
9//! 1. Read `TOOLSET.md` (256 KiB size cap before parse).
10//! 2. UTF-8 decode (non-UTF-8 → `NotUtf8`; never a panic).
11//! 3. Split the leading `---`-fenced frontmatter.
12//! 4. Drive the yaml-rust2 ITERATIVE event pull loop over the frontmatter string.
13//!    - Each event is pulled one at a time via `Parser::next_token()` — the
14//!      parser's state machine uses an explicit heap stack; there is no C-stack
15//!      recursion on the pull path (only `Parser::load` is C-stack recursive;
16//!      we do not call it).
17//!    - Reject YAML anchors/aliases at the event level (pre-expansion).
18//!    - Track nesting depth (both BLOCK and FLOW); exceed 8 → `FrontmatterTooDeep`.
19//!      Because we stop pulling events the instant depth > MAX_DEPTH, C-stack depth
20//!      stays O(1) for any nesting; scanner heap is bounded by the 256 KiB file cap
21//!      (a single-line flow document may buffer up to the capped input as tokens —
22//!      finite, no OOM/overflow — before the depth bound fires).
23//!    - Track keys at each mapping level; duplicate → `DuplicateKey`.
24//! 5. Map the event stream to a `Frontmatter` struct.
25//! 6. Validate `name` / `description` / `compatibility`.
26//! 7. Extract and parse the capability manifest.
27//! 8. Return `Toolset` or the first `ToolsetFormatError`.
28
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31
32use yaml_rust2::parser::{Event, Parser};
33use yaml_rust2::scanner::ScanError;
34
35use crate::capability::{
36    CAPABILITY_KEY, RESERVED_PREFIX, is_valid_token_char, parse_capability_value,
37};
38use crate::{CapabilitySet, ToolsetFormatError};
39
40/// Maximum `TOOLSET.md` file size in bytes before parse (256 KiB).
41const MAX_FILE_BYTES: u64 = 256 * 1024;
42
43/// Maximum YAML frontmatter nesting depth (both BLOCK and FLOW styles).
44///
45/// Because the iterative pull loop stops pulling after depth > MAX_DEPTH, the
46/// C-stack depth for `Parser::next_token` is O(1) regardless of input nesting.
47/// Scanner heap is bounded by the 256 KiB file cap (a single-line flow document
48/// may buffer tokens up to the capped input size before the depth bound fires —
49/// finite, no OOM or stack overflow).
50const MAX_DEPTH: usize = 8;
51
52/// Maximum length for the `name` field.
53const NAME_MAX_LEN: usize = 64;
54
55/// Maximum length for the `description` field.
56const DESC_MAX_LEN: usize = 1024;
57
58/// Maximum length for the `compatibility` field.
59const COMPAT_MAX_LEN: usize = 500;
60
61// ── Public types ─────────────────────────────────────────────────────────────
62
63/// A parsed and validated toolset.
64///
65/// Produced by [`parse_toolset`] when `TOOLSET.md` passes all format and validation
66/// rules.
67///
68/// `#[non_exhaustive]` so that future releases can add fields (e.g. publisher
69/// hash, install path, attestation token) without a breaking change to downstream
70/// consumers that destructure the struct.
71///
72/// ## Note on `metadata`
73///
74/// The full `metadata` map is retained VERBATIM, including the
75/// `stellar-agent-capabilities` key.  The [`Toolset::capabilities`] field is the
76/// typed view derived from it; future consumers may read other metadata keys
77/// directly.
78#[non_exhaustive]
79#[derive(Clone, Debug)]
80pub struct Toolset {
81    /// The toolset name.  ASCII `[a-z0-9-]`, 1–64 chars.
82    pub name: String,
83
84    /// Human-readable description of what the toolset does and when to use it.
85    pub description: String,
86
87    /// SPDX license identifier or reference to a bundled license file.
88    pub license: Option<String>,
89
90    /// Environment requirements (intended product, required packages, etc.).
91    pub compatibility: Option<String>,
92
93    /// Verbatim `metadata` map from the frontmatter (string → string).
94    ///
95    /// Includes the `stellar-agent-capabilities` key if present.
96    pub metadata: HashMap<String, String>,
97
98    /// Whitespace-tokenised list of pre-approved tools from `allowed-tools`.
99    ///
100    /// Captured verbatim; runtime enforcement is performed by the capability
101    /// enforcement layer.
102    pub allowed_tools: Vec<String>,
103
104    /// Typed capability set parsed from `stellar-agent-capabilities`.
105    ///
106    /// Empty if the key is absent or the value is empty / whitespace-only.
107    pub capabilities: CapabilitySet,
108
109    /// Markdown body after the frontmatter fence.
110    ///
111    /// Contains the toolset instructions.
112    pub instructions: String,
113}
114
115// ── Public API ────────────────────────────────────────────────────────────────
116
117/// Parse and validate a toolset directory.
118///
119/// `dir` must be the path to a toolset DIRECTORY (not the `TOOLSET.md` file itself).
120/// The directory name is used for the `name == directory-name` check.
121///
122/// ## Parse pipeline
123///
124/// 1. Read `TOOLSET.md` from `dir` (256 KiB size cap, UTF-8 only).
125/// 2. Split the leading `---` YAML frontmatter.
126/// 3. Parse the frontmatter with an ITERATIVE event pull loop (`Parser::next_token`
127///    — no C-stack recursion; the parser's state machine uses an explicit heap
128///    stack internally).  Anchors, both BLOCK and FLOW nesting depth, and duplicate
129///    keys are all checked at the event level.
130/// 4. Validate `name`, `description`, `compatibility`.
131/// 5. Parse the capability manifest from `stellar-agent-capabilities`.
132/// 6. Return [`Toolset`] or the first [`ToolsetFormatError`].
133///
134/// ## Security properties
135///
136/// - The parser operates on fully-adversarial bytes: no reliance on signature
137///   or hash verification running first.
138/// - YAML anchors/aliases are rejected at the event level before any tree is
139///   materialised (billion-laughs defence).
140/// - Nesting depth is bounded at 8 for BOTH BLOCK and FLOW styles via the
141///   iterative pull loop: the loop stops pulling events the instant depth exceeds
142///   MAX_DEPTH.  Because the underlying `Parser::next_token` state machine is
143///   iterative (explicit heap stack, no C-stack recursion), this bound prevents
144///   stack overflow for both compact block-sequence `- - - …` chains and deeply
145///   nested flow documents `{a:{a:…}}` / `[[[[…`.
146/// - Duplicate mapping keys are refused (viewer-vs-parser confusion defence).
147/// - The `sign-transaction` capability token is always refused.
148///
149/// # Errors
150///
151/// Returns the first [`ToolsetFormatError`] encountered.  See the error variant
152/// documentation for the triggering conditions.
153///
154/// # Examples
155///
156/// ```
157/// use stellar_agent_toolsets::parse_toolset;
158/// use std::path::Path;
159///
160/// // The path must point to a directory whose name matches the toolset's `name`
161/// // field and which contains a valid `TOOLSET.md`.
162/// let dir = Path::new("tests/fixtures/valid-minimal/read-balance");
163/// match parse_toolset(dir) {
164///     Ok(toolset) => println!("parsed toolset: {}", toolset.name),
165///     Err(e) => eprintln!("parse error: {e}"),
166/// }
167/// ```
168pub fn parse_toolset(dir: &Path) -> Result<Toolset, ToolsetFormatError> {
169    // Extract the directory name for the `name == dir-name` check.
170    let dir_name = dir
171        .file_name()
172        .and_then(|n| n.to_str())
173        .unwrap_or("")
174        .to_owned();
175
176    // Step 1: size-cap then read.
177    let toolset_md_path = dir.join("TOOLSET.md");
178    let raw_bytes = read_size_capped(&toolset_md_path)?;
179
180    // Step 2: UTF-8 decode.
181    let content = std::str::from_utf8(&raw_bytes).map_err(|_| ToolsetFormatError::NotUtf8)?;
182
183    // Step 3: split frontmatter from body.
184    let (frontmatter_yaml, body) = split_frontmatter(content)?;
185
186    // Steps 4-5: iterative event-based parse.
187    let fm = parse_frontmatter(frontmatter_yaml)?;
188
189    // Step 6: field validation.
190    validate_name(&fm.name, &dir_name)?;
191    validate_description(&fm.description)?;
192    if let Some(ref compat) = fm.compatibility {
193        validate_compatibility(compat)?;
194    }
195
196    // Step 7: capability manifest.
197    let capabilities = extract_capabilities(&fm.metadata)?;
198
199    Ok(Toolset {
200        name: fm.name.unwrap_or_default(),
201        description: fm.description.unwrap_or_default(),
202        license: fm.license,
203        compatibility: fm.compatibility,
204        metadata: fm.metadata,
205        allowed_tools: fm.allowed_tools,
206        capabilities,
207        instructions: body.to_owned(),
208    })
209}
210
211// ── Internal types ────────────────────────────────────────────────────────────
212
213/// Raw, unvalidated frontmatter fields after the event parse step.
214#[derive(Debug, Default)]
215struct Frontmatter {
216    name: Option<String>,
217    description: Option<String>,
218    license: Option<String>,
219    compatibility: Option<String>,
220    metadata: HashMap<String, String>,
221    allowed_tools: Vec<String>,
222}
223
224// ── I/O helpers ───────────────────────────────────────────────────────────────
225
226/// Read a file after checking the size cap.
227///
228/// Returns `Err(ToolsetFileTooLarge)` if the file exceeds `MAX_FILE_BYTES`, or
229/// `Err(Io)` on any other I/O failure.
230fn read_size_capped(path: &Path) -> Result<Vec<u8>, ToolsetFormatError> {
231    use std::io::Read;
232
233    let file = std::fs::File::open(path).map_err(|e| ToolsetFormatError::Io {
234        detail: e.to_string(),
235    })?;
236
237    let metadata = file.metadata().map_err(|e| ToolsetFormatError::Io {
238        detail: e.to_string(),
239    })?;
240
241    let file_size = metadata.len();
242    if file_size > MAX_FILE_BYTES {
243        return Err(ToolsetFormatError::ToolsetFileTooLarge {
244            size: file_size,
245            cap: MAX_FILE_BYTES,
246        });
247    }
248
249    // Read at most MAX_FILE_BYTES + 1 bytes.  The +1 detects files that grew
250    // between the metadata call and the read (TOCTOU window narrowed by
251    // the read-limit, not eliminated — we still catch and refuse).
252    let limit = usize::try_from(MAX_FILE_BYTES).unwrap_or(usize::MAX) + 1;
253    let mut buf = Vec::with_capacity(usize::try_from(file_size).unwrap_or(0).min(limit));
254    file.take(MAX_FILE_BYTES + 1)
255        .read_to_end(&mut buf)
256        .map_err(|e| ToolsetFormatError::Io {
257            detail: e.to_string(),
258        })?;
259
260    if buf.len() > usize::try_from(MAX_FILE_BYTES).unwrap_or(usize::MAX) {
261        return Err(ToolsetFormatError::ToolsetFileTooLarge {
262            size: u64::try_from(buf.len()).unwrap_or(u64::MAX),
263            cap: MAX_FILE_BYTES,
264        });
265    }
266
267    Ok(buf)
268}
269
270// ── Frontmatter splitting ─────────────────────────────────────────────────────
271
272/// Split the `---`-fenced YAML frontmatter from the Markdown body.
273///
274/// Returns `(frontmatter_yaml, body_after_closing_fence)` or
275/// `Err(MissingFrontmatter)` if the file does not begin with `---\n` (or `---\r\n`).
276///
277/// The frontmatter is the content between the opening `---` and the closing
278/// `---` (or end of file if no closing fence is present — the agentskills format
279/// does not require a closing fence).
280fn split_frontmatter(content: &str) -> Result<(&str, &str), ToolsetFormatError> {
281    // The file must begin with "---" followed by a newline (LF or CRLF).
282    let after_open = content
283        .strip_prefix("---\n")
284        .or_else(|| content.strip_prefix("---\r\n"))
285        .ok_or(ToolsetFormatError::MissingFrontmatter)?;
286
287    // Find the closing "---" fence.  It must be at the start of a line.
288    if let Some(close_pos) = find_closing_fence(after_open) {
289        let frontmatter = &after_open[..close_pos];
290        let rest = &after_open[close_pos..];
291        // Skip the closing "---" line.
292        let body = rest
293            .strip_prefix("---\n")
294            .or_else(|| rest.strip_prefix("---\r\n"))
295            .or_else(|| rest.strip_prefix("---"))
296            .unwrap_or(rest);
297        Ok((frontmatter, body))
298    } else {
299        // No closing fence — entire remainder is frontmatter, body is empty.
300        Ok((after_open, ""))
301    }
302}
303
304/// Find the byte offset of the closing `---` line within `s`.
305///
306/// The closing fence must be EXACTLY the three characters `---` on their own
307/// line with no leading or trailing content (e.g. `---extra` is NOT a fence).
308///
309/// Returns `None` if no closing fence is found.
310fn find_closing_fence(s: &str) -> Option<usize> {
311    let mut offset = 0;
312    for line in s.lines() {
313        // `lines()` does not include line terminators, so we check the raw bytes.
314        let line_start = offset;
315        let line_bytes = line.len();
316
317        if line == "---" {
318            return Some(line_start);
319        }
320
321        // Advance past the line + its terminator.
322        // lines() strips \n and \r\n; advance by the line length plus 1 or 2.
323        offset += line_bytes;
324        // Check whether the line was followed by \r\n or just \n.
325        if s.as_bytes().get(offset) == Some(&b'\r') {
326            offset += 1; // skip \r
327        }
328        if s.as_bytes().get(offset) == Some(&b'\n') {
329            offset += 1; // skip \n
330        }
331    }
332    None
333}
334
335// ── YAML iterative-event parse ────────────────────────────────────────────────
336
337/// Parse the YAML frontmatter string using the yaml-rust2 ITERATIVE event API.
338///
339/// ## Why iterative and not `Parser::load`
340///
341/// `Parser::load` drives `load_document` → `load_node` → `load_mapping` /
342/// `load_sequence` which are mutually recursive on the C-stack, one frame per
343/// nesting level, with no internal depth limit for BLOCK-style nesting.
344/// `MarkedEventReceiver::on_event` returns `()` — there is no way for the
345/// receiver to abort the recursion.  Under the 256 KiB cap a compact block-
346/// sequence chain (`- - - - …`) or a nested block-mapping chain can reach ~60 000
347/// levels (~120 KB of `- ` prefixes), causing a stack overflow before the depth
348/// check in the receiver ever fires.
349///
350/// `Parser::next_token()` is a public iterative pull API.  The parser's state
351/// machine (`state` + `states: Vec<State>`) is an explicit heap stack —
352/// `next_token` has O(1) C-stack depth regardless of YAML nesting depth.
353/// By pulling one event at a time and stopping the moment depth > MAX_DEPTH, we
354/// ensure the C-stack never grows past a constant bound for ANY nesting style
355/// (BLOCK or FLOW) or ANY input.
356///
357/// The `FrontmatterReceiver` state machine and all security checks (anchor/alias
358/// rejection, depth bounding, duplicate-key detection) are identical to the former
359/// push-receiver design; this function is the thin adapter between the pull loop
360/// and those checks.
361///
362/// # Errors
363///
364/// - [`ToolsetFormatError::FrontmatterTooDeep`] — nesting depth > MAX_DEPTH (8).
365/// - [`ToolsetFormatError::YamlAnchorsForbidden`] — alias or anchored node event.
366/// - [`ToolsetFormatError::DuplicateKey`] — a key appears twice in any mapping.
367/// - [`ToolsetFormatError::MalformedFrontmatter`] — syntactically invalid YAML.
368fn parse_frontmatter(yaml_str: &str) -> Result<Frontmatter, ToolsetFormatError> {
369    let mut receiver = FrontmatterReceiver::new();
370    let mut parser = Parser::new_from_str(yaml_str);
371
372    loop {
373        // Pull one event at a time — O(1) C-stack depth per call.
374        let (ev, _) = parser.next_token().map_err(|e: ScanError| {
375            ToolsetFormatError::MalformedFrontmatter {
376                detail: e.to_string(),
377            }
378        })?;
379
380        // Capture the stream-end test before moving the event into the receiver,
381        // so the event need not be cloned (it carries owned Strings for Scalars).
382        let is_stream_end = ev == Event::StreamEnd;
383
384        // Process the event through the receiver state machine.
385        receiver.process_event(ev);
386
387        // Propagate any error the receiver accumulated (anchor, depth, duplicate).
388        if let Some(err) = receiver.error {
389            return Err(err);
390        }
391
392        // Stop when the stream ends.
393        if is_stream_end {
394            break;
395        }
396    }
397
398    Ok(receiver.frontmatter)
399}
400
401// ── Event receiver ────────────────────────────────────────────────────────────
402
403/// State in the event-driven frontmatter builder.
404#[derive(Debug)]
405enum ReceiverState {
406    /// Awaiting the document start.
407    Init,
408    /// Inside the top-level mapping; `current_key` is `None` (expecting a key)
409    /// or `Some(key_name)` (expecting the value for that key).
410    TopLevelMapping { current_key: Option<String> },
411    /// Inside the `metadata` sub-mapping.
412    MetadataMapping { current_key: Option<String> },
413    /// Skipping an unknown structured value (mapping or sequence) whose value is
414    /// not the recognised `metadata` sub-mapping.
415    ///
416    /// `return_depth` is the depth at which the skip ends and the top-level
417    /// mapping state is resumed with `current_key: None` (the key has been
418    /// consumed; we now expect the next key in the parent mapping).
419    ///
420    /// Metadata-valued keys are rejected upstream (structured values inside the
421    /// `metadata` mapping produce `MalformedFrontmatter` or
422    /// `CapabilityManifestMalformed` before this state is ever entered), so this
423    /// state is ONLY entered from `TopLevelMapping` and ALWAYS resumes
424    /// `TopLevelMapping` on exit.
425    ///
426    /// Nesting depth bookkeeping and anchor/alias rejection continue globally
427    /// (handled in the pre-dispatch block) so that alias bombs or over-deep
428    /// structures inside a skipped subtree are still refused.
429    SkippingUnknown {
430        /// The depth to which we return after skipping.
431        return_depth: usize,
432    },
433    /// Done — document end has been received.
434    Done,
435}
436
437/// Event receiver that builds a [`Frontmatter`] from the yaml-rust2 event stream.
438struct FrontmatterReceiver {
439    frontmatter: Frontmatter,
440    state: ReceiverState,
441    /// Depth of the current nesting (counts MappingStart/SequenceStart minus
442    /// MappingEnd/SequenceEnd).  Tracked for BOTH BLOCK and FLOW nesting.
443    depth: usize,
444    /// Keys seen at the TOP LEVEL mapping (for duplicate-key detection).
445    top_level_seen_keys: HashSet<String>,
446    /// Keys seen inside the `metadata` mapping (for duplicate-key detection).
447    metadata_seen_keys: HashSet<String>,
448    /// Error accumulated during event processing; checked after each event.
449    error: Option<ToolsetFormatError>,
450}
451
452impl FrontmatterReceiver {
453    fn new() -> Self {
454        Self {
455            frontmatter: Frontmatter::default(),
456            state: ReceiverState::Init,
457            depth: 0,
458            top_level_seen_keys: HashSet::new(),
459            metadata_seen_keys: HashSet::new(),
460            error: None,
461        }
462    }
463
464    /// Record an error and transition to `Done` so subsequent events are ignored.
465    fn set_error(&mut self, err: ToolsetFormatError) {
466        if self.error.is_none() {
467            self.error = Some(err);
468        }
469        self.state = ReceiverState::Done;
470    }
471
472    /// Process a single YAML event.
473    #[allow(
474        clippy::too_many_lines,
475        reason = "single large match over YAML event types"
476    )]
477    fn process_event(&mut self, ev: Event) {
478        // If we already have an error, ignore all further events.
479        if self.error.is_some() {
480            return;
481        }
482
483        match &ev {
484            // ── Alias: refuse immediately, pre-expansion ──────────────────────
485            Event::Alias(_) => {
486                self.set_error(ToolsetFormatError::YamlAnchorsForbidden);
487                return;
488            }
489
490            // ── Depth tracking ────────────────────────────────────────────────
491            Event::MappingStart(anchor_id, _) | Event::SequenceStart(anchor_id, _) => {
492                // Anchors on structures are also forbidden.
493                if *anchor_id != 0 {
494                    self.set_error(ToolsetFormatError::YamlAnchorsForbidden);
495                    return;
496                }
497                self.depth += 1;
498                if self.depth > MAX_DEPTH {
499                    self.set_error(ToolsetFormatError::FrontmatterTooDeep);
500                    return;
501                }
502            }
503            Event::MappingEnd | Event::SequenceEnd => {
504                // Saturating sub to avoid underflow on malformed input (parser
505                // should never emit more Ends than Starts, but be defensive).
506                self.depth = self.depth.saturating_sub(1);
507            }
508
509            // Anchored scalars are also forbidden.
510            Event::Scalar(_, _, anchor_id, _) if *anchor_id != 0 => {
511                self.set_error(ToolsetFormatError::YamlAnchorsForbidden);
512                return;
513            }
514
515            _ => {}
516        }
517
518        // ── State machine ─────────────────────────────────────────────────────
519        match &self.state {
520            ReceiverState::Done => {}
521
522            // ── SkippingUnknown ───────────────────────────────────────────────
523            //
524            // We are inside a structured value (mapping or sequence) whose top-
525            // level key was not a recognised key.  All events inside the subtree
526            // are discarded.  Depth tracking and anchor/alias rejection continue
527            // globally (handled above, before this dispatch), so an alias bomb or
528            // an over-deep structure INSIDE the skipped subtree is still refused.
529            //
530            // We resume the parent mapping state when `self.depth` returns to
531            // `return_depth`.  The MappingEnd / SequenceEnd that brings the depth
532            // back was already decremented above; we check the post-decrement depth
533            // here.
534            ReceiverState::SkippingUnknown { return_depth } => {
535                // Copy before any mutation to avoid holding the borrow of
536                // `self.state` through the `self.state = ...` assignment.
537                let return_depth = *return_depth;
538                // Only end events can terminate the skip.
539                match ev {
540                    // `self.depth` was already decremented by the global pre-dispatch
541                    // block.  When we are back at `return_depth`, the skipped subtree
542                    // has closed — resume the top-level mapping state.
543                    Event::MappingEnd | Event::SequenceEnd if self.depth == return_depth => {
544                        self.state = ReceiverState::TopLevelMapping { current_key: None };
545                    }
546                    // All other events (an end still inside the subtree, Scalar, inner
547                    // MappingStart/SequenceStart, stream framing) are discarded while
548                    // skipping.
549                    _ => {}
550                }
551            }
552
553            ReceiverState::Init => match ev {
554                Event::StreamStart
555                | Event::DocumentStart
556                | Event::DocumentEnd
557                | Event::StreamEnd => {
558                    // No state transition needed for these framing events.
559                }
560                Event::MappingStart(_, _) => {
561                    self.state = ReceiverState::TopLevelMapping { current_key: None };
562                }
563                _ => {
564                    // The frontmatter must be a top-level mapping, not a scalar
565                    // or sequence.
566                    self.set_error(ToolsetFormatError::MalformedFrontmatter {
567                        detail: "frontmatter must be a YAML mapping".to_owned(),
568                    });
569                }
570            },
571
572            ReceiverState::TopLevelMapping { current_key } => {
573                match ev {
574                    Event::MappingEnd => {
575                        self.state = ReceiverState::Done;
576                    }
577
578                    Event::Scalar(value, _, _, _) if current_key.is_none() => {
579                        // This scalar is a KEY in the top-level mapping.
580                        let key = value;
581
582                        // Duplicate-key detection.
583                        if !self.top_level_seen_keys.insert(key.clone()) {
584                            self.set_error(ToolsetFormatError::DuplicateKey { key });
585                            return;
586                        }
587
588                        self.state = ReceiverState::TopLevelMapping {
589                            current_key: Some(key),
590                        };
591                    }
592
593                    Event::Scalar(value, _, _, _) => {
594                        // This scalar is a VALUE for the current key.
595                        // SAFETY: `current_key` is `Some` in this arm (the
596                        // guard `if current_key.is_none()` selected the prior arm).
597                        let key = match current_key.as_deref() {
598                            Some(k) => k,
599                            None => {
600                                // Parser emitted a value scalar with no preceding key
601                                // scalar — the YAML is structurally malformed.
602                                self.set_error(ToolsetFormatError::MalformedFrontmatter {
603                                    detail: "unexpected scalar value without a key".to_owned(),
604                                });
605                                return;
606                            }
607                        };
608                        let val = value;
609
610                        match key {
611                            "name" => self.frontmatter.name = Some(val),
612                            "description" => self.frontmatter.description = Some(val),
613                            "license" => self.frontmatter.license = Some(val),
614                            "compatibility" => self.frontmatter.compatibility = Some(val),
615                            "allowed-tools" => {
616                                self.frontmatter.allowed_tools =
617                                    val.split_ascii_whitespace().map(str::to_owned).collect();
618                            }
619                            // Unknown top-level scalar keys are tolerated
620                            // (forward-compat per the agentskills format spec).
621                            _other => {}
622                        }
623
624                        self.state = ReceiverState::TopLevelMapping { current_key: None };
625                    }
626
627                    Event::MappingStart(_, _) if current_key.as_deref() == Some("metadata") => {
628                        // Entering the `metadata` sub-mapping.
629                        self.state = ReceiverState::MetadataMapping { current_key: None };
630                    }
631
632                    // A non-metadata mapping or sequence value for any other top-
633                    // level key.  Tolerated as an unknown forward-compat structured
634                    // value per the agentskills format spec's forward-compat intent.
635                    //
636                    // We transition to `SkippingUnknown` so that:
637                    //   (a) inner scalar events are NOT inserted into
638                    //       `top_level_seen_keys` (false DuplicateKey prevention);
639                    //   (b) inner MappingEnd/SequenceEnd events do NOT prematurely
640                    //       terminate the top-level mapping (silent key-drop prevention).
641                    //
642                    // `return_depth` is the current depth AFTER the global pre-dispatch
643                    // block already incremented it for this MappingStart/SequenceStart.
644                    // When `self.depth` returns to that value — via the matching end
645                    // event — we resume `TopLevelMapping`.
646                    Event::MappingStart(_, _) | Event::SequenceStart(_, _) => {
647                        self.state = ReceiverState::SkippingUnknown {
648                            return_depth: self.depth - 1,
649                        };
650                    }
651
652                    _ => {
653                        // StreamEnd etc. — ignore.
654                    }
655                }
656            }
657
658            ReceiverState::MetadataMapping { current_key } => {
659                match ev {
660                    Event::MappingEnd => {
661                        // Back to the top-level mapping (key was already consumed).
662                        self.state = ReceiverState::TopLevelMapping { current_key: None };
663                        // Depth was decremented above.
664                    }
665
666                    Event::Scalar(value, _, _, _) if current_key.is_none() => {
667                        // Metadata KEY.
668                        let key = value;
669
670                        // Duplicate-key detection within metadata.
671                        if !self.metadata_seen_keys.insert(key.clone()) {
672                            self.set_error(ToolsetFormatError::DuplicateKey { key });
673                            return;
674                        }
675
676                        // Reserved-prefix check.
677                        if key.starts_with(RESERVED_PREFIX) && key != CAPABILITY_KEY {
678                            self.set_error(ToolsetFormatError::ReservedMetadataKey { key });
679                            return;
680                        }
681
682                        self.state = ReceiverState::MetadataMapping {
683                            current_key: Some(key),
684                        };
685                    }
686
687                    Event::Scalar(value, _, _, _) => {
688                        // Metadata VALUE.
689                        // SAFETY: `current_key` is `Some` in this arm.
690                        let key = match current_key.as_ref() {
691                            Some(k) => k.clone(),
692                            None => {
693                                // Deliberately fail loud: a metadata value scalar
694                                // with no preceding key is structurally malformed
695                                // YAML.  Using `unwrap_or_default()` here would
696                                // silently insert under an empty-string key, masking
697                                // the parse error.
698                                self.set_error(ToolsetFormatError::MalformedFrontmatter {
699                                    detail: "unexpected metadata value scalar without a key"
700                                        .to_owned(),
701                                });
702                                return;
703                            }
704                        };
705                        self.frontmatter.metadata.insert(key, value);
706                        self.state = ReceiverState::MetadataMapping { current_key: None };
707                    }
708
709                    Event::MappingStart(_, _) | Event::SequenceStart(_, _) => {
710                        // A non-string metadata value.
711                        //
712                        // The agentskills format defines `metadata` values as strings;
713                        // a YAML list or mapping is a spec violation regardless of
714                        // which metadata key carries it.
715                        let key = current_key.as_deref().unwrap_or("");
716                        let detail = if key == CAPABILITY_KEY {
717                            "stellar-agent-capabilities value must be a string, not a mapping or sequence".to_owned()
718                        } else {
719                            format!("metadata value for key '{key}' must be a string")
720                        };
721
722                        if key == CAPABILITY_KEY {
723                            self.set_error(ToolsetFormatError::CapabilityManifestMalformed {
724                                detail,
725                            });
726                        } else {
727                            self.set_error(ToolsetFormatError::MalformedFrontmatter { detail });
728                        }
729                    }
730
731                    _ => {}
732                }
733            }
734        }
735    }
736}
737
738// ── Field validators ──────────────────────────────────────────────────────────
739
740/// Validate the `name` field.
741///
742/// The length limit (64) is in Unicode scalar values (Rust `char` count), not bytes.
743///
744/// # Errors
745///
746/// - [`ToolsetFormatError::MissingName`] — field absent.
747/// - [`ToolsetFormatError::NameEmpty`] — field is empty.
748/// - [`ToolsetFormatError::NameTooLong`] — exceeds 64 Unicode scalar values.
749/// - [`ToolsetFormatError::NameInvalidChar`] — contains a char outside `[a-z0-9-]`.
750/// - [`ToolsetFormatError::NameLeadingTrailingHyphen`] — starts or ends with `-`.
751/// - [`ToolsetFormatError::NameConsecutiveHyphens`] — contains `--`.
752/// - [`ToolsetFormatError::NameDirMismatch`] — does not match the directory name.
753fn validate_name(name: &Option<String>, dir_name: &str) -> Result<(), ToolsetFormatError> {
754    let n = name.as_deref().ok_or(ToolsetFormatError::MissingName)?;
755
756    if n.is_empty() {
757        return Err(ToolsetFormatError::NameEmpty);
758    }
759
760    if n.chars().count() > NAME_MAX_LEN {
761        return Err(ToolsetFormatError::NameTooLong);
762    }
763
764    if !n.chars().all(is_valid_token_char) {
765        return Err(ToolsetFormatError::NameInvalidChar);
766    }
767
768    if n.starts_with('-') || n.ends_with('-') {
769        return Err(ToolsetFormatError::NameLeadingTrailingHyphen);
770    }
771
772    if n.contains("--") {
773        return Err(ToolsetFormatError::NameConsecutiveHyphens);
774    }
775
776    // Byte-exact comparison: name is ASCII-only (guaranteed by the charset gate
777    // above), and the dir_name may be arbitrary OS bytes.  If dir_name contains
778    // non-ASCII characters, the byte-exact comparison will fail here, which is
779    // the correct behaviour (homoglyph dir-spoof defence).
780    if n != dir_name {
781        return Err(ToolsetFormatError::NameDirMismatch {
782            name: n.to_owned(),
783            dir: dir_name.to_owned(),
784        });
785    }
786
787    Ok(())
788}
789
790/// Validate the `description` field.
791///
792/// The length limit (1024) is in Unicode scalar values (Rust `char` count), not bytes.
793///
794/// # Errors
795///
796/// - [`ToolsetFormatError::MissingDescription`] — field absent.
797/// - [`ToolsetFormatError::DescriptionEmpty`] — empty or whitespace-only.
798/// - [`ToolsetFormatError::DescriptionTooLong`] — exceeds 1024 Unicode scalar values.
799fn validate_description(desc: &Option<String>) -> Result<(), ToolsetFormatError> {
800    let d = desc
801        .as_deref()
802        .ok_or(ToolsetFormatError::MissingDescription)?;
803
804    if d.trim().is_empty() {
805        return Err(ToolsetFormatError::DescriptionEmpty);
806    }
807
808    if d.chars().count() > DESC_MAX_LEN {
809        return Err(ToolsetFormatError::DescriptionTooLong);
810    }
811
812    Ok(())
813}
814
815/// Validate the `compatibility` field.
816///
817/// The length limit (500) is in Unicode scalar values (Rust `char` count), not bytes.
818///
819/// # Errors
820///
821/// - [`ToolsetFormatError::CompatibilityTooLong`] — exceeds 500 Unicode scalar values.
822fn validate_compatibility(compat: &str) -> Result<(), ToolsetFormatError> {
823    if compat.chars().count() > COMPAT_MAX_LEN {
824        return Err(ToolsetFormatError::CompatibilityTooLong);
825    }
826    Ok(())
827}
828
829/// Extract and parse the capability manifest from the `metadata` map.
830///
831/// Non-string values for the `stellar-agent-capabilities` key are rejected
832/// earlier in the event receiver when inside the `metadata` mapping (which
833/// produces [`ToolsetFormatError::CapabilityManifestMalformed`] and transitions
834/// to the `Done` state before `extract_capabilities` is ever called).  By the
835/// time this function runs, the `metadata` map contains only validated `String`
836/// values; there is no re-check here.
837///
838/// # Errors
839///
840/// - Capability parse errors from [`parse_capability_value`]:
841///   [`ToolsetFormatError::CapabilityTokenInvalidChar`],
842///   [`ToolsetFormatError::BareSignTransactionForbidden`], or
843///   [`ToolsetFormatError::UnknownCapability`].
844fn extract_capabilities(
845    metadata: &HashMap<String, String>,
846) -> Result<CapabilitySet, ToolsetFormatError> {
847    match metadata.get(CAPABILITY_KEY) {
848        None => Ok(CapabilitySet::empty()),
849        Some(value) => parse_capability_value(value),
850    }
851}
852
853// ── Unit tests ────────────────────────────────────────────────────────────────
854
855#[cfg(test)]
856mod tests {
857    #![allow(
858        clippy::unwrap_used,
859        clippy::expect_used,
860        reason = "test-only; panics acceptable in unit tests"
861    )]
862
863    use super::*;
864
865    // ── Frontmatter splitting ─────────────────────────────────────────────────
866
867    #[test]
868    fn split_minimal_frontmatter() {
869        let content = "---\nname: foo\n---\nbody";
870        let (fm, body) = split_frontmatter(content).unwrap();
871        assert!(fm.contains("name: foo"), "fm={fm:?}");
872        assert_eq!(body, "body");
873    }
874
875    #[test]
876    fn split_no_closing_fence_body_empty() {
877        let content = "---\nname: foo\n";
878        let (fm, body) = split_frontmatter(content).unwrap();
879        assert!(fm.contains("name: foo"));
880        assert_eq!(body, "");
881    }
882
883    #[test]
884    fn split_missing_fence_error() {
885        let err = split_frontmatter("no fence here").unwrap_err();
886        assert!(matches!(err, ToolsetFormatError::MissingFrontmatter));
887    }
888
889    // ── Name validation ───────────────────────────────────────────────────────
890
891    #[test]
892    fn name_valid_simple() {
893        validate_name(&Some("my-toolset".to_owned()), "my-toolset").unwrap();
894    }
895
896    #[test]
897    fn name_missing() {
898        let err = validate_name(&None, "foo").unwrap_err();
899        assert!(matches!(err, ToolsetFormatError::MissingName));
900    }
901
902    #[test]
903    fn name_empty() {
904        let err = validate_name(&Some(String::new()), "").unwrap_err();
905        assert!(matches!(err, ToolsetFormatError::NameEmpty));
906    }
907
908    #[test]
909    fn name_too_long() {
910        let long = "a".repeat(65);
911        let err = validate_name(&Some(long.clone()), &long).unwrap_err();
912        assert!(matches!(err, ToolsetFormatError::NameTooLong));
913    }
914
915    #[test]
916    fn name_uppercase_refused() {
917        let err = validate_name(&Some("MyToolset".to_owned()), "MyToolset").unwrap_err();
918        assert!(matches!(err, ToolsetFormatError::NameInvalidChar));
919    }
920
921    #[test]
922    fn name_leading_hyphen_refused() {
923        let err = validate_name(&Some("-toolset".to_owned()), "-toolset").unwrap_err();
924        assert!(matches!(err, ToolsetFormatError::NameLeadingTrailingHyphen));
925    }
926
927    #[test]
928    fn name_trailing_hyphen_refused() {
929        let err = validate_name(&Some("toolset-".to_owned()), "toolset-").unwrap_err();
930        assert!(matches!(err, ToolsetFormatError::NameLeadingTrailingHyphen));
931    }
932
933    #[test]
934    fn name_consecutive_hyphens_refused() {
935        let err = validate_name(&Some("my--toolset".to_owned()), "my--toolset").unwrap_err();
936        assert!(matches!(err, ToolsetFormatError::NameConsecutiveHyphens));
937    }
938
939    #[test]
940    fn name_dir_mismatch_refused() {
941        let err = validate_name(&Some("my-toolset".to_owned()), "other-toolset").unwrap_err();
942        assert!(matches!(err, ToolsetFormatError::NameDirMismatch { .. }));
943    }
944
945    #[test]
946    fn name_unicode_homoglyph_dir_refused() {
947        // Directory name with Cyrillic 'і' (looks like 'i') — does not match
948        // the ASCII 'i' in the name field.
949        let cyrillic_dir = "my-sk\u{0456}ll"; // Cyrillic і
950        let err = validate_name(&Some("my-toolset".to_owned()), cyrillic_dir).unwrap_err();
951        assert!(matches!(err, ToolsetFormatError::NameDirMismatch { .. }));
952    }
953
954    // ── Description validation ────────────────────────────────────────────────
955
956    #[test]
957    fn description_valid() {
958        validate_description(&Some("A useful toolset.".to_owned())).unwrap();
959    }
960
961    #[test]
962    fn description_missing() {
963        let err = validate_description(&None).unwrap_err();
964        assert!(matches!(err, ToolsetFormatError::MissingDescription));
965    }
966
967    #[test]
968    fn description_empty() {
969        let err = validate_description(&Some(String::new())).unwrap_err();
970        assert!(matches!(err, ToolsetFormatError::DescriptionEmpty));
971    }
972
973    #[test]
974    fn description_whitespace_only() {
975        let err = validate_description(&Some("   \t\n  ".to_owned())).unwrap_err();
976        assert!(matches!(err, ToolsetFormatError::DescriptionEmpty));
977    }
978
979    #[test]
980    fn description_too_long() {
981        let long = "a".repeat(1025);
982        let err = validate_description(&Some(long)).unwrap_err();
983        assert!(matches!(err, ToolsetFormatError::DescriptionTooLong));
984    }
985
986    // ── Compatibility validation ──────────────────────────────────────────────
987
988    #[test]
989    fn compatibility_valid() {
990        validate_compatibility("Requires Python 3.14+").unwrap();
991    }
992
993    #[test]
994    fn compatibility_too_long() {
995        let long = "x".repeat(501);
996        let err = validate_compatibility(&long).unwrap_err();
997        assert!(matches!(err, ToolsetFormatError::CompatibilityTooLong));
998    }
999
1000    // ── YAML iterative event parse ────────────────────────────────────────────
1001
1002    #[test]
1003    fn alias_bomb_refused_pre_expansion() {
1004        // Classic alias-bomb prefix: define anchor &a, then expand *a many times.
1005        // We expect YamlAnchorsForbidden without OOM.
1006        let yaml = "a: &a []\nb: *a\n";
1007        let err = parse_frontmatter(yaml).unwrap_err();
1008        assert!(
1009            matches!(err, ToolsetFormatError::YamlAnchorsForbidden),
1010            "expected YamlAnchorsForbidden, got {err:?}"
1011        );
1012    }
1013
1014    #[test]
1015    fn deep_nesting_refused() {
1016        // Create nesting > 8 levels deep via block style.
1017        let yaml = "a:\n  b:\n    c:\n      d:\n        e:\n          f:\n            g:\n              h:\n                i: deep\n";
1018        let err = parse_frontmatter(yaml).unwrap_err();
1019        assert!(
1020            matches!(err, ToolsetFormatError::FrontmatterTooDeep),
1021            "expected FrontmatterTooDeep, got {err:?}"
1022        );
1023    }
1024
1025    #[test]
1026    fn duplicate_top_level_key_refused() {
1027        let yaml = "name: foo\ndescription: bar\nname: baz\n";
1028        let err = parse_frontmatter(yaml).unwrap_err();
1029        assert!(
1030            matches!(err, ToolsetFormatError::DuplicateKey { .. }),
1031            "expected DuplicateKey, got {err:?}"
1032        );
1033    }
1034
1035    #[test]
1036    fn duplicate_metadata_key_refused() {
1037        let yaml = "name: foo\ndescription: bar\nmetadata:\n  author: a\n  author: b\n";
1038        let err = parse_frontmatter(yaml).unwrap_err();
1039        assert!(
1040            matches!(err, ToolsetFormatError::DuplicateKey { .. }),
1041            "expected DuplicateKey, got {err:?}"
1042        );
1043    }
1044
1045    #[test]
1046    fn capability_non_string_refused() {
1047        // stellar-agent-capabilities: [list, value] is not a string.
1048        let yaml =
1049            "name: foo\ndescription: bar\nmetadata:\n  stellar-agent-capabilities:\n    - item\n";
1050        let err = parse_frontmatter(yaml).unwrap_err();
1051        assert!(
1052            matches!(err, ToolsetFormatError::CapabilityManifestMalformed { .. }),
1053            "expected CapabilityManifestMalformed, got {err:?}"
1054        );
1055    }
1056
1057    #[test]
1058    fn reserved_metadata_key_refused() {
1059        let yaml = "name: foo\ndescription: bar\nmetadata:\n  stellar-agent-policy: x\n";
1060        let err = parse_frontmatter(yaml).unwrap_err();
1061        assert!(
1062            matches!(err, ToolsetFormatError::ReservedMetadataKey { .. }),
1063            "expected ReservedMetadataKey, got {err:?}"
1064        );
1065    }
1066
1067    #[test]
1068    fn recognised_capability_key_not_reserved() {
1069        // stellar-agent-capabilities is the recognised exception to the reserved
1070        // prefix rule; it must NOT produce ReservedMetadataKey.
1071        let yaml =
1072            "name: foo\ndescription: bar\nmetadata:\n  stellar-agent-capabilities: read-balance\n";
1073        parse_frontmatter(yaml).unwrap();
1074    }
1075
1076    // ── Skip-state correctness ────────────────────────────────────────────────
1077    //
1078    // These unit tests directly call `parse_frontmatter` (not `parse_toolset`) to
1079    // verify the state machine in isolation without the field-validation layer.
1080
1081    /// Shared fixture: a toolset with an unknown nested mapping BEFORE the metadata
1082    /// block, with `read-balance` declared in `stellar-agent-capabilities`.
1083    const SKIP_STATE_YAML: &str = "name: test-toolset\ndescription: A test.\nextended-info:\n  name: inner\nmetadata:\n  stellar-agent-capabilities: read-balance\n";
1084
1085    /// Unknown nested mapping BEFORE the metadata block — capabilities must survive.
1086    #[test]
1087    fn skip_state_unknown_nested_map_before_metadata() {
1088        let fm = parse_frontmatter(SKIP_STATE_YAML).unwrap();
1089        assert!(
1090            fm.metadata.contains_key("stellar-agent-capabilities"),
1091            "metadata must be populated after unknown nested map: {fm:?}"
1092        );
1093    }
1094
1095    /// Unknown nested mapping — inner key matching a top-level key must NOT
1096    /// cause a false `DuplicateKey`.
1097    #[test]
1098    fn skip_state_inner_key_not_inserted_into_top_level_seen() {
1099        let result = parse_frontmatter(SKIP_STATE_YAML);
1100        assert!(
1101            !matches!(result, Err(ToolsetFormatError::DuplicateKey { .. })),
1102            "inner key must not produce false DuplicateKey: {result:?}"
1103        );
1104    }
1105
1106    /// Alias bomb inside a skipped unknown nested map — must still produce
1107    /// `YamlAnchorsForbidden`, not silently pass.
1108    #[test]
1109    fn skip_state_alias_inside_skipped_subtree_still_refused() {
1110        // An alias inside an unknown nested map must be refused at the global
1111        // pre-dispatch level (not silently skipped).
1112        let yaml = "name: test-toolset\nextended-info:\n  x: &a val\n  y: *a\n";
1113        let err = parse_frontmatter(yaml).unwrap_err();
1114        assert!(
1115            matches!(err, ToolsetFormatError::YamlAnchorsForbidden),
1116            "expected YamlAnchorsForbidden inside skipped subtree, got {err:?}"
1117        );
1118    }
1119
1120    // ── No-panic table ────────────────────────────────────────────────────────
1121    //
1122    // Each input below must return Err(_), never panic / OOM / stack-overflow.
1123
1124    #[test]
1125    fn no_panic_truncated_input() {
1126        // Abruptly truncated YAML.
1127        let yaml = "name: foo\ndescription: \"\n";
1128        let result = parse_frontmatter(yaml);
1129        // May succeed or fail depending on truncation; must not panic.
1130        let _ = result;
1131    }
1132
1133    #[test]
1134    fn no_panic_garbage_bytes_via_split() {
1135        // Pass garbage bytes through the full split path.
1136        let content = "---\n\x00\x01\x02\x03\n---\n";
1137        let result = split_frontmatter(content);
1138        // Must not panic.
1139        let _ = result;
1140    }
1141
1142    #[test]
1143    fn no_panic_empty_frontmatter() {
1144        let result = parse_frontmatter("");
1145        let _ = result;
1146    }
1147
1148    // ── Deep-nesting overflow prevention ──────────────────────────────────────
1149    //
1150    // yaml-rust2's `Parser::load` drives `load_node` / `load_mapping` /
1151    // `load_sequence` which recurse on the C-stack proportional to nesting depth
1152    // with no internal limit for BLOCK-style nesting.  The iterative pull loop
1153    // using `Parser::next_token()` has O(1) C-stack depth — it stops pulling
1154    // events the instant depth > MAX_DEPTH without ever going deeper on the stack.
1155    //
1156    // The following tests prove that BOTH compact block-sequence chains AND
1157    // deeply-nested flow documents are refused with `Err` and NO stack overflow.
1158
1159    /// Compact block-sequence chain: `- ` repeated ~60 000 times encodes ~60 000
1160    /// nesting levels at only ~2 bytes per level (~120 KB total — within the
1161    /// 256 KiB cap).  The iterative pull loop MUST return `FrontmatterTooDeep`
1162    /// (or `MalformedFrontmatter`) WITHOUT a stack overflow.
1163    ///
1164    /// This is the primary overflow vector: a flow-only pre-parse depth guard would
1165    /// count ZERO frames for this input (block nesting is invisible to it), which is
1166    /// why depth is enforced on the event stream for both block and flow styles.
1167    #[test]
1168    fn block_sequence_compact_deep_refused_no_overflow() {
1169        // Build a YAML compact block sequence chain of depth 10 000.
1170        // Each `- ` prefix on the SAME line nests one level deeper.
1171        //
1172        // Example (5-deep): `- - - - - z`
1173        // We use 10 000 levels — far above MAX_DEPTH (8) — so the depth guard
1174        // fires after 9 levels and we never recurse further.
1175        let levels = 10_000_usize;
1176        let mut yaml = String::with_capacity(levels * 2 + 4);
1177        for _ in 0..levels {
1178            yaml.push_str("- ");
1179        }
1180        yaml.push('z');
1181
1182        let err = parse_frontmatter(&yaml).unwrap_err();
1183        assert!(
1184            matches!(
1185                err,
1186                ToolsetFormatError::FrontmatterTooDeep
1187                    | ToolsetFormatError::MalformedFrontmatter { .. }
1188            ),
1189            "expected FrontmatterTooDeep or MalformedFrontmatter for compact block sequence, \
1190            got {err:?}"
1191        );
1192    }
1193
1194    /// Full `parse_toolset` path: compact block-sequence chain through the file
1195    /// read, UTF-8 decode, frontmatter split, and parse pipeline — must return
1196    /// `Err` without stack overflow.
1197    #[test]
1198    fn block_sequence_deep_refused_via_parse_toolset_no_overflow() {
1199        use std::io::Write;
1200        let tmp = tempfile::TempDir::new().unwrap();
1201        let toolset_dir = tmp.path().join("test-toolset");
1202        std::fs::create_dir_all(&toolset_dir).unwrap();
1203        let mut f = std::fs::File::create(toolset_dir.join("TOOLSET.md")).unwrap();
1204
1205        let levels = 10_000_usize;
1206        let mut chain = String::with_capacity(levels * 2 + 4);
1207        for _ in 0..levels {
1208            chain.push_str("- ");
1209        }
1210        chain.push('z');
1211        // Wrap in a valid frontmatter fence.
1212        write!(f, "---\n{chain}\n---\n").unwrap();
1213
1214        let result = parse_toolset(&toolset_dir);
1215        assert!(
1216            result.is_err(),
1217            "deeply nested compact block sequence must return Err, got Ok"
1218        );
1219    }
1220
1221    /// Compact block-mapping chain using indented mappings to achieve many nesting
1222    /// levels at linear byte cost.  Must return an error without stack overflow.
1223    #[test]
1224    fn block_mapping_compact_deep_refused_no_overflow() {
1225        // Deeply nested via indented block mappings at minimum cost.
1226        // 10 000 levels → well within 256 KiB and far above MAX_DEPTH.
1227        let levels = 10_000_usize;
1228        let mut yaml = String::with_capacity(levels * 4);
1229        for i in 0..levels {
1230            let indent = " ".repeat(i);
1231            yaml.push_str(&format!("{indent}k:\n"));
1232        }
1233        yaml.push_str(&format!("{} v", " ".repeat(levels)));
1234
1235        let result = parse_frontmatter(&yaml);
1236        assert!(
1237            result.is_err(),
1238            "deeply nested block mapping chain must return Err, got Ok"
1239        );
1240    }
1241
1242    /// Deep flow sequence: 60 000 `[` chars.  Must return `Err` without overflow.
1243    #[test]
1244    fn flow_sequence_deep_refused_no_overflow() {
1245        let deep: String = "[".repeat(60_000);
1246        let result = parse_frontmatter(&deep);
1247        assert!(
1248            result.is_err(),
1249            "deeply nested flow sequence must return Err, got Ok"
1250        );
1251    }
1252
1253    /// Deep flow mapping: 20 000 levels of `{a:`.
1254    #[test]
1255    fn flow_mapping_deep_refused_no_overflow() {
1256        let levels = 20_000_usize;
1257        let deep: String = "{a:".repeat(levels);
1258        let result = parse_frontmatter(&deep);
1259        assert!(
1260            result.is_err(),
1261            "deeply nested flow mapping must return Err, got Ok"
1262        );
1263    }
1264}