//! Byte fuzz over the whole substitution surface. //! //! Row 6 of `astra-soak-overview`: creator templates reach this parser, so the //! boundary is hostile input and the failure cost is injection or a panic DoS. //! It is named there as the cheapest target in the tree, which is why it is the //! one that proves the soak loop before eight more harnesses are written. //! //! `substitute` is the entry point rather than `parse_expr`, which is //! `pub(crate)` and only half the surface. One call runs code-span detection, //! the marker regex, path lookup, the filter chain and the formatter, so //! fuzzing the public function covers the private parser and everything the //! parser hands off to. //! //! ## Oracles //! //! Not-panicking is the weakest thing a fuzz target can assert, and a target //! that asserts only that will report clean forever while returning wrong //! answers. Three real properties are checked besides: //! //! 1. **Code-span ranges are usable as byte ranges.** `code_span_ranges` is //! public API and its output is used to index the input. A range past the //! end, or one that splits a UTF-8 sequence, is a panic in every caller //! rather than in this crate. //! 2. **Text with no marker is returned unchanged.** The engine's whole //! contract is that it touches `{{ ... }}` and nothing else. //! 3. **Substitution reaches a fixed point.** Output that still contains a //! resolvable marker would mean a value could inject a further //! substitution, which is the injection half of the failure cost. #![no_main] use libfuzzer_sys::fuzz_target; use subst::{Substituter, Value, code_span_ranges}; /// A table with one value per `Value` shape, so the filter chain is reachable. /// /// Built per call rather than once in a `static`: a `Substituter` holds boxed /// trait objects, and a target that shares mutable state between runs stops /// being reproducible from its input alone, which is what makes a crash /// artifact worth keeping. fn substituter() -> Substituter { Substituter::new() .with_value("price.basic", Value::Int(16)) .with_value("stripe.percent", Value::Float(0.029)) .with_value("brand.name", Value::String("Make Creative".into())) .with_value("nested.deep.path.here", Value::Int(-1)) // No value here contains `{{`, and that is load-bearing for oracle 3. // // A value whose own text is a marker breaks the fixed point trivially: // one pass yields the marker, a second resolves it. That says nothing // about this crate, because the untrusted input on this path is the // TEMPLATE, not the table -- `astra-soak-overview` row 6 is creator // templates, and callers populate values from config they control. The // first version of this target planted such a value and the fuzzer // reported it in under a minute, which was the oracle being wrong rather // than the engine. // // Whether hostile values should also be inert is a real question and a // different one. It needs the caller contract stated first, and if it is // ever answered "yes" it wants its own target rather than a weakened // oracle here. } fuzz_target!(|text: &str| { // Oracle 1. Checked before substitute, because substitute consumes these // ranges and a bad one panics there instead of here, where the message // would say which invariant broke. let ranges = code_span_ranges(text); let mut prev_end = 0usize; for &(start, end) in &ranges { assert!(start <= end, "inverted code span {start}..{end}"); assert!( end <= text.len(), "code span {start}..{end} past len {}", text.len() ); assert!( text.is_char_boundary(start) && text.is_char_boundary(end), "code span {start}..{end} splits a UTF-8 sequence" ); assert!( start >= prev_end, "code spans overlap or are unsorted at {start}" ); prev_end = end; // The range is what callers slice with. Prove it. let _ = &text[start..end]; } let s = substituter(); let Ok(out) = s.substitute(text) else { // An unresolved marker is the documented error, not a defect. Nothing // further is promised about the output in that case. return; }; // Oracle 2. if !text.contains("{{") { assert_eq!(out, text, "text with no marker was modified"); } // Oracle 3, deliberately narrow. // // An unconditional fixed point is not true even of correct code. A template // may legitimately ask for text that looks like a marker: // // {{ price | money("{{ other }}") }} -> {{ other }}16.00 // // That is the author's literal string, correctly emitted, and substitution // is one pass by design. Asserting otherwise would demand the engine escape // its own output, which fights the crate's whole purpose: code spans exist // so documentation can show template syntax. // // So the property is asserted where it does hold. With no quote in the // input there is no string literal to carry marker text, and anything the // engine emits must be inert. That still covers a scanner that mis-locates // a marker, which mangles quoteless input too. if !text.contains('"') && !text.contains('\'') { if let Ok(twice) = s.substitute(&out) { assert_eq!(twice, out, "substitution is not a fixed point"); } } });