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}