Skip to main content

max / makenotwork

5.4 KB · 124 lines History Blame Raw
1 //! Byte fuzz over the whole substitution surface.
2 //!
3 //! Row 6 of `astra-soak-overview`: creator templates reach this parser, so the
4 //! boundary is hostile input and the failure cost is injection or a panic DoS.
5 //! It is named there as the cheapest target in the tree, which is why it is the
6 //! one that proves the soak loop before eight more harnesses are written.
7 //!
8 //! `substitute` is the entry point rather than `parse_expr`, which is
9 //! `pub(crate)` and only half the surface. One call runs code-span detection,
10 //! the marker regex, path lookup, the filter chain and the formatter, so
11 //! fuzzing the public function covers the private parser and everything the
12 //! parser hands off to.
13 //!
14 //! ## Oracles
15 //!
16 //! Not-panicking is the weakest thing a fuzz target can assert, and a target
17 //! that asserts only that will report clean forever while returning wrong
18 //! answers. Three real properties are checked besides:
19 //!
20 //! 1. **Code-span ranges are usable as byte ranges.** `code_span_ranges` is
21 //! public API and its output is used to index the input. A range past the
22 //! end, or one that splits a UTF-8 sequence, is a panic in every caller
23 //! rather than in this crate.
24 //! 2. **Text with no marker is returned unchanged.** The engine's whole
25 //! contract is that it touches `{{ ... }}` and nothing else.
26 //! 3. **Substitution reaches a fixed point.** Output that still contains a
27 //! resolvable marker would mean a value could inject a further
28 //! substitution, which is the injection half of the failure cost.
29
30 #![no_main]
31
32 use libfuzzer_sys::fuzz_target;
33 use subst::{Substituter, Value, code_span_ranges};
34
35 /// A table with one value per `Value` shape, so the filter chain is reachable.
36 ///
37 /// Built per call rather than once in a `static`: a `Substituter` holds boxed
38 /// trait objects, and a target that shares mutable state between runs stops
39 /// being reproducible from its input alone, which is what makes a crash
40 /// artifact worth keeping.
41 fn substituter() -> Substituter {
42 Substituter::new()
43 .with_value("price.basic", Value::Int(16))
44 .with_value("stripe.percent", Value::Float(0.029))
45 .with_value("brand.name", Value::String("Make Creative".into()))
46 .with_value("nested.deep.path.here", Value::Int(-1))
47 // No value here contains `{{`, and that is load-bearing for oracle 3.
48 //
49 // A value whose own text is a marker breaks the fixed point trivially:
50 // one pass yields the marker, a second resolves it. That says nothing
51 // about this crate, because the untrusted input on this path is the
52 // TEMPLATE, not the table -- `astra-soak-overview` row 6 is creator
53 // templates, and callers populate values from config they control. The
54 // first version of this target planted such a value and the fuzzer
55 // reported it in under a minute, which was the oracle being wrong rather
56 // than the engine.
57 //
58 // Whether hostile values should also be inert is a real question and a
59 // different one. It needs the caller contract stated first, and if it is
60 // ever answered "yes" it wants its own target rather than a weakened
61 // oracle here.
62 }
63
64 fuzz_target!(|text: &str| {
65 // Oracle 1. Checked before substitute, because substitute consumes these
66 // ranges and a bad one panics there instead of here, where the message
67 // would say which invariant broke.
68 let ranges = code_span_ranges(text);
69 let mut prev_end = 0usize;
70 for &(start, end) in &ranges {
71 assert!(start <= end, "inverted code span {start}..{end}");
72 assert!(
73 end <= text.len(),
74 "code span {start}..{end} past len {}",
75 text.len()
76 );
77 assert!(
78 text.is_char_boundary(start) && text.is_char_boundary(end),
79 "code span {start}..{end} splits a UTF-8 sequence"
80 );
81 assert!(
82 start >= prev_end,
83 "code spans overlap or are unsorted at {start}"
84 );
85 prev_end = end;
86 // The range is what callers slice with. Prove it.
87 let _ = &text[start..end];
88 }
89
90 let s = substituter();
91 let Ok(out) = s.substitute(text) else {
92 // An unresolved marker is the documented error, not a defect. Nothing
93 // further is promised about the output in that case.
94 return;
95 };
96
97 // Oracle 2.
98 if !text.contains("{{") {
99 assert_eq!(out, text, "text with no marker was modified");
100 }
101
102 // Oracle 3, deliberately narrow.
103 //
104 // An unconditional fixed point is not true even of correct code. A template
105 // may legitimately ask for text that looks like a marker:
106 //
107 // {{ price | money("{{ other }}") }} -> {{ other }}16.00
108 //
109 // That is the author's literal string, correctly emitted, and substitution
110 // is one pass by design. Asserting otherwise would demand the engine escape
111 // its own output, which fights the crate's whole purpose: code spans exist
112 // so documentation can show template syntax.
113 //
114 // So the property is asserted where it does hold. With no quote in the
115 // input there is no string literal to carry marker text, and anything the
116 // engine emits must be inert. That still covers a scanner that mis-locates
117 // a marker, which mangles quoteless input too.
118 if !text.contains('"') && !text.contains('\'') {
119 if let Ok(twice) = s.substitute(&out) {
120 assert_eq!(twice, out, "substitution is not a fixed point");
121 }
122 }
123 });
124