1use 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#[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 pub max_reader_input_bytes: Option<usize>,
116 #[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 #[cfg_attr(
135 feature = "serde_derived_types",
136 serde(default = "default_simple_key_max_lookahead")
137 )]
138 pub simple_key_max_lookahead: usize,
139 #[cfg_attr(
145 feature = "serde_derived_types",
146 serde(default = "default_flow_nesting_limit")
147 )]
148 pub flow_nesting_limit: usize,
149 pub max_events: usize,
153 pub max_aliases: usize,
157 pub max_anchors: usize,
161 #[cfg_attr(
169 feature = "serde_derived_types",
170 serde(default = "default_max_recorded_anchor_events")
171 )]
172 pub max_recorded_anchor_events: usize,
173 #[cfg_attr(
182 feature = "serde_derived_types",
183 serde(default = "default_max_recorded_anchor_bytes")
184 )]
185 pub max_recorded_anchor_bytes: usize,
186 #[cfg_attr(
195 feature = "serde_derived_types",
196 serde(default = "default_max_property_expansion_depth")
197 )]
198 pub max_property_expansion_depth: usize,
199 #[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 pub max_depth: usize,
221 pub max_inclusion_depth: u32,
228 pub max_documents: usize,
232 pub max_nodes: usize,
236 pub max_total_scalar_bytes: usize,
240 #[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 pub max_merge_keys: usize,
257 pub enforce_alias_anchor_ratio: bool,
264 pub alias_anchor_min_aliases: usize,
269 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), #[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, max_aliases: 50_000, 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, max_inclusion_depth: 24,
294 max_documents: 1_024, max_nodes: 250_000, max_total_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES, #[cfg(feature = "comments")]
298 max_total_comment_bytes: DEFAULT_MAX_TOTAL_COMMENT_BYTES, max_merge_keys: 10_000, 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 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 emit_comments: cfg!(feature = "comments"),
322 #[cfg(feature = "comments")]
323 max_buffered_comment_events: self.max_buffered_comment_events,
324 }
325 }
326}
327
328#[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 Events {
338 events: usize,
340 },
341
342 Aliases {
344 aliases: usize,
346 },
347
348 Anchors {
350 anchors: usize,
352 },
353
354 Depth {
358 depth: usize,
360 },
361
362 InclusionDepth {
364 depth: u32,
366 },
367
368 Documents {
370 documents: usize,
372 },
373
374 Nodes {
378 nodes: usize,
380 },
381
382 ScalarBytes {
384 total_scalar_bytes: usize,
386 },
387
388 #[cfg(feature = "comments")]
390 CommentBytes {
391 total_comment_bytes: usize,
393 },
394
395 MergeKeys {
397 merge_keys: usize,
399 },
400
401 AliasAnchorRatio {
408 aliases: usize,
410 anchors: usize,
412 },
413
414 SequenceUnbalanced,
417
418 InputBytes {
420 input_bytes: usize,
422 },
423
424 RecordedAnchorEvents {
426 recorded_anchor_events: usize,
428 },
429
430 RecordedAnchorBytes {
433 recorded_anchor_bytes: usize,
435 },
436
437 PropertyExpansionDepth {
439 depth: usize,
441 max_depth: usize,
443 },
444
445 PropertyInterpolationWork {
448 work: usize,
450 max_work: usize,
452 },
453}
454
455#[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 pub breached: Option<BudgetBreach>,
465
466 pub events: usize,
468
469 pub aliases: usize,
471
472 pub anchors: usize,
474
475 #[cfg_attr(feature = "serde_derived_types", serde(default))]
477 pub recorded_anchor_events: usize,
478
479 #[cfg_attr(feature = "serde_derived_types", serde(default))]
481 pub recorded_anchor_bytes: usize,
482
483 pub documents: usize,
485
486 pub nodes: usize,
488
489 pub max_depth: usize,
491
492 pub total_scalar_bytes: usize,
494
495 #[cfg_attr(feature = "serde_derived_types", serde(default))]
497 #[cfg(feature = "comments")]
498 pub total_comment_bytes: usize,
499
500 pub merge_keys: usize,
502}
503
504impl BudgetReport {
505 fn reset(&mut self) {
506 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#[non_exhaustive]
529#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub enum EnforcingPolicy {
531 AllContent,
533 PerDocument,
535}
536
537#[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 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 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 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 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 #[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 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 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 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 pub fn into_report(mut self) -> BudgetReport {
910 self.report.anchors = self.defined_anchors.len();
911 self.report
912 }
913
914 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 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
960pub 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
996pub 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 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, ..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 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 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}