pub fn check_yaml_expansion(
content: &str,
max_bytes: usize,
) -> Result<(), usize>Expand description
Streams content through yaml-rust2’s own parser event stream and
tallies the total bytes the Yaml nodes YamlLoader::load_from_str
would allocate, rejecting once the tally exceeds max_bytes.
This is a pre-pass driven by the same Parser/event stream
YamlLoader::load_from_str itself uses (Parser::new(content.chars()),
multi = true), so anchor ids and event order are identical to the real
load — unlike a raw-text &anchor/*alias scan, which was tried and
rejected: ordinary prose such as description: A widget *multiplier* helper sits at exactly the position a text scanner treats as a token
boundary, so it false-positives as an alias reference.
The accounting model mirrors YamlLoader::on_event_impl exactly, in
bytes rather than node count: a Scalar charges
YAML_NODE_OVERHEAD_BYTES plus its own string content length; a closed
Sequence/Mapping charges YAML_NODE_OVERHEAD_BYTES for itself,
plus the byte weight of its already-charged descendants. An anchored
node (SequenceStart/MappingStart/Scalar anchor id > 0) charges
its own subtree’s byte weight a second time, mirroring
insert_new_node’s anchor_map.insert clone; an Alias charges the
referenced anchor’s recorded byte weight (or
YAML_NODE_OVERHEAD_BYTES for an unknown anchor id, matching the
loader’s own Yaml::BadValue fallback, which owns no heap content),
mirroring the v.clone() in the Event::Alias arm. All counting uses
u64 with saturating_add, since the counter itself — not just the
input — is the attack surface.
This pre-pass is not itself free relative to max_bytes: its own
anchors: BTreeMap<usize, u64> grows by one entry per distinct anchor
id seen, so a document built almost entirely of many tiny anchors (e.g.
~262,000 one-byte-scalar anchors, ~3.6 MB source) can transiently grow
this map to roughly the same order of magnitude as max_bytes itself
before the tally crosses it and rejection kicks in. This is bounded and
transient, not unbounded like the vulnerability this guard closes, but
callers should not assume the pre-pass’s own peak memory is negligible
next to the budget it enforces.
Any ScanError from this pre-pass is ignored: the real
YamlLoader::load_from_str call that follows reports the authoritative
syntax error. If the budget was already exceeded before the scan error,
this still returns Err.
This pre-pass is, like the real load, driven by Parser::load’s mutually
recursive load_node/load_mapping/load_sequence — callers must run
check_yaml_nesting_depth first so this never recurses on input deep
enough to overflow the stack itself.
§Errors
Returns Err(bytes) with the byte tally reached the instant it exceeds
max_bytes.
§Examples
use deps_core::parser::check_yaml_expansion;
assert!(check_yaml_expansion("a: 1\nb: [2, 3]\n", 1000).is_ok());
// A widget *multiplier* helper is a plain scalar, not an alias.
assert!(check_yaml_expansion("description: A widget *multiplier* helper", 1000).is_ok());
// Each anchor doubles the next one's alias count, so N levels expand to
// roughly 2^N nodes from a source only ~2N bytes long.
let mut doubling_chain = String::from("a0: &a0 [x, x]\n");
for i in 1..20 {
doubling_chain.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
}
assert!(check_yaml_expansion(&doubling_chain, 1000).is_err());