Skip to main content

serde_saphyr/de/
budget.rs

1//! Streaming YAML budget checker using granit-parser.
2//!
3//! This inspects the parser's event stream and enforces simple budgets to
4//! avoid pathological inputs
5
6use super::tags::is_yaml_merge_tag;
7use crate::options::MergeKeyPolicy;
8use granit_parser::{Event, Parser, ScalarStyle, ScanError, Tag};
9use smallvec::SmallVec;
10use std::collections::HashSet;
11
12const DEFAULT_MAX_SCALAR_BYTES: usize = 64 * 1024 * 1024;
13#[cfg(feature = "comments")]
14const DEFAULT_MAX_TOTAL_COMMENT_BYTES: usize = 64 * 1024 * 1024;
15#[cfg(feature = "comments")]
16const DEFAULT_MAX_BUFFERED_COMMENT_EVENTS: usize = 32;
17const DEFAULT_SIMPLE_KEY_MAX_LOOKAHEAD: usize = 1024;
18const DEFAULT_FLOW_NESTING_LIMIT: usize = 255;
19const DEFAULT_MAX_PROPERTY_EXPANSION_DEPTH: usize = 64;
20const DEFAULT_MAX_TOTAL_PROPERTY_INTERPOLATION_WORK: usize = 256 * 1024 * 1024;
21
22#[cfg(all(feature = "serde_derived_types", feature = "comments"))]
23fn default_max_total_comment_bytes() -> usize {
24    DEFAULT_MAX_TOTAL_COMMENT_BYTES
25}
26
27#[cfg(feature = "serde_derived_types")]
28fn default_max_recorded_anchor_events() -> usize {
29    1_000_000
30}
31
32#[cfg(feature = "serde_derived_types")]
33fn default_max_recorded_anchor_bytes() -> usize {
34    64 * 1024 * 1024
35}
36
37#[cfg(all(feature = "serde_derived_types", feature = "comments"))]
38fn default_max_buffered_comment_events() -> usize {
39    DEFAULT_MAX_BUFFERED_COMMENT_EVENTS
40}
41
42#[cfg(feature = "serde_derived_types")]
43fn default_simple_key_max_lookahead() -> usize {
44    DEFAULT_SIMPLE_KEY_MAX_LOOKAHEAD
45}
46
47#[cfg(feature = "serde_derived_types")]
48fn default_flow_nesting_limit() -> usize {
49    DEFAULT_FLOW_NESTING_LIMIT
50}
51
52#[cfg(feature = "serde_derived_types")]
53fn default_max_property_expansion_depth() -> usize {
54    DEFAULT_MAX_PROPERTY_EXPANSION_DEPTH
55}
56
57#[cfg(feature = "serde_derived_types")]
58fn default_max_total_property_interpolation_work() -> usize {
59    DEFAULT_MAX_TOTAL_PROPERTY_INTERPOLATION_WORK
60}
61
62/// Resource budgets for YAML parsing and deserialization.
63///
64/// The defaults are intentionally permissive for typical configuration files
65/// while stopping obvious resource-amplifying inputs. Tune these per your
66/// application if you regularly process very large YAML streams.
67///
68/// Example: using a `Budget` with `from_str_with_options` to parse into a small
69/// `Config` struct.
70///
71/// ```rust
72/// use serde::Deserialize;
73///
74/// #[derive(Debug, Deserialize, PartialEq)]
75/// struct Config {
76///     name: String,
77///     enabled: bool,
78///     retries: i32,
79/// }
80///
81/// let yaml = r#"
82///   name: My Application
83///   enabled: true
84///   retries: 5
85/// "#;
86///
87/// let options = serde_saphyr::options! {
88///     budget: serde_saphyr::budget! {
89///         // Example override
90///         max_documents: 2,
91///     },
92/// };
93///
94/// let cfg: Config = serde_saphyr::from_str_with_options(yaml, options).unwrap();
95/// assert_eq!(cfg.retries, 5);
96/// ```
97#[non_exhaustive]
98#[derive(Clone, Debug)]
99#[cfg_attr(
100    feature = "serde_derived_types",
101    derive(serde::Serialize, serde::Deserialize)
102)]
103pub struct Budget {
104    /// Hard cap on the size of the input in bytes.
105    /// This limit only applies to reader-based input, to avoid resource exhaustion
106    /// from large malicious inputs (even when they are valid YAML). String inputs can be
107    /// checked by the caller when needed because they are already in memory.
108    /// If the limit is exceeded, `serde_saphyr::Error::IOError` is returned with a
109    /// `std::io::Error` cause using `ErrorKind::FileTooLarge`.
110    ///
111    /// If set to None, this check is not active. This may be needed when reading from
112    /// stream into iterator, where potentially infinite input may need to be supported.
113    ///
114    /// Default: 256 Mb
115    pub max_reader_input_bytes: Option<usize>,
116    /// Maximum number of consecutive comment events the parser may buffer while resolving an
117    /// ambiguous collection entry.
118    ///
119    /// A value of `0` rejects the first comment that would need buffering.
120    ///
121    /// Default: 32
122    #[cfg_attr(
123        feature = "serde_derived_types",
124        serde(default = "default_max_buffered_comment_events")
125    )]
126    #[cfg(feature = "comments")]
127    pub max_buffered_comment_events: usize,
128    /// Maximum number of characters the scanner may inspect while resolving a simple key.
129    ///
130    /// A key at exactly this limit is accepted. Values above 1,024 relax YAML's simple-key
131    /// length restriction, while lower values impose a stricter resource limit.
132    ///
133    /// Default: 1,024
134    #[cfg_attr(
135        feature = "serde_derived_types",
136        serde(default = "default_simple_key_max_lookahead")
137    )]
138    pub simple_key_max_lookahead: usize,
139    /// Maximum number of simultaneously nested flow collections.
140    ///
141    /// A value of `0` rejects the first flow collection opener.
142    ///
143    /// Default: 255
144    #[cfg_attr(
145        feature = "serde_derived_types",
146        serde(default = "default_flow_nesting_limit")
147    )]
148    pub flow_nesting_limit: usize,
149    /// Maximum total parser events (counting every event).
150    ///
151    /// Default: 1,000,000
152    pub max_events: usize,
153    /// Maximum number of alias (`*ref`) events allowed.
154    ///
155    /// Default: 50,000
156    pub max_aliases: usize,
157    /// Maximal total number of anchors (distinct `&anchor` definitions).
158    ///
159    /// Default: 50,000
160    pub max_anchors: usize,
161    /// Maximum cumulative number of event copies attempted for anchor retention.
162    ///
163    /// An event copied into multiple simultaneously open anchored containers is charged once
164    /// for every destination buffer. Scalar anchor buffers and events recorded while replaying
165    /// aliases are charged by the same limit.
166    ///
167    /// Default: `1_000_000` retained event copies.
168    #[cfg_attr(
169        feature = "serde_derived_types",
170        serde(default = "default_max_recorded_anchor_events")
171    )]
172    pub max_recorded_anchor_events: usize,
173    /// Maximum cumulative bytes of owned string payload attempted for retained anchor events.
174    ///
175    /// This counts owned scalar values and explicit tag spellings before each retained clone.
176    /// Borrowed input text is excluded because cloning its [`std::borrow::Cow`] does not copy
177    /// the referenced bytes. [`Budget::max_recorded_anchor_events`] separately limits the
178    /// fixed-size event storage for both borrowed and owned input.
179    ///
180    /// Default: `67_108_864` bytes (64 MiB).
181    #[cfg_attr(
182        feature = "serde_derived_types",
183        serde(default = "default_max_recorded_anchor_bytes")
184    )]
185    pub max_recorded_anchor_bytes: usize,
186    /// Maximum nested operator-text expansions in one property interpolation.
187    ///
188    /// The root scalar is not included. This limit is used only when the `properties`
189    /// feature is enabled and a property map is configured, or when
190    /// `PropertySyntax::DockerCompose` or `PropertySyntax::DockerComposeWithNulls` is selected
191    /// with an empty map.
192    ///
193    /// Default: 64
194    #[cfg_attr(
195        feature = "serde_derived_types",
196        serde(default = "default_max_property_expansion_depth")
197    )]
198    pub max_property_expansion_depth: usize,
199    /// Maximum cumulative property-interpolation work across one deserialization stream.
200    ///
201    /// Work is measured in attacker-controlled input bytes inspected while scanning property
202    /// references and nested operator text. This limit is used only when the `properties`
203    /// feature is enabled and a property map is configured, or when
204    /// `PropertySyntax::DockerCompose` or `PropertySyntax::DockerComposeWithNulls` is selected
205    /// with an empty map.
206    ///
207    /// Default: 268,435,456 (256 MiB of byte inspections)
208    #[cfg_attr(
209        feature = "serde_derived_types",
210        serde(default = "default_max_total_property_interpolation_work")
211    )]
212    pub max_total_property_interpolation_work: usize,
213    /// Maximum structural nesting depth (sequences + mappings).
214    ///
215    /// This also raises the parser's block-collection nesting limit as needed.
216    /// The separate [`Self::flow_nesting_limit`] remains an independent cap on
217    /// flow collections.
218    ///
219    /// Default: 64
220    pub max_depth: usize,
221    /// Maximum allowed `!include` nesting depth.
222    ///
223    /// This limits how many nested included parsers may be active below the root
224    /// document at once. A value of `0` disables includes entirely.
225    ///
226    /// Default: 24
227    pub max_inclusion_depth: u32,
228    /// Maximum number of YAML documents in the stream.
229    ///
230    /// Default: 1,024. If the budget scope is "per document", this is ignored.
231    pub max_documents: usize,
232    /// Maximum number of *nodes* (SequenceStart/MappingStart/Scalar).
233    ///
234    /// Default: 250,000
235    pub max_nodes: usize,
236    /// Maximum total bytes of scalar contents plus explicit tag spellings.
237    ///
238    /// Default: 67,108,864 (64 MiB)
239    pub max_total_scalar_bytes: usize,
240    /// Maximum total bytes of comment contents.
241    ///
242    /// Comment text may be copied and retained for [`crate::Commented`] support, so this
243    /// limit is enforced before deserialization stores comment text.
244    ///
245    /// Default: 67,108,864 (64 MiB)
246    #[cfg_attr(
247        feature = "serde_derived_types",
248        serde(default = "default_max_total_comment_bytes")
249    )]
250    #[cfg(feature = "comments")]
251    pub max_total_comment_bytes: usize,
252    /// Maximum number of merge keys (`<<`) allowed across the stream when merge-key
253    /// expansion is enabled.
254    ///
255    /// Default: 10,000
256    pub max_merge_keys: usize,
257    /// If `true`, enforce the alias/anchor heuristic.
258    ///
259    /// The heuristic flags inputs that use an excessive number of aliases
260    /// relative to the number of defined anchors.
261    ///
262    /// Default: true
263    pub enforce_alias_anchor_ratio: bool,
264    /// Minimum number of aliases required before the alias/anchor ratio
265    /// heuristic is evaluated. This avoids tiny-input false positives.
266    ///
267    /// Default: 100
268    pub alias_anchor_min_aliases: usize,
269    /// Multiplier used for the alias/anchor ratio heuristic. A breach occurs
270    /// when `aliases > alias_anchor_ratio_multiplier * anchors` (after
271    /// scanning), once [`Budget::alias_anchor_min_aliases`] is met.
272    ///
273    /// Default: 10
274    pub alias_anchor_ratio_multiplier: usize,
275}
276
277impl Default for Budget {
278    fn default() -> Self {
279        Self {
280            max_reader_input_bytes: Some(256 * 1024 * 1024), // 256 Mb
281            #[cfg(feature = "comments")]
282            max_buffered_comment_events: DEFAULT_MAX_BUFFERED_COMMENT_EVENTS,
283            simple_key_max_lookahead: DEFAULT_SIMPLE_KEY_MAX_LOOKAHEAD,
284            flow_nesting_limit: DEFAULT_FLOW_NESTING_LIMIT,
285            max_events: 1_000_000, // plenty for normal configs
286            max_aliases: 50_000,   // liberal absolute cap
287            max_anchors: 50_000,
288            max_recorded_anchor_events: 1_000_000,
289            max_recorded_anchor_bytes: 64 * 1024 * 1024,
290            max_property_expansion_depth: DEFAULT_MAX_PROPERTY_EXPANSION_DEPTH,
291            max_total_property_interpolation_work: DEFAULT_MAX_TOTAL_PROPERTY_INTERPOLATION_WORK,
292            max_depth: 64, // protects stack/CPU
293            max_inclusion_depth: 24,
294            max_documents: 1_024, // doc separator storms
295            max_nodes: 250_000,   // sequences + maps + scalars
296            max_total_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES, // 64 MiB of scalar text
297            #[cfg(feature = "comments")]
298            max_total_comment_bytes: DEFAULT_MAX_TOTAL_COMMENT_BYTES, // 64 MiB of comment text
299            max_merge_keys: 10_000, // generous cap for merge keys
300            enforce_alias_anchor_ratio: true,
301            alias_anchor_min_aliases: 100,
302            alias_anchor_ratio_multiplier: 10,
303        }
304    }
305}
306
307impl Budget {
308    pub(crate) fn parser_options(&self) -> granit_parser::Options {
309        // Preserve the parser's baseline for smaller budgets. For larger budgets,
310        // allow the first over-limit container through so callers receive
311        // BudgetBreach::Depth rather than a parser error.
312        let block_nesting_limit = granit_parser::Options::default()
313            .block_nesting_limit
314            .max(self.max_depth.saturating_add(1));
315
316        granit_parser::options! {
317            simple_key_max_lookahead: self.simple_key_max_lookahead,
318            flow_nesting_limit: self.flow_nesting_limit,
319            block_nesting_limit: block_nesting_limit,
320            // Another dependency may enable upstream comments even when our feature is disabled.
321            emit_comments: cfg!(feature = "comments"),
322            #[cfg(feature = "comments")]
323            max_buffered_comment_events: self.max_buffered_comment_events,
324        }
325    }
326}
327
328/// What tripped the budget (if anything).
329#[non_exhaustive]
330#[derive(Clone, Debug)]
331#[cfg_attr(
332    feature = "serde_derived_types",
333    derive(serde::Serialize, serde::Deserialize)
334)]
335pub enum BudgetBreach {
336    /// The total number of parser events exceeded [`Budget::max_events`].
337    Events {
338        /// Total events observed at the moment of the breach.
339        events: usize,
340    },
341
342    /// The number of alias events (`*ref`) exceeded [`Budget::max_aliases`].
343    Aliases {
344        /// Total alias events observed at the moment of the breach.
345        aliases: usize,
346    },
347
348    /// The number of distinct anchors defined exceeded [`Budget::max_anchors`].
349    Anchors {
350        /// Total distinct anchors observed at the moment of the breach.
351        anchors: usize,
352    },
353
354    /// The structural nesting depth exceeded [`Budget::max_depth`].
355    ///
356    /// Depth counts nested `SequenceStart` and `MappingStart` events.
357    Depth {
358        /// Maximum depth reached when the breach occurred.
359        depth: usize,
360    },
361
362    /// The include nesting depth exceeded [`Budget::max_inclusion_depth`].
363    InclusionDepth {
364        /// Include nesting depth reached when the breach occurred.
365        depth: u32,
366    },
367
368    /// The number of YAML documents exceeded [`Budget::max_documents`].
369    Documents {
370        /// Total documents observed at the moment of the breach.
371        documents: usize,
372    },
373
374    /// The number of nodes exceeded [`Budget::max_nodes`].
375    ///
376    /// Nodes are `SequenceStart`, `MappingStart`, and `Scalar` events.
377    Nodes {
378        /// Total nodes observed at the moment of the breach.
379        nodes: usize,
380    },
381
382    /// The cumulative size of scalar contents exceeded [`Budget::max_total_scalar_bytes`].
383    ScalarBytes {
384        /// Sum of `Scalar.value.len()` over all scalars seen so far.
385        total_scalar_bytes: usize,
386    },
387
388    /// The cumulative size of comment contents exceeded [`Budget::max_total_comment_bytes`].
389    #[cfg(feature = "comments")]
390    CommentBytes {
391        /// Sum of comment text lengths over all comments seen so far.
392        total_comment_bytes: usize,
393    },
394
395    /// The number of merge keys (`<<`) exceeded [`Budget::max_merge_keys`].
396    MergeKeys {
397        /// Total merge keys observed at the moment of the breach.
398        merge_keys: usize,
399    },
400
401    /// The ratio of aliases to defined anchors is excessive.
402    ///
403    /// Triggered when [`Budget::enforce_alias_anchor_ratio`] is true and
404    /// `aliases > alias_anchor_ratio_multiplier × anchors` (after scanning),
405    /// once `aliases >= alias_anchor_min_aliases` to avoid tiny-input
406    /// false positives.
407    AliasAnchorRatio {
408        /// Total alias events seen.
409        aliases: usize,
410        /// Total distinct anchors defined (by id) in the input.
411        anchors: usize,
412    },
413
414    /// Unbalanced structure: a closing event was encountered without a matching
415    /// opening event (depth underflow). Indicates malformed or truncated input.
416    SequenceUnbalanced,
417
418    /// The total number of input bytes exceeded [`Budget::max_reader_input_bytes`].
419    InputBytes {
420        /// Total number of bytes consumed from the input when the breach occurred.
421        input_bytes: usize,
422    },
423
424    /// Retained anchor event copies exceeded [`Budget::max_recorded_anchor_events`].
425    RecordedAnchorEvents {
426        /// Cumulative copies attempted at the moment of the breach.
427        recorded_anchor_events: usize,
428    },
429
430    /// Owned payload copied into retained anchor events exceeded
431    /// [`Budget::max_recorded_anchor_bytes`].
432    RecordedAnchorBytes {
433        /// Cumulative owned payload bytes attempted at the moment of the breach.
434        recorded_anchor_bytes: usize,
435    },
436
437    /// Property operator text exceeded [`Budget::max_property_expansion_depth`].
438    PropertyExpansionDepth {
439        /// Nested expansion depth attempted at the moment of the breach.
440        depth: usize,
441        /// Configured maximum nested expansion depth.
442        max_depth: usize,
443    },
444
445    /// Property interpolation exceeded
446    /// [`Budget::max_total_property_interpolation_work`].
447    PropertyInterpolationWork {
448        /// Cumulative work attempted at the moment of the breach.
449        work: usize,
450        /// Configured maximum cumulative work.
451        max_work: usize,
452    },
453}
454
455/// Summary of the scan (even if no breach).
456#[non_exhaustive]
457#[derive(Clone, Debug, Default)]
458#[cfg_attr(
459    feature = "serde_derived_types",
460    derive(serde::Serialize, serde::Deserialize)
461)]
462pub struct BudgetReport {
463    /// `Some(..)` if a limit was exceeded; `None` if all budgets were respected.
464    pub breached: Option<BudgetBreach>,
465
466    /// Total number of parser events observed.
467    pub events: usize,
468
469    /// Total number of alias events (`*ref`).
470    pub aliases: usize,
471
472    /// Total number of distinct anchors that were defined (by id).
473    pub anchors: usize,
474
475    /// Cumulative number of event copies attempted for anchor retention.
476    #[cfg_attr(feature = "serde_derived_types", serde(default))]
477    pub recorded_anchor_events: usize,
478
479    /// Cumulative owned string payload bytes attempted for retained anchor events.
480    #[cfg_attr(feature = "serde_derived_types", serde(default))]
481    pub recorded_anchor_bytes: usize,
482
483    /// Total number of YAML documents in the stream.
484    pub documents: usize,
485
486    /// Total number of nodes encountered (scalars + sequence starts + mapping starts).
487    pub nodes: usize,
488
489    /// Maximum structural nesting depth reached at any point in the stream.
490    pub max_depth: usize,
491
492    /// Sum of bytes across all scalar values (`Scalar.value.len()`), saturating on overflow.
493    pub total_scalar_bytes: usize,
494
495    /// Sum of bytes across all comment values, saturating on overflow.
496    #[cfg_attr(feature = "serde_derived_types", serde(default))]
497    #[cfg(feature = "comments")]
498    pub total_comment_bytes: usize,
499
500    /// Total number of merge keys (`<<`) encountered while merge-key expansion was enabled.
501    pub merge_keys: usize,
502}
503
504impl BudgetReport {
505    fn reset(&mut self) {
506        // Resets all fields except "breached" and document count.
507        self.events = 0;
508        self.aliases = 0;
509        self.anchors = 0;
510        self.recorded_anchor_events = 0;
511        self.recorded_anchor_bytes = 0;
512        self.nodes = 0;
513        self.max_depth = 0;
514        self.total_scalar_bytes = 0;
515        #[cfg(feature = "comments")]
516        {
517            self.total_comment_bytes = 0;
518        }
519        self.merge_keys = 0;
520    }
521}
522
523/// Scope over which YAML budget counters are enforced.
524///
525/// Single-document and whole-input entry points use [`EnforcingPolicy::AllContent`].
526/// Streaming reader iterators use [`EnforcingPolicy::PerDocument`] so an arbitrarily long
527/// stream can be processed while each document remains bounded.
528#[non_exhaustive]
529#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub enum EnforcingPolicy {
531    /// Apply one set of counters to the complete input stream.
532    AllContent,
533    /// Reset applicable counters for every YAML document.
534    PerDocument,
535}
536
537/// Stateful helper that enforces a [`Budget`] while consuming a stream of [`Event`]s.
538#[derive(Debug)]
539pub(crate) struct BudgetEnforcer {
540    budget: Budget,
541    report: BudgetReport,
542    depth: usize,
543    defined_anchors: HashSet<usize>,
544    containers: SmallVec<[ContainerState; 64]>,
545    scope: EnforcingPolicy,
546    merge_keys: MergeKeyPolicy,
547}
548
549#[derive(Clone, Copy, Debug)]
550enum ContainerState {
551    Sequence {
552        from_mapping_value: bool,
553    },
554    Mapping {
555        expecting_key: bool,
556        from_mapping_value: bool,
557    },
558}
559
560#[derive(Clone, Copy)]
561enum ContainerKind {
562    Sequence,
563    Mapping,
564}
565
566fn tag_display_len(tag: Option<&Tag>) -> usize {
567    tag.map_or(0, |tag| {
568        let (handle, suffix) = tag.parts();
569        handle.len().saturating_add(suffix.len())
570    })
571}
572
573impl BudgetEnforcer {
574    /// Create a new enforcer for the provided `budget`.
575    pub(crate) fn new(budget: Budget, scope: EnforcingPolicy, merge_keys: MergeKeyPolicy) -> Self {
576        Self {
577            budget,
578            report: BudgetReport::default(),
579            depth: 0,
580            defined_anchors: HashSet::new(),
581            containers: SmallVec::new(),
582            scope,
583            merge_keys,
584        }
585    }
586
587    /// Observe a parser [`Event`], updating the internal counters.
588    ///
589    /// Returns `Err(BudgetBreach)` as soon as a limit is exceeded.
590    pub fn observe(&mut self, ev: &Event) -> Result<(), BudgetBreach> {
591        if let Some(breach) = self.report.breached.as_ref() {
592            return Err(breach.clone());
593        }
594        let result = self.observe_inner(ev);
595        self.remember_breach(&result);
596        result
597    }
598
599    fn observe_inner(&mut self, ev: &Event) -> Result<(), BudgetBreach> {
600        self.report.events += 1;
601        if self.report.events > self.budget.max_events {
602            return Err(BudgetBreach::Events {
603                events: self.report.events,
604            });
605        }
606
607        match ev {
608            Event::Scalar(value, style, anchor_id, tag_opt) => {
609                self.bump_nodes()?;
610                self.bump_total_scalar_bytes(
611                    value
612                        .len()
613                        .saturating_add(tag_display_len(tag_opt.as_deref())),
614                )?;
615                self.record_anchor(*anchor_id)?;
616                self.handle_scalar(value, style, tag_opt.as_deref())?;
617            }
618            Event::MappingStart(_style, anchor_id, tag_opt) => {
619                self.enter_container(*anchor_id, tag_opt.as_deref(), |from_mapping_value| {
620                    ContainerState::Mapping {
621                        expecting_key: true,
622                        from_mapping_value,
623                    }
624                })?;
625            }
626            Event::MappingEnd => {
627                self.leave_container(ContainerKind::Mapping)?;
628            }
629            Event::SequenceStart(_style, anchor_id, tag_opt) => {
630                self.enter_container(*anchor_id, tag_opt.as_deref(), |from_mapping_value| {
631                    ContainerState::Sequence { from_mapping_value }
632                })?;
633            }
634            Event::SequenceEnd => {
635                self.leave_container(ContainerKind::Sequence)?;
636            }
637            Event::Alias(_anchor_id) => {
638                self.observe_alias_event(true)?;
639            }
640            Event::DocumentStart(..) => {
641                if self.scope == EnforcingPolicy::PerDocument {
642                    self.finalize_document()?;
643                    self.report.reset();
644                    self.defined_anchors.clear();
645                } else {
646                    self.report.documents += 1;
647                    if self.report.documents > self.budget.max_documents {
648                        return Err(BudgetBreach::Documents {
649                            documents: self.report.documents,
650                        });
651                    }
652                }
653            }
654            #[cfg(feature = "comments")]
655            Event::Comment(text, _) => {
656                self.report.total_comment_bytes =
657                    self.report.total_comment_bytes.saturating_add(text.len());
658                if self.report.total_comment_bytes > self.budget.max_total_comment_bytes {
659                    return Err(BudgetBreach::CommentBytes {
660                        total_comment_bytes: self.report.total_comment_bytes,
661                    });
662                }
663            }
664            _ => {}
665        }
666
667        Ok(())
668    }
669
670    /// Observe an alias token from the parser stream without advancing mapping
671    /// key/value state.
672    ///
673    /// This is used by `LiveEvents`, where alias references are immediately
674    /// expanded into replayed events that already advance mapping state.
675    pub(crate) fn observe_alias_reference(&mut self) -> Result<(), BudgetBreach> {
676        if let Some(breach) = self.report.breached.as_ref() {
677            return Err(breach.clone());
678        }
679        let result = self.observe_alias_reference_inner();
680        self.remember_breach(&result);
681        result
682    }
683
684    /// Charge one event clone that is about to be retained in an anchor buffer.
685    pub(crate) fn observe_recorded_anchor_event(
686        &mut self,
687        owned_bytes: usize,
688    ) -> Result<(), BudgetBreach> {
689        if let Some(breach) = self.report.breached.as_ref() {
690            return Err(breach.clone());
691        }
692        let result = self.observe_recorded_anchor_event_inner(owned_bytes);
693        self.remember_breach(&result);
694        result
695    }
696
697    fn observe_recorded_anchor_event_inner(
698        &mut self,
699        owned_bytes: usize,
700    ) -> Result<(), BudgetBreach> {
701        self.report.recorded_anchor_events = self.report.recorded_anchor_events.saturating_add(1);
702        if self.report.recorded_anchor_events > self.budget.max_recorded_anchor_events {
703            return Err(BudgetBreach::RecordedAnchorEvents {
704                recorded_anchor_events: self.report.recorded_anchor_events,
705            });
706        }
707
708        self.report.recorded_anchor_bytes = self
709            .report
710            .recorded_anchor_bytes
711            .saturating_add(owned_bytes);
712        if self.report.recorded_anchor_bytes > self.budget.max_recorded_anchor_bytes {
713            return Err(BudgetBreach::RecordedAnchorBytes {
714                recorded_anchor_bytes: self.report.recorded_anchor_bytes,
715            });
716        }
717
718        Ok(())
719    }
720
721    fn observe_alias_reference_inner(&mut self) -> Result<(), BudgetBreach> {
722        self.report.events += 1;
723        if self.report.events > self.budget.max_events {
724            return Err(BudgetBreach::Events {
725                events: self.report.events,
726            });
727        }
728        self.observe_alias_event(false)
729    }
730
731    fn remember_breach<T>(&mut self, result: &Result<T, BudgetBreach>) {
732        if self.report.breached.is_none()
733            && let Err(breach) = result
734        {
735            self.report.breached = Some(breach.clone());
736        }
737    }
738
739    /// Record a breach detected by a deserialization stage that shares this budget.
740    #[cfg(feature = "properties")]
741    pub(crate) fn record_external_breach(&mut self, breach: BudgetBreach) {
742        if self.report.breached.is_none() {
743            self.report.breached = Some(breach);
744        }
745    }
746
747    fn observe_alias_event(&mut self, advance_mapping_state: bool) -> Result<(), BudgetBreach> {
748        self.report.aliases += 1;
749        if self.report.aliases > self.budget.max_aliases {
750            return Err(BudgetBreach::Aliases {
751                aliases: self.report.aliases,
752            });
753        }
754        if advance_mapping_state {
755            self.handle_alias();
756        }
757        Ok(())
758    }
759
760    fn bump_nodes(&mut self) -> Result<(), BudgetBreach> {
761        self.report.nodes += 1;
762        if self.report.nodes > self.budget.max_nodes {
763            return Err(BudgetBreach::Nodes {
764                nodes: self.report.nodes,
765            });
766        }
767        Ok(())
768    }
769
770    fn bump_total_scalar_bytes(&mut self, bytes: usize) -> Result<(), BudgetBreach> {
771        self.report.total_scalar_bytes = self.report.total_scalar_bytes.saturating_add(bytes);
772        if self.report.total_scalar_bytes > self.budget.max_total_scalar_bytes {
773            return Err(BudgetBreach::ScalarBytes {
774                total_scalar_bytes: self.report.total_scalar_bytes,
775            });
776        }
777        Ok(())
778    }
779
780    // Track structural nesting before a mapping or sequence is pushed.
781    fn enter_depth(&mut self) -> Result<(), BudgetBreach> {
782        self.depth = self.depth.saturating_add(1);
783        if self.depth > self.report.max_depth {
784            self.report.max_depth = self.depth;
785        }
786        if self.report.max_depth > self.budget.max_depth {
787            return Err(BudgetBreach::Depth {
788                depth: self.report.max_depth,
789            });
790        }
791        Ok(())
792    }
793
794    // Apply all shared accounting for a mapping or sequence start event.
795    fn enter_container(
796        &mut self,
797        anchor_id: usize,
798        tag: Option<&Tag>,
799        container: impl FnOnce(bool) -> ContainerState,
800    ) -> Result<(), BudgetBreach> {
801        self.bump_nodes()?;
802        self.enter_depth()?;
803        self.bump_total_scalar_bytes(tag_display_len(tag))?;
804        let from_mapping_value = self.entering_container();
805        self.containers.push(container(from_mapping_value));
806        self.record_anchor(anchor_id)
807    }
808
809    fn record_anchor(&mut self, anchor_id: usize) -> Result<(), BudgetBreach> {
810        if anchor_id != 0 && self.defined_anchors.insert(anchor_id) {
811            let count = self.defined_anchors.len();
812            if count > self.budget.max_anchors {
813                self.report.anchors = count;
814                return Err(BudgetBreach::Anchors { anchors: count });
815            }
816        }
817        self.report.anchors = self.defined_anchors.len();
818        Ok(())
819    }
820
821    fn handle_scalar(
822        &mut self,
823        value: &str,
824        style: &ScalarStyle,
825        tag: Option<&Tag>,
826    ) -> Result<(), BudgetBreach> {
827        if let Some(ContainerState::Mapping { expecting_key, .. }) = self.containers.last_mut() {
828            if *expecting_key {
829                if matches!(self.merge_keys, MergeKeyPolicy::Merge)
830                    && value == "<<"
831                    && (tag.is_some_and(is_yaml_merge_tag)
832                        || tag.is_none() && matches!(style, ScalarStyle::Plain))
833                {
834                    self.report.merge_keys += 1;
835                    if self.report.merge_keys > self.budget.max_merge_keys {
836                        return Err(BudgetBreach::MergeKeys {
837                            merge_keys: self.report.merge_keys,
838                        });
839                    }
840                }
841                *expecting_key = false;
842            } else {
843                self.finish_value();
844            }
845        }
846        Ok(())
847    }
848
849    fn handle_alias(&mut self) {
850        if let Some(ContainerState::Mapping { expecting_key, .. }) = self.containers.last_mut() {
851            if *expecting_key {
852                *expecting_key = false;
853            } else {
854                self.finish_value();
855            }
856        }
857    }
858
859    fn entering_container(&mut self) -> bool {
860        if let Some(ContainerState::Mapping { expecting_key, .. }) = self.containers.last_mut() {
861            if *expecting_key {
862                *expecting_key = false;
863                false
864            } else {
865                true
866            }
867        } else {
868            false
869        }
870    }
871
872    // Pop the expected container and complete its parent mapping value if needed.
873    fn leave_container(&mut self, expected: ContainerKind) -> Result<(), BudgetBreach> {
874        self.depth = self
875            .depth
876            .checked_sub(1)
877            .ok_or(BudgetBreach::SequenceUnbalanced)?;
878
879        #[expect(
880            clippy::manual_let_else,
881            reason = "the equivalent or-pattern is harder to read"
882        )]
883        let from_mapping_value = match (expected, self.containers.pop()) {
884            (ContainerKind::Sequence, Some(ContainerState::Sequence { from_mapping_value }))
885            | (
886                ContainerKind::Mapping,
887                Some(ContainerState::Mapping {
888                    from_mapping_value, ..
889                }),
890            ) => from_mapping_value,
891            _ => return Err(BudgetBreach::SequenceUnbalanced),
892        };
893
894        if from_mapping_value {
895            self.finish_value();
896        }
897        Ok(())
898    }
899
900    fn finish_value(&mut self) {
901        if let Some(ContainerState::Mapping { expecting_key, .. }) = self.containers.last_mut() {
902            *expecting_key = true;
903        }
904    }
905
906    /// Consume the enforcer and return the accumulated [`BudgetReport`].
907    ///
908    /// This should be used after a breach has already been detected.
909    pub fn into_report(mut self) -> BudgetReport {
910        self.report.anchors = self.defined_anchors.len();
911        self.report
912    }
913
914    /// Finalize the current document when limits are enforced per document.
915    ///
916    /// Unlike [`BudgetEnforcer::finalize`], this keeps the enforcer alive so it
917    /// can be reset and reused for the next document in a stream.
918    pub(crate) fn finalize_document(&mut self) -> Result<(), BudgetBreach> {
919        if let Some(breach) = self.report.breached.as_ref() {
920            return Err(breach.clone());
921        }
922        if self.scope == EnforcingPolicy::PerDocument {
923            self.check_post_scan_heuristics()?;
924        }
925        Ok(())
926    }
927
928    fn check_post_scan_heuristics(&mut self) -> Result<(), BudgetBreach> {
929        self.report.anchors = self.defined_anchors.len();
930
931        if self.budget.enforce_alias_anchor_ratio
932            && self.report.aliases >= self.budget.alias_anchor_min_aliases
933            && (self.report.anchors == 0
934                || self.report.aliases
935                    > self
936                        .budget
937                        .alias_anchor_ratio_multiplier
938                        .saturating_mul(self.report.anchors))
939        {
940            let breach = BudgetBreach::AliasAnchorRatio {
941                aliases: self.report.aliases,
942                anchors: self.report.anchors,
943            };
944            self.report.breached = Some(breach.clone());
945            return Err(breach);
946        }
947
948        Ok(())
949    }
950
951    /// Finalize the enforcement, performing post-scan heuristics (like alias/anchor ratio).
952    pub fn finalize(mut self) -> BudgetReport {
953        if self.report.breached.is_none() {
954            let _ = self.check_post_scan_heuristics();
955        }
956        self.report
957    }
958}
959
960/// Check an input `&str` against the given `Budget`.
961///
962/// Parameters:
963/// - `input`: YAML text (UTF-8). If you accept non-UTF-8, transcode before calling.
964/// - `budget`: limits to enforce (see [`Budget`]).
965///
966/// Returns:
967/// - `Ok(report)` — `report.breached.is_none()` means **within budget**.
968///   If `report.breached.is_some()`, you should **reject** the input.
969/// - `Err(ScanError)` — scanning (lexing/parsing) failed.
970///
971/// Note:
972/// - This is **streaming** and does not allocate a DOM.
973/// - Depth counts nested `SequenceStart` and `MappingStart`.
974/// - Standalone budget checks do not receive [`Options`](crate::Options);
975///   merge keys are counted as if merge expansion is enabled, preserving
976///   historical behavior.
977pub fn check_yaml_budget(
978    input: &str,
979    budget: Budget,
980    scope: EnforcingPolicy,
981) -> Result<BudgetReport, ScanError> {
982    let parser_options = budget.parser_options();
983    let parser = Parser::with_options(granit_parser::StrInput::new(input), parser_options);
984    let mut enforcer = BudgetEnforcer::new(budget, scope, MergeKeyPolicy::Merge);
985
986    for item in parser {
987        let (ev, _span) = item?;
988        if enforcer.observe(&ev).is_err() {
989            return Ok(enforcer.into_report());
990        }
991    }
992
993    Ok(enforcer.finalize())
994}
995
996/// Return `true` if the YAML **exceeds** any budget.
997///
998/// Parameters:
999/// - `input`: YAML text (UTF-8).
1000/// - `budget`: limits to enforce.
1001///
1002/// Returns:
1003/// - `Ok(true)` if a budget was exceeded (reject).
1004/// - `Ok(false)` if within budget.
1005/// - `Err(ScanError)` on parser error.
1006///
1007/// This only scans the parser event stream; it does not deserialize a YAML value.
1008pub fn parse_yaml(input: &str, budget: Budget) -> Result<bool, ScanError> {
1009    let report = check_yaml_budget(input, budget, EnforcingPolicy::AllContent)?;
1010    Ok(report.breached.is_some())
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use std::fmt::Write as _;
1017
1018    #[test]
1019    fn tiny_yaml_ok() {
1020        let b = Budget::default();
1021        let y = "a: [1, 2, 3]\n";
1022        let r = check_yaml_budget(y, b, EnforcingPolicy::AllContent).unwrap();
1023        assert!(r.breached.is_none());
1024        assert_eq!(r.documents, 1);
1025        assert!(r.nodes > 0);
1026    }
1027
1028    #[test]
1029    fn alias_bomb_trips_alias_limit() {
1030        // A toy alias-bomb-ish input (not huge, just to exercise the check).
1031        let y = r#"root: &A [1, 2]
1032a: *A
1033b: *A
1034c: *A
1035d: *A
1036e: *A
1037"#;
1038
1039        let b = Budget {
1040            max_aliases: 3, // set a tiny limit for the test
1041            ..Default::default()
1042        };
1043
1044        let rep = check_yaml_budget(y, b, EnforcingPolicy::AllContent).unwrap();
1045        assert!(matches!(rep.breached, Some(BudgetBreach::Aliases { .. })));
1046    }
1047
1048    #[test]
1049    fn finalization_preserves_an_immediate_breach() {
1050        let budget = Budget {
1051            max_aliases: 0,
1052            alias_anchor_min_aliases: 1,
1053            ..Default::default()
1054        };
1055        let mut enforcer =
1056            BudgetEnforcer::new(budget, EnforcingPolicy::AllContent, MergeKeyPolicy::Merge);
1057
1058        let breach = enforcer.observe_alias_reference().unwrap_err();
1059        assert!(matches!(breach, BudgetBreach::Aliases { aliases: 1 }));
1060
1061        let report = enforcer.finalize();
1062        assert!(matches!(
1063            report.breached,
1064            Some(BudgetBreach::Aliases { aliases: 1 })
1065        ));
1066    }
1067
1068    #[test]
1069    fn deep_nesting_trips_depth() {
1070        let mut y = String::new();
1071        // Keep nesting below saphyr's internal recursion ceiling to ensure
1072        // the budget check, not the parser, trips first.
1073        for _ in 0..200 {
1074            y.push('[');
1075        }
1076        for _ in 0..200 {
1077            y.push(']');
1078        }
1079
1080        let b = Budget {
1081            max_depth: 150,
1082            ..Default::default()
1083        };
1084
1085        let rep = check_yaml_budget(&y, b, EnforcingPolicy::AllContent).unwrap();
1086        assert!(matches!(rep.breached, Some(BudgetBreach::Depth { .. })));
1087    }
1088
1089    #[test]
1090    fn parser_block_nesting_limit_tracks_max_depth() {
1091        let parser_default = granit_parser::Options::default().block_nesting_limit;
1092        for max_depth in [0, 64, parser_default, parser_default + 1, usize::MAX] {
1093            let budget = Budget {
1094                max_depth,
1095                ..Default::default()
1096            };
1097            assert_eq!(
1098                budget.parser_options().block_nesting_limit,
1099                parser_default.max(max_depth.saturating_add(1))
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn anchors_limit_trips() {
1106        // Three distinct anchors defined on scalar nodes
1107        let y = "a: &A 1\nb: &B 2\nc: &C 3\n";
1108        let b = Budget {
1109            max_anchors: 2,
1110            ..Default::default()
1111        };
1112        let rep = check_yaml_budget(y, b, EnforcingPolicy::AllContent).unwrap();
1113        assert!(matches!(
1114            rep.breached,
1115            Some(BudgetBreach::Anchors { anchors: 3 })
1116        ));
1117    }
1118
1119    #[test]
1120    fn merge_key_limit_trips() {
1121        let mut y = String::from("base: &B\n  k: 1\nitems:\n");
1122        for idx in 0..3 {
1123            let _ = writeln!(y, "  item{idx}:\n    <<: *B\n    extra: {idx}");
1124        }
1125
1126        let b = Budget {
1127            max_merge_keys: 2,
1128            ..Default::default()
1129        };
1130
1131        let rep = check_yaml_budget(&y, b, EnforcingPolicy::AllContent).unwrap();
1132        assert!(matches!(
1133            rep.breached,
1134            Some(BudgetBreach::MergeKeys { merge_keys }) if merge_keys == 3
1135        ));
1136        assert_eq!(rep.merge_keys, 3);
1137    }
1138
1139    #[test]
1140    fn explicit_merge_tag_counts_against_merge_key_limit() {
1141        let yaml = "root:\n  !!merge <<: { key: value }\n";
1142        let budget = Budget {
1143            max_merge_keys: 0,
1144            ..Default::default()
1145        };
1146
1147        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1148
1149        assert!(matches!(
1150            report.breached,
1151            Some(BudgetBreach::MergeKeys { merge_keys: 1 })
1152        ));
1153        assert_eq!(report.merge_keys, 1);
1154    }
1155
1156    #[test]
1157    fn merge_key_limit_is_ignored_when_policy_is_as_ordinary() {
1158        let y = "base: &B\n  k: 1\nroot:\n  <<: *B\n";
1159        let budget = Budget {
1160            max_merge_keys: 0,
1161            ..Default::default()
1162        };
1163        let mut enforcer = BudgetEnforcer::new(
1164            budget,
1165            EnforcingPolicy::AllContent,
1166            MergeKeyPolicy::AsOrdinary,
1167        );
1168
1169        for item in Parser::new_from_str(y) {
1170            let (event, _span) = item.unwrap();
1171            enforcer.observe(&event).unwrap();
1172        }
1173
1174        let report = enforcer.finalize();
1175        assert!(report.breached.is_none());
1176        assert_eq!(report.merge_keys, 0);
1177    }
1178
1179    #[test]
1180    fn alias_anchor_ratio_trips_when_excessive() {
1181        let yaml = "root: &A [1]\na: *A\nb: *A\nc: *A\n";
1182
1183        let budget = Budget {
1184            alias_anchor_min_aliases: 1,
1185            alias_anchor_ratio_multiplier: 2,
1186            ..Default::default()
1187        };
1188
1189        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1190        assert!(matches!(
1191            report.breached,
1192            Some(BudgetBreach::AliasAnchorRatio {
1193                aliases: 3,
1194                anchors: 1
1195            })
1196        ));
1197        assert_eq!(report.aliases, 3);
1198        assert_eq!(report.anchors, 1);
1199    }
1200
1201    #[test]
1202    fn per_document_alias_anchor_ratio_is_checked_before_reset() {
1203        let yaml = concat!(
1204            "---\n",
1205            "root: &A [1]\n",
1206            "a: *A\n",
1207            "b: *A\n",
1208            "c: *A\n",
1209            "---\n",
1210            "root: &B [2]\n",
1211        );
1212        let budget = Budget {
1213            alias_anchor_min_aliases: 1,
1214            alias_anchor_ratio_multiplier: 2,
1215            ..Default::default()
1216        };
1217
1218        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::PerDocument).unwrap();
1219
1220        assert!(matches!(
1221            report.breached,
1222            Some(BudgetBreach::AliasAnchorRatio {
1223                aliases: 3,
1224                anchors: 1
1225            })
1226        ));
1227    }
1228
1229    #[test]
1230    fn alias_anchor_ratio_respects_minimum_alias_threshold() {
1231        let yaml = "root: &A [1]\na: *A\nb: *A\nc: *A\n";
1232
1233        let budget = Budget {
1234            alias_anchor_min_aliases: 5,
1235            alias_anchor_ratio_multiplier: 1,
1236            ..Default::default()
1237        };
1238
1239        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1240        assert!(report.breached.is_none());
1241        assert_eq!(report.aliases, 3);
1242        assert_eq!(report.anchors, 1);
1243    }
1244
1245    #[test]
1246    fn alias_anchor_ratio_multiplier_overflow_does_not_panic() {
1247        let budget = Budget {
1248            alias_anchor_min_aliases: 1,
1249            alias_anchor_ratio_multiplier: usize::MAX,
1250            ..Default::default()
1251        };
1252        let mut enforcer =
1253            BudgetEnforcer::new(budget, EnforcingPolicy::AllContent, MergeKeyPolicy::Merge);
1254        enforcer.report.aliases = usize::MAX;
1255        enforcer.defined_anchors.insert(1);
1256        enforcer.defined_anchors.insert(2);
1257
1258        let report = enforcer.finalize();
1259
1260        assert!(report.breached.is_none());
1261        assert_eq!(report.aliases, usize::MAX);
1262        assert_eq!(report.anchors, 2);
1263    }
1264
1265    #[test]
1266    fn budget_default_sets_specialized_limits() {
1267        let budget = Budget::default();
1268
1269        assert_eq!(budget.max_inclusion_depth, 24);
1270        assert_eq!(budget.max_property_expansion_depth, 64);
1271        assert_eq!(
1272            budget.max_total_property_interpolation_work,
1273            256 * 1024 * 1024
1274        );
1275    }
1276
1277    #[test]
1278    fn scalar_budget_counts_tag_bytes() {
1279        let yaml = "root: !!str tagged\n";
1280        let budget = Budget {
1281            max_total_scalar_bytes: 14,
1282            ..Default::default()
1283        };
1284
1285        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1286        assert!(matches!(
1287            report.breached,
1288            Some(BudgetBreach::ScalarBytes {
1289                total_scalar_bytes
1290            }) if total_scalar_bytes > 14
1291        ));
1292    }
1293
1294    #[test]
1295    fn scalar_budget_counts_container_tag_bytes() {
1296        for yaml in ["root: !!seq [a]\n", "root: !!map {a: b}\n"] {
1297            let budget = Budget {
1298                max_total_scalar_bytes: 24,
1299                ..Default::default()
1300            };
1301
1302            let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1303            assert!(
1304                matches!(
1305                    report.breached,
1306                    Some(BudgetBreach::ScalarBytes {
1307                        total_scalar_bytes
1308                    }) if total_scalar_bytes > 24
1309                ),
1310                "yaml: {yaml:?}, report: {report:?}"
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn tag_display_len_matches_display_without_allocating() {
1317        for tag in [
1318            Tag::with_original_handle("tag:yaml.org,2002:", "str", "!!"),
1319            Tag::with_original_handle("!", "local", "!"),
1320            Tag::with_original_handle("", "tag:example.com,2000:thing", ""),
1321        ] {
1322            assert_eq!(tag_display_len(Some(&tag)), tag.to_string().len());
1323        }
1324    }
1325
1326    #[test]
1327    #[cfg(feature = "comments")]
1328    fn comment_budget_counts_comment_bytes() {
1329        let yaml = "#abcdef\nroot: ok\n";
1330        let budget = Budget {
1331            max_total_comment_bytes: 5,
1332            ..Default::default()
1333        };
1334
1335        let report = check_yaml_budget(yaml, budget, EnforcingPolicy::AllContent).unwrap();
1336        assert!(matches!(
1337            report.breached,
1338            Some(BudgetBreach::CommentBytes {
1339                total_comment_bytes
1340            }) if total_comment_bytes > 5
1341        ));
1342        assert_eq!(report.total_scalar_bytes, 0);
1343    }
1344}