Skip to main content

noyalib/
policy.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Pluggable parser policies for "Safe YAML" enforcement.
5//!
6//! A [`Policy`](crate::policy::Policy) inspects parser events as
7//! the document is loaded and rejects any that violate
8//! organisational constraints — common examples are "no anchors",
9//! "no custom tags", or "no scalar larger than N bytes". Policies
10//! fire on the AST loader path; if any policy is registered the
11//! streaming fast-path is bypassed automatically so the policy
12//! contract is honoured everywhere.
13//!
14//! Built-in policies live in this module:
15//!
16//! - [`DenyAnchors`](crate::policy::DenyAnchors) — reject any
17//!   document that defines or dereferences an anchor.
18//! - [`DenyTags`](crate::policy::DenyTags) — reject any tagged
19//!   scalar / collection.
20//! - [`MaxScalarLength`](crate::policy::MaxScalarLength) — cap
21//!   individual scalar length in bytes.
22//!
23//! Custom policies implement the trait directly. Stateful policies
24//! that need to mutate during a parse should hold their state
25//! behind interior mutability ([`std::sync::Mutex`] or equivalent).
26//!
27//! # Examples
28//!
29//! ```
30//! use noyalib::{from_str_with_config, ParserConfig, Value};
31//! use noyalib::policy::DenyAnchors;
32//!
33//! let cfg = ParserConfig::new().with_policy(DenyAnchors);
34//! let res: Result<Value, _> =
35//!     from_str_with_config("k: &x 1\nv: *x\n", &cfg);
36//! assert!(res.is_err(), "DenyAnchors must reject anchored input");
37//! ```
38
39use crate::error::{Error, Result};
40use crate::prelude::*;
41use crate::value::Value;
42
43/// Kind of parser event handed to a policy.
44///
45/// # Examples
46///
47/// ```
48/// use noyalib::policy::PolicyEventKind;
49/// assert_eq!(PolicyEventKind::Scalar, PolicyEventKind::Scalar);
50/// assert_ne!(PolicyEventKind::Scalar, PolicyEventKind::Alias);
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum PolicyEventKind {
54    /// A scalar value (plain, quoted, literal, or folded).
55    Scalar,
56    /// The start of a sequence (block or flow).
57    SequenceStart,
58    /// The start of a mapping (block or flow).
59    MappingStart,
60    /// An alias dereference (`*name`).
61    Alias,
62}
63
64/// Lightweight projection of a parser event for policy inspection.
65///
66/// `PolicyEvent` borrows from the parser's internal event so
67/// policies can inspect the anchor name, tag URI, and scalar text
68/// without taking ownership.
69///
70/// # Examples
71///
72/// ```
73/// use noyalib::policy::{PolicyEvent, PolicyEventKind};
74/// let ev = PolicyEvent {
75///     kind: PolicyEventKind::Scalar,
76///     anchor: None,
77///     tag: Some("!!str"),
78///     scalar: Some("hello"),
79/// };
80/// assert_eq!(ev.kind, PolicyEventKind::Scalar);
81/// assert_eq!(ev.scalar, Some("hello"));
82/// ```
83#[derive(Debug, Clone, Copy)]
84pub struct PolicyEvent<'a> {
85    /// The kind of event.
86    pub kind: PolicyEventKind,
87    /// The anchor on this node, if any (the `&name` after the `&`).
88    pub anchor: Option<&'a str>,
89    /// The fully-reconstructed tag on this node, if any (e.g.
90    /// `"!!str"`, `"!Custom"`, or `"tag:yaml.org,2002:binary"`).
91    pub tag: Option<&'a str>,
92    /// The decoded scalar content (escapes already resolved), present
93    /// only for [`PolicyEventKind::Scalar`].
94    pub scalar: Option<&'a str>,
95}
96
97/// Pluggable "Safe YAML" policy.
98///
99/// Implementors override either or both check methods. The default
100/// implementations accept everything, so a policy that only cares
101/// about the post-parse value can leave [`Policy::check_event`]
102/// alone.
103///
104/// # Examples
105///
106/// ```
107/// use noyalib::policy::{Policy, PolicyEvent, PolicyEventKind};
108/// use noyalib::{from_str_with_config, ParserConfig, Value, Result, Error};
109///
110/// #[derive(Debug, Default)]
111/// struct DenyTabs;
112/// impl Policy for DenyTabs {
113///     fn check_event(&self, ev: PolicyEvent<'_>) -> Result<()> {
114///         if ev.kind == PolicyEventKind::Scalar
115///             && ev.scalar.is_some_and(|s| s.contains('\t'))
116///         {
117///             return Err(Error::Deserialize("tab in scalar".into()));
118///         }
119///         Ok(())
120///     }
121/// }
122///
123/// let cfg = ParserConfig::new().with_policy(DenyTabs);
124/// let res: Result<Value> = from_str_with_config("k: \"a\\tb\"\n", &cfg);
125/// assert!(res.is_err());
126/// ```
127pub trait Policy: Send + Sync + fmt::Debug {
128    /// Inspect a parser event during loading. Return `Err(...)` to
129    /// abort the parse with a diagnostic.
130    fn check_event(&self, _event: PolicyEvent<'_>) -> Result<()> {
131        Ok(())
132    }
133
134    /// Inspect the fully-built [`Value`] tree. Runs after the AST
135    /// is assembled and before deserialisation continues. Return
136    /// `Err(...)` to reject the document.
137    fn check_value(&self, _value: &Value) -> Result<()> {
138        Ok(())
139    }
140}
141
142/// Reject any document that defines an anchor (`&name`) or
143/// dereferences one (`*name`).
144///
145/// Aliases are a known billion-laughs vector and a major
146/// readability hazard in audited configs; many enterprise pipelines
147/// disable them outright.
148///
149/// # Examples
150///
151/// ```
152/// use noyalib::policy::DenyAnchors;
153/// use noyalib::{from_str_with_config, ParserConfig, Value};
154/// let cfg = ParserConfig::new().with_policy(DenyAnchors);
155/// let res: Result<Value, _> = from_str_with_config("k: &x 1\nv: *x\n", &cfg);
156/// assert!(res.is_err());
157/// ```
158#[derive(Debug, Clone, Copy, Default)]
159pub struct DenyAnchors;
160
161impl Policy for DenyAnchors {
162    fn check_event(&self, event: PolicyEvent<'_>) -> Result<()> {
163        if let Some(name) = event.anchor {
164            return Err(Error::Deserialize(format!(
165                "policy DenyAnchors: anchor `&{name}` is not allowed"
166            )));
167        }
168        if event.kind == PolicyEventKind::Alias {
169            return Err(Error::Deserialize(
170                "policy DenyAnchors: alias dereference is not allowed".into(),
171            ));
172        }
173        Ok(())
174    }
175}
176
177/// Reject any document carrying a custom (non-default) tag.
178///
179/// Default YAML 1.2 core tags (`!!str`, `!!int`, `!!bool`,
180/// `!!float`, `!!null`, `!!seq`, `!!map`, `!!binary`) are still
181/// permitted — only user-defined tags trigger rejection. Useful in
182/// configs where downstream consumers do not understand custom tag
183/// resolution.
184///
185/// # Examples
186///
187/// ```
188/// use noyalib::policy::DenyTags;
189/// use noyalib::{from_str_with_config, ParserConfig, Value};
190/// let cfg = ParserConfig::new().with_policy(DenyTags);
191/// let bad: Result<Value, _> = from_str_with_config("k: !Custom 1\n", &cfg);
192/// assert!(bad.is_err());
193/// // Core tags are still allowed.
194/// let ok: Value = from_str_with_config("k: !!str 1\n", &cfg).unwrap();
195/// assert!(matches!(ok, Value::Mapping(_)));
196/// ```
197#[derive(Debug, Clone, Copy, Default)]
198pub struct DenyTags;
199
200impl Policy for DenyTags {
201    fn check_event(&self, event: PolicyEvent<'_>) -> Result<()> {
202        if let Some(tag) = event.tag
203            && !is_core_tag(tag)
204        {
205            return Err(Error::Deserialize(format!(
206                "policy DenyTags: tag `{tag}` is not allowed"
207            )));
208        }
209        Ok(())
210    }
211}
212
213/// Cap the byte length of any individual scalar.
214///
215/// Counts the decoded scalar content (escape sequences resolved),
216/// *not* the post-resolution typed value; numeric / boolean scalars
217/// are measured by their textual representation. Helpful for
218/// resource-constrained pipelines that
219/// cannot trust upstream input size.
220///
221/// # Examples
222///
223/// ```
224/// use noyalib::policy::MaxScalarLength;
225/// use noyalib::{from_str_with_config, ParserConfig, Value};
226/// let cfg = ParserConfig::new().with_policy(MaxScalarLength(8));
227/// let ok: Value = from_str_with_config("k: short\n", &cfg).unwrap();
228/// assert!(matches!(ok, Value::Mapping(_)));
229/// let long: Result<Value, _> =
230///     from_str_with_config("k: this-is-too-long\n", &cfg);
231/// assert!(long.is_err());
232/// ```
233#[derive(Debug, Clone, Copy)]
234pub struct MaxScalarLength(pub usize);
235
236impl Policy for MaxScalarLength {
237    fn check_event(&self, event: PolicyEvent<'_>) -> Result<()> {
238        if let Some(s) = event.scalar
239            && s.len() > self.0
240        {
241            return Err(Error::Deserialize(format!(
242                "policy MaxScalarLength: scalar of {} bytes exceeds limit of {}",
243                s.len(),
244                self.0
245            )));
246        }
247        Ok(())
248    }
249}
250
251/// Run every policy's [`Policy::check_value`] on one parsed document.
252///
253/// Event checks run inside the shared budget meter, which every loader
254/// charges; this is the matching hook for the whole-document checks, and
255/// every entry point that produces a document calls it (after include
256/// and property expansion where those apply), so a policy means the same
257/// thing whichever API read the input.
258pub(crate) fn check_document(policies: &[Arc<dyn Policy>], value: &Value) -> Result<()> {
259    for p in policies {
260        p.check_value(value)?;
261    }
262    Ok(())
263}
264
265fn is_core_tag(tag: &str) -> bool {
266    matches!(
267        tag,
268        "!!str"
269            | "!!int"
270            | "!!bool"
271            | "!!float"
272            | "!!null"
273            | "!!seq"
274            | "!!map"
275            | "!!binary"
276            | "tag:yaml.org,2002:str"
277            | "tag:yaml.org,2002:int"
278            | "tag:yaml.org,2002:bool"
279            | "tag:yaml.org,2002:float"
280            | "tag:yaml.org,2002:null"
281            | "tag:yaml.org,2002:seq"
282            | "tag:yaml.org,2002:map"
283            | "tag:yaml.org,2002:binary"
284    )
285}