max / makenotwork
6 files changed,
+326 insertions,
-35 deletions
| @@ -2,15 +2,54 @@ | |||
| 2 | 2 | # It is not intended for manual editing. | |
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | - | [[package]] | |
| 6 | - | name = "regex-lite" | |
| 7 | - | version = "0.1.9" | |
| 8 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | - | checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" | |
| 10 | - | ||
| 11 | 5 | [[package]] | |
| 12 | 6 | name = "subst" | |
| 13 | 7 | version = "0.1.0" | |
| 14 | - | dependencies = [ | |
| 15 | - | "regex-lite", | |
| 16 | - | ] | |
| 8 | + | ||
| 9 | + | [[patch.unused]] | |
| 10 | + | name = "synckit-client" | |
| 11 | + | version = "0.8.0" | |
| 12 | + | ||
| 13 | + | [[patch.unused]] | |
| 14 | + | name = "synckit-config" | |
| 15 | + | version = "0.2.0" | |
| 16 | + | ||
| 17 | + | [[patch.unused]] | |
| 18 | + | name = "docengine" | |
| 19 | + | version = "0.5.0" | |
| 20 | + | ||
| 21 | + | [[patch.unused]] | |
| 22 | + | name = "kberg" | |
| 23 | + | version = "0.1.0" | |
| 24 | + | ||
| 25 | + | [[patch.unused]] | |
| 26 | + | name = "painhours" | |
| 27 | + | version = "0.1.0" | |
| 28 | + | ||
| 29 | + | [[patch.unused]] | |
| 30 | + | name = "tagtree" | |
| 31 | + | version = "0.4.0" | |
| 32 | + | ||
| 33 | + | [[patch.unused]] | |
| 34 | + | name = "quasi-axum" | |
| 35 | + | version = "0.1.0" | |
| 36 | + | ||
| 37 | + | [[patch.unused]] | |
| 38 | + | name = "quasi-http" | |
| 39 | + | version = "0.1.0" | |
| 40 | + | ||
| 41 | + | [[patch.unused]] | |
| 42 | + | name = "quasi-router" | |
| 43 | + | version = "0.1.0" | |
| 44 | + | ||
| 45 | + | [[patch.unused]] | |
| 46 | + | name = "quasi-store" | |
| 47 | + | version = "0.1.0" | |
| 48 | + | ||
| 49 | + | [[patch.unused]] | |
| 50 | + | name = "quasi-tauri" | |
| 51 | + | version = "0.1.0" | |
| 52 | + | ||
| 53 | + | [[patch.unused]] | |
| 54 | + | name = "quasi-webview" | |
| 55 | + | version = "0.1.0" |
| @@ -5,7 +5,6 @@ | |||
| 5 | 5 | license = "MIT" | |
| 6 | 6 | ||
| 7 | 7 | [dependencies] | |
| 8 | - | regex-lite = "0.1" | |
| 9 | 8 | ||
| 10 | 9 | [dev-dependencies] | |
| 11 | 10 |
| @@ -107,12 +107,6 @@ | |||
| 107 | 107 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | 108 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 109 | 109 | ||
| 110 | - | [[package]] | |
| 111 | - | name = "regex-lite" | |
| 112 | - | version = "0.1.9" | |
| 113 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | - | checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" | |
| 115 | - | ||
| 116 | 110 | [[package]] | |
| 117 | 111 | name = "shlex" | |
| 118 | 112 | version = "2.0.1" | |
| @@ -122,9 +116,6 @@ | |||
| 122 | 116 | [[package]] | |
| 123 | 117 | name = "subst" | |
| 124 | 118 | version = "0.1.0" | |
| 125 | - | dependencies = [ | |
| 126 | - | "regex-lite", | |
| 127 | - | ] | |
| 128 | 119 | ||
| 129 | 120 | [[package]] | |
| 130 | 121 | name = "subst-fuzz" |
| @@ -123,11 +123,32 @@ | |||
| 123 | 123 | /// resolved (or every filter that failed). The output is the text with all | |
| 124 | 124 | /// resolved markers replaced; unresolved markers are left in place when an | |
| 125 | 125 | /// error is returned, so callers can grep for them. | |
| 126 | + | /// | |
| 127 | + | /// # Do not substitute the output again | |
| 128 | + | /// | |
| 129 | + | /// One pass, and the output is not rescanned. A template may therefore ask | |
| 130 | + | /// for text that looks like a marker and get it back verbatim: | |
| 131 | + | /// | |
| 132 | + | /// ```text | |
| 133 | + | /// {{ price | money("{{ other }}") }} -> {{ other }}16.00 | |
| 134 | + | /// ``` | |
| 135 | + | /// | |
| 136 | + | /// That is the template author's literal string, correctly emitted. It is | |
| 137 | + | /// only a hazard if something feeds the result back in, which would | |
| 138 | + | /// evaluate a marker the first pass deliberately treated as data. **A | |
| 139 | + | /// second pass over untrusted output is a template injection**, and the | |
| 140 | + | /// same is true of any value in the table whose text contains `{{`. | |
| 141 | + | /// | |
| 142 | + | /// Stated here because it had never been stated anywhere, and a caller | |
| 143 | + | /// building a two-stage pipeline would reasonably have assumed the | |
| 144 | + | /// opposite. | |
| 126 | 145 | pub fn substitute(&self, text: &str) -> Result<String, SubstError> { | |
| 127 | - | // Matches `{{ … }}` non-greedily — the inner body may contain spaces, | |
| 128 | - | // pipes, parens, and quoted strings (e.g. `{{ x | money("$") }}`). | |
| 129 | - | let re = regex_lite::Regex::new(r"\{\{(.*?)\}\}").expect("static regex"); | |
| 130 | - | ||
| 146 | + | // Markers are located by `parser::markers`, which shares the parser's | |
| 147 | + | // own notion of a quoted string. It used to be a non-greedy regex, | |
| 148 | + | // which did not, so a `}}` inside a filter's string argument ended the | |
| 149 | + | // marker early and the tail was copied through as text. See | |
| 150 | + | // `parser::markers` for the case and the reasoning. | |
| 151 | + | // | |
| 131 | 152 | // Skip matches inside inline code spans and fenced code blocks so that | |
| 132 | 153 | // documentation showing literal `{{ … }}` template syntax (e.g. Tauri | |
| 133 | 154 | // updater URL patterns) is preserved verbatim. | |
| @@ -139,26 +160,27 @@ | |||
| 139 | 160 | let mut out = String::with_capacity(text.len()); | |
| 140 | 161 | let mut last = 0; | |
| 141 | 162 | ||
| 142 | - | for m in re.find_iter(text) { | |
| 143 | - | out.push_str(&text[last..m.start()]); | |
| 144 | - | last = m.end(); | |
| 163 | + | for m in parser::markers(text) { | |
| 164 | + | out.push_str(&text[last..m.start]); | |
| 165 | + | last = m.end; | |
| 145 | 166 | ||
| 146 | - | if in_code(m.start()) { | |
| 147 | - | out.push_str(m.as_str()); | |
| 167 | + | let raw = &text[m.start..m.end]; | |
| 168 | + | if in_code(m.start) { | |
| 169 | + | out.push_str(raw); | |
| 148 | 170 | continue; | |
| 149 | 171 | } | |
| 150 | 172 | ||
| 151 | - | let body = re.captures(m.as_str()).unwrap().get(1).unwrap().as_str(); | |
| 173 | + | let body = m.body; | |
| 152 | 174 | match self.resolve(body) { | |
| 153 | 175 | Ok(Some(v)) => out.push_str(&v.to_string()), | |
| 154 | 176 | Ok(None) => { | |
| 155 | 177 | // Path not found in the table. Preserve marker; flag for caller. | |
| 156 | 178 | unresolved.push(body.trim().to_string()); | |
| 157 | - | out.push_str(m.as_str()); | |
| 179 | + | out.push_str(raw); | |
| 158 | 180 | } | |
| 159 | 181 | Err(e) => { | |
| 160 | 182 | errors.push(format!("`{body}`: {e}")); | |
| 161 | - | out.push_str(m.as_str()); | |
| 183 | + | out.push_str(raw); | |
| 162 | 184 | } | |
| 163 | 185 | } | |
| 164 | 186 | } | |
| @@ -208,6 +230,43 @@ | |||
| 208 | 230 | .with_value("cohort.lock_duration", Value::String("lifetime".into())) | |
| 209 | 231 | } | |
| 210 | 232 | ||
| 233 | + | /// Regression, from the soak tier 2026-08-11 | |
| 234 | + | /// (GoingsOn `subst-substitute:4def8fda6141`). | |
| 235 | + | /// | |
| 236 | + | /// The old regex ended the marker at the `}}` inside the string argument, | |
| 237 | + | /// so the parser was handed ` expenses.F_monthly | money("` and reported an | |
| 238 | + | /// unclosed paren, while `") }}` was copied through as literal text. | |
| 239 | + | #[test] | |
| 240 | + | fn a_close_brace_inside_a_string_argument_is_not_the_end_of_the_marker() { | |
| 241 | + | let out = sample() | |
| 242 | + | .substitute(r#"{{ expenses.F_monthly | money("}}") }}"#) | |
| 243 | + | .expect("parses as one marker"); | |
| 244 | + | assert_eq!(out, "}}580.00"); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// The other half of the same finding: with the marker now bounded | |
| 248 | + | /// correctly, this is a fixed point. | |
| 249 | + | #[test] | |
| 250 | + | fn a_close_brace_in_a_string_argument_round_trips() { | |
| 251 | + | let s = sample(); | |
| 252 | + | let once = s | |
| 253 | + | .substitute(r#"{{ expenses.F_monthly | money("}}") }}"#) | |
| 254 | + | .unwrap(); | |
| 255 | + | assert_eq!(s.substitute(&once).unwrap(), once); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// A template may ask for text that looks like a marker, and gets it back | |
| 259 | + | /// literally. Substitution is one pass and does not rescan its own output, | |
| 260 | + | /// so this is the documented behaviour rather than a defect -- see the note | |
| 261 | + | /// on [`Substituter::substitute`] about not re-substituting output. | |
| 262 | + | #[test] | |
| 263 | + | fn marker_text_inside_a_string_argument_is_emitted_literally() { | |
| 264 | + | let out = sample() | |
| 265 | + | .substitute(r#"{{ expenses.F_monthly | money("{{ x }}") }}"#) | |
| 266 | + | .unwrap(); | |
| 267 | + | assert_eq!(out, "{{ x }}580.00"); | |
| 268 | + | } | |
| 269 | + | ||
| 211 | 270 | #[test] | |
| 212 | 271 | fn substitute_replaces_known_keys() { | |
| 213 | 272 | let out = sample() |
| @@ -38,6 +38,103 @@ | |||
| 38 | 38 | } | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | /// One `{{ … }}` marker located in a document. | |
| 42 | + | #[derive(Debug, PartialEq, Eq)] | |
| 43 | + | pub(crate) struct Marker<'a> { | |
| 44 | + | /// Byte offset of the opening `{{`. | |
| 45 | + | pub start: usize, | |
| 46 | + | /// Byte offset just past the closing `}}`. | |
| 47 | + | pub end: usize, | |
| 48 | + | /// What sits between them, braces excluded. | |
| 49 | + | pub body: &'a str, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Find every marker in `text`, respecting quoted strings. | |
| 53 | + | /// | |
| 54 | + | /// This lives beside the grammar above rather than in `lib.rs` because it has | |
| 55 | + | /// to agree with it. It previously did not: markers were found with the regex | |
| 56 | + | /// `\{\{(.*?)\}\}`, which knows nothing about quoting, while [`split_commas`] | |
| 57 | + | /// and [`parse_arg`] do. The two disagreed about where a marker ends, and a | |
| 58 | + | /// `}}` inside a string argument ended it early: | |
| 59 | + | /// | |
| 60 | + | /// ```text | |
| 61 | + | /// {{ price.basic | money("}}") }} | |
| 62 | + | /// ``` | |
| 63 | + | /// | |
| 64 | + | /// The regex stopped inside the string literal and handed `parse_expr` the | |
| 65 | + | /// fragment ` price.basic | money("`, which then reported an unclosed paren -- | |
| 66 | + | /// a true statement about the fragment and a misleading one about the input. | |
| 67 | + | /// Worse, the tail after the false ending was copied through as literal text, | |
| 68 | + | /// so substituting a creator template could emit live template syntax. Found by | |
| 69 | + | /// the soak tier 2026-08-11, GoingsOn `subst-substitute:4def8fda6141`. | |
| 70 | + | /// | |
| 71 | + | /// Two rules, and both are deliberate: | |
| 72 | + | /// | |
| 73 | + | /// - **Quotes are tracked exactly as [`split_commas`] tracks them**: `"` and | |
| 74 | + | /// `'` both open, the same character closes, and there is no escape | |
| 75 | + | /// sequence. Sharing the rule is the point. Two scanners with their own | |
| 76 | + | /// notion of "quoted" is what the bug was. | |
| 77 | + | /// - **A marker does not span a line.** The regex could not, because `.` does | |
| 78 | + | /// not match `\n`, and that is worth keeping rather than an accident to fix: | |
| 79 | + | /// it bounds how far a stray `{{` can reach. Without it an unclosed marker in | |
| 80 | + | /// one paragraph would swallow prose until a `}}` turned up further down the | |
| 81 | + | /// document. | |
| 82 | + | /// | |
| 83 | + | /// An unterminated marker is not a marker. It is left in the output verbatim, | |
| 84 | + | /// which is what the regex did and what documentation showing template syntax | |
| 85 | + | /// depends on. | |
| 86 | + | pub(crate) fn markers(text: &str) -> Vec<Marker<'_>> { | |
| 87 | + | let bytes = text.as_bytes(); | |
| 88 | + | let mut out = Vec::new(); | |
| 89 | + | let mut i = 0; | |
| 90 | + | ||
| 91 | + | while i + 1 < bytes.len() { | |
| 92 | + | if !(bytes[i] == b'{' && bytes[i + 1] == b'{') { | |
| 93 | + | i += 1; | |
| 94 | + | continue; | |
| 95 | + | } | |
| 96 | + | let body_start = i + 2; | |
| 97 | + | let mut j = body_start; | |
| 98 | + | let mut in_string: Option<u8> = None; | |
| 99 | + | let mut end = None; | |
| 100 | + | ||
| 101 | + | while j < bytes.len() { | |
| 102 | + | let b = bytes[j]; | |
| 103 | + | match in_string { | |
| 104 | + | Some(q) if b == q => in_string = None, | |
| 105 | + | Some(_) => {} | |
| 106 | + | None => match b { | |
| 107 | + | b'"' | b'\'' => in_string = Some(b), | |
| 108 | + | b'\n' => break, | |
| 109 | + | b'}' if j + 1 < bytes.len() && bytes[j + 1] == b'}' => { | |
| 110 | + | end = Some(j); | |
| 111 | + | break; | |
| 112 | + | } | |
| 113 | + | _ => {} | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | j += 1; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | match end { | |
| 120 | + | Some(close) => { | |
| 121 | + | out.push(Marker { | |
| 122 | + | start: i, | |
| 123 | + | end: close + 2, | |
| 124 | + | // Both ends are ASCII brace boundaries, so this cannot | |
| 125 | + | // split a character. | |
| 126 | + | body: &text[body_start..close], | |
| 127 | + | }); | |
| 128 | + | i = close + 2; | |
| 129 | + | } | |
| 130 | + | // No close on this line. Resume scanning after the `{{` rather than | |
| 131 | + | // past it, so `{{{{ x }}` still finds the inner marker. | |
| 132 | + | None => i = body_start, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | out | |
| 136 | + | } | |
| 137 | + | ||
| 41 | 138 | /// Parse the body of a `{{ … }}` marker. | |
| 42 | 139 | /// | |
| 43 | 140 | /// Grammar: | |
| @@ -207,6 +304,90 @@ | |||
| 207 | 304 | mod tests { | |
| 208 | 305 | use super::*; | |
| 209 | 306 | ||
| 307 | + | #[test] | |
| 308 | + | fn marker_ends_after_a_close_brace_inside_a_string_argument() { | |
| 309 | + | // The bug, minimally. The old regex stopped at the `}}` inside the | |
| 310 | + | // string and handed the parser ` price.basic | money("`. | |
| 311 | + | let m = markers(r#"{{ price.basic | money("}}") }}"#); | |
| 312 | + | assert_eq!(m.len(), 1); | |
| 313 | + | assert_eq!(m[0].body, r#" price.basic | money("}}") "#); | |
| 314 | + | assert_eq!(m[0].start, 0); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[test] | |
| 318 | + | fn single_quotes_hide_a_close_brace_too() { | |
| 319 | + | let m = markers("{{ x | money('}}') }}"); | |
| 320 | + | assert_eq!(m.len(), 1); | |
| 321 | + | assert_eq!(m[0].body, " x | money('}}') "); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /// The crash input from the soak tier, verbatim. One marker, not two. | |
| 325 | + | #[test] | |
| 326 | + | fn nested_marker_text_inside_a_string_is_one_marker() { | |
| 327 | + | let text = r#"{{ price.basic | money("{{ price.basic | money("$") }}$") }}"#; | |
| 328 | + | let m = markers(text); | |
| 329 | + | assert_eq!(m.len(), 1); | |
| 330 | + | assert_eq!(m[0].start, 0); | |
| 331 | + | assert_eq!(m[0].end, text.len()); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /// The regex could not span a newline, because `.` does not match one. That | |
| 335 | + | /// bound is kept deliberately: without it a stray `{{` swallows prose until | |
| 336 | + | /// a `}}` turns up further down the document. | |
| 337 | + | #[test] | |
| 338 | + | fn a_marker_does_not_span_a_line() { | |
| 339 | + | assert!(markers("{{ foo\nbar }}").is_empty()); | |
| 340 | + | // And the scanner recovers on the next line rather than giving up. | |
| 341 | + | let m = markers("{{ unclosed\n{{ ok }}"); | |
| 342 | + | assert_eq!(m.len(), 1); | |
| 343 | + | assert_eq!(m[0].body, " ok "); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn an_unterminated_marker_is_not_a_marker() { | |
| 348 | + | assert!(markers("{{ ").is_empty()); | |
| 349 | + | assert!(markers(r#"{{ x | money("unclosed }}"#).is_empty()); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// Matching the old regex exactly: it opened at the first `{{` and closed | |
| 353 | + | /// at the first `}}`, so the extra braces land inside the body and the | |
| 354 | + | /// expression fails to parse there rather than here. Locating a marker and | |
| 355 | + | /// judging its contents are separate jobs. | |
| 356 | + | #[test] | |
| 357 | + | fn doubled_braces_open_at_the_first_pair() { | |
| 358 | + | let m = markers("{{{{ x }}"); | |
| 359 | + | assert_eq!(m.len(), 1); | |
| 360 | + | assert_eq!(m[0].body, "{{ x "); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | /// The resume path: a `{{` that never closes must not hide a later marker | |
| 364 | + | /// on the same line. Scanning resumes just after the failed open rather | |
| 365 | + | /// than past it. | |
| 366 | + | #[test] | |
| 367 | + | fn an_unclosed_open_does_not_swallow_a_later_marker() { | |
| 368 | + | let m = markers(r#"{{ a | money(" and then {{ b }}"#); | |
| 369 | + | assert_eq!(m.len(), 1); | |
| 370 | + | assert_eq!(m[0].body, " b "); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn adjacent_markers_are_separate() { | |
| 375 | + | let m = markers("{{ a }}{{ b }}"); | |
| 376 | + | assert_eq!(m.len(), 2); | |
| 377 | + | assert_eq!(m[0].body, " a "); | |
| 378 | + | assert_eq!(m[1].body, " b "); | |
| 379 | + | assert_eq!(m[1].start, 7); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn multibyte_text_yields_slicable_spans() { | |
| 384 | + | let text = "ü {{ x }} £"; | |
| 385 | + | let m = markers(text); | |
| 386 | + | assert_eq!(m.len(), 1); | |
| 387 | + | // The spans are what `substitute` indexes with. | |
| 388 | + | assert_eq!(&text[m[0].start..m[0].end], "{{ x }}"); | |
| 389 | + | } | |
| 390 | + | ||
| 210 | 391 | #[test] | |
| 211 | 392 | fn parses_bare_path() { | |
| 212 | 393 | let e = parse_expr("foo.bar").unwrap(); |
| @@ -99,9 +99,31 @@ | |||
| 99 | 99 | assert_eq!(out, text, "text with no marker was modified"); | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | // Oracle 3. A second pass over the output must be a no-op: `substitute` is | |
| 103 | - | // not recursive by design, so anything it produced must be inert. | |
| 104 | - | if let Ok(twice) = s.substitute(&out) { | |
| 105 | - | assert_eq!(twice, out, "substitution is not a fixed point"); | |
| 102 | + | // Oracle 3, narrowed on 2026-08-11 by what it found. | |
| 103 | + | // | |
| 104 | + | // It used to assert an unconditional fixed point, and it caught a real bug | |
| 105 | + | // that way: the marker regex was not quote-aware, so a `}}` inside a string | |
| 106 | + | // argument ended the marker early and the tail was copied through as text | |
| 107 | + | // (GoingsOn `subst-substitute:4def8fda6141`, fixed in `parser::markers`). | |
| 108 | + | // | |
| 109 | + | // But the unconditional form is not true even of correct code. A template | |
| 110 | + | // may legitimately ask for text that looks like a marker: | |
| 111 | + | // | |
| 112 | + | // {{ price | money("{{ other }}") }} -> {{ other }}16.00 | |
| 113 | + | // | |
| 114 | + | // That is the author's literal string, correctly emitted, and substitution | |
| 115 | + | // is one pass by design. Asserting otherwise would demand the engine escape | |
| 116 | + | // its own output, which fights the crate's whole purpose -- code spans | |
| 117 | + | // exist so documentation can show template syntax. | |
| 118 | + | // | |
| 119 | + | // So the property is asserted where it does hold: with no quote in the | |
| 120 | + | // input there is no string literal to carry marker text, and anything the | |
| 121 | + | // engine emits must be inert. That still covers the class the original bug | |
| 122 | + | // was in, because a scanner that mis-locates a marker mangles quoteless | |
| 123 | + | // input too. | |
| 124 | + | if !text.contains('"') && !text.contains('\'') { | |
| 125 | + | if let Ok(twice) = s.substitute(&out) { | |
| 126 | + | assert_eq!(twice, out, "substitution is not a fixed point"); | |
| 127 | + | } | |
| 106 | 128 | } | |
| 107 | 129 | }); |