Skip to main content

check_yaml_expansion

Function check_yaml_expansion 

Source
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());