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}