Skip to main content

bynk_emit/emitter/
toml_doc.rs

1//! P7.3 (#1303): a minimal typed TOML tree and printer — the one piece of
2//! R7.8 (`Artefacts` is a keyed set of typed documents, never a `String` at
3//! construction) landable ahead of `bynk-ts` (Arc B, P7.5+), since
4//! `wrangler.toml` is the one document `bynk-emit` produces that isn't
5//! TypeScript. Not a general TOML library: `TomlValue` represents exactly
6//! what `emitter::wrangler::emit_wrangler_toml` needs to build a
7//! `wrangler.toml` today, no more.
8//!
9//! [`print_toml_document`] is the *only* function in this crate that writes
10//! TOML syntax — `emit_wrangler_toml` builds a [`TomlDocument`], this module
11//! renders it. That split is what makes string-escaping a printer guarantee
12//! (every `TomlValue::Str` is escaped unconditionally, §"Decision B" of
13//! #1303) rather than a per-call-site judgement call, which is what left
14//! `wrangler.rs`'s own `name`/`binding`/`class_name` values unescaped before
15//! this — safe today only because those particular values happen to be
16//! compiler-derived identifiers that can't contain a TOML-breaking
17//! character, not because anything enforced it structurally.
18
19use std::fmt::Write as _;
20
21/// A TOML document: the root block's entries (review of #1304, finding 3 —
22/// a field set once at construction, not a `TomlBlock` a caller could
23/// accidentally `push_block` out of first position, which `push_block`
24/// itself couldn't have rejected: TOML has no marker distinguishing "the
25/// root block" from "a table block with no header" once both are just
26/// entries in the same list) followed by any number of headed blocks. Every
27/// block, printed — root included — is followed by exactly one blank line
28/// (including the last — confirmed against every current
29/// `expected/**/wrangler.toml` golden fixture, which all end in a trailing
30/// blank line).
31pub struct TomlDocument {
32    header_comment: &'static str,
33    root: Vec<TomlEntry>,
34    blocks: Vec<TomlBlock>,
35}
36
37impl TomlDocument {
38    /// `header_comment` is the leading `# …` line's text *without* the `#`
39    /// marker — [`print_toml_document`] prepends it, the same convention
40    /// [`TomlEntry::with_comment`] already used (review of #1304, finding
41    /// 2: the two used to disagree, one taking a marker-inclusive literal
42    /// and the other marker-exclusive, with nothing enforcing either).
43    pub(crate) fn new(header_comment: &'static str, root: Vec<TomlEntry>) -> Self {
44        Self {
45            header_comment,
46            root,
47            blocks: Vec::new(),
48        }
49    }
50
51    pub(crate) fn push_block(&mut self, block: TomlBlock) {
52        self.blocks.push(block);
53    }
54
55    /// Set the root `main` entry's value in place — the structural,
56    /// tree-level equivalent of the old text-based `toml_edit` patch this
57    /// replaces (P7.6, #1309, Decision E): a caller that already holds the
58    /// real tree (`bynk-strip::strip_project_to_js`) uses this instead of
59    /// printing then re-parsing just to change one field.
60    ///
61    /// Returns `false`, changing nothing, if the document has no root
62    /// `main` entry — the caller's job to treat that as an error (P7.4,
63    /// #1305's own guardrail against a silently-unpatched JS artefact whose
64    /// manifest still names the stripped `.ts` entry: this method reports
65    /// the miss, it doesn't decide it's fine).
66    #[must_use]
67    pub fn set_main(&mut self, value: impl Into<String>) -> bool {
68        for entry in &mut self.root {
69            if entry.key == "main" {
70                entry.value = TomlValue::Str(value.into());
71                return true;
72            }
73        }
74        false
75    }
76}
77
78/// One `[path]` or `[[path]]` section plus its `key = value` entries, in
79/// order. Always headed — the document's own root block is
80/// [`TomlDocument`]'s own field, not constructible as a `TomlBlock` (review
81/// of #1304, finding 3).
82pub(crate) struct TomlBlock {
83    header: TomlHeader,
84    entries: Vec<TomlEntry>,
85}
86
87enum TomlHeader {
88    Table(&'static str),
89    ArrayTable(&'static str),
90}
91
92impl TomlBlock {
93    pub(crate) fn table(path: &'static str, entries: Vec<TomlEntry>) -> Self {
94        Self {
95            header: TomlHeader::Table(path),
96            entries,
97        }
98    }
99
100    pub(crate) fn array_table(path: &'static str, entries: Vec<TomlEntry>) -> Self {
101        Self {
102            header: TomlHeader::ArrayTable(path),
103            entries,
104        }
105    }
106}
107
108/// One `key = value` line, with an optional trailing `# comment` — TOML's
109/// own comment syntax, not an escape hatch; `wrangler.toml`'s one instance
110/// today is the KV namespace id's `# set at deploy time`.
111pub(crate) struct TomlEntry {
112    key: &'static str,
113    value: TomlValue,
114    comment: Option<&'static str>,
115}
116
117impl TomlEntry {
118    pub(crate) fn kv(key: &'static str, value: TomlValue) -> Self {
119        Self {
120            key,
121            value,
122            comment: None,
123        }
124    }
125
126    pub(crate) fn with_comment(key: &'static str, value: TomlValue, comment: &'static str) -> Self {
127        Self {
128            key,
129            value,
130            comment: Some(comment),
131        }
132    }
133}
134
135/// Exactly the value shapes `wrangler.toml` generation writes today — a
136/// basic string (always escaped on render, unconditionally), a bare
137/// integer, and an array (rendered as `[a, b, …]`, TOML's inline-array
138/// form). No bool, no float, no inline table, no nesting beyond one section
139/// level: none of those appear in the current output, and widening this
140/// when a real future value needs it (R8.20's deploy-time `Placeholder`,
141/// P7.4) is cheap.
142pub(crate) enum TomlValue {
143    Str(String),
144    Int(i64),
145    Array(Vec<TomlValue>),
146}
147
148impl TomlValue {
149    pub(crate) fn str(s: impl Into<String>) -> Self {
150        Self::Str(s.into())
151    }
152}
153
154/// Render `doc` to TOML text. The one function in this module — and, per
155/// this file's own module doc, in `bynk-emit`'s TOML-producing surface —
156/// that calls `write!`/`writeln!`/`format!` to build TOML syntax.
157pub fn print_toml_document(doc: &TomlDocument) -> String {
158    let mut out = String::new();
159    let _ = writeln!(out, "# {}", doc.header_comment);
160    print_entries(&mut out, &doc.root);
161    let _ = writeln!(out);
162    for block in &doc.blocks {
163        match &block.header {
164            TomlHeader::Table(path) => {
165                let _ = writeln!(out, "[{path}]");
166            }
167            TomlHeader::ArrayTable(path) => {
168                let _ = writeln!(out, "[[{path}]]");
169            }
170        }
171        print_entries(&mut out, &block.entries);
172        let _ = writeln!(out);
173    }
174    out
175}
176
177fn print_entries(out: &mut String, entries: &[TomlEntry]) {
178    for entry in entries {
179        let value = render_value(&entry.value);
180        match entry.comment {
181            Some(comment) => {
182                let _ = writeln!(out, "{} = {value} # {comment}", entry.key);
183            }
184            None => {
185                let _ = writeln!(out, "{} = {value}", entry.key);
186            }
187        }
188    }
189}
190
191fn render_value(value: &TomlValue) -> String {
192    match value {
193        TomlValue::Str(s) => format!("\"{}\"", escape_toml_basic_string(s)),
194        TomlValue::Int(n) => n.to_string(),
195        TomlValue::Array(items) => {
196            let rendered: Vec<String> = items.iter().map(render_value).collect();
197            format!("[{}]", rendered.join(", "))
198        }
199    }
200}
201
202/// Escape a source string literal for interpolation into a TOML *basic*
203/// string (the `"…"` form). Queue names and cron expressions come from user
204/// string literals, which can decode to contain `"`, `\`, newline and tab
205/// (`bynk-syntax/src/lexer.rs`) — all of which would otherwise break out of
206/// the TOML string and inject config keys. Every character we escape maps
207/// to a valid TOML compact escape; remaining control characters fall back
208/// to the `\uXXXX` form so the output is always a well-formed basic string.
209///
210/// Applied unconditionally by [`render_value`] to every [`TomlValue::Str`] —
211/// not just the values a caller happens to know are user-supplied. Relocated
212/// here from `emitter/wrangler.rs` (P7.3, #1303): escaping is the printer's
213/// job now, applied structurally to every string this module renders, not a
214/// per-call-site judgement about which particular value might need it.
215fn escape_toml_basic_string(s: &str) -> String {
216    let mut out = String::with_capacity(s.len());
217    for c in s.chars() {
218        match c {
219            '\\' => out.push_str("\\\\"),
220            '"' => out.push_str("\\\""),
221            '\n' => out.push_str("\\n"),
222            '\t' => out.push_str("\\t"),
223            '\r' => out.push_str("\\r"),
224            // Control characters have no compact TOML escape besides the ones
225            // above and must not appear raw in a basic string.
226            c if (c as u32) < 0x20 || c == '\u{7f}' => {
227                let _ = write!(out, "\\u{:04X}", c as u32);
228            }
229            c => out.push(c),
230        }
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn escape_toml_basic_string_neutralises_injection() {
241        // The trigger from the defect report: a queue name whose decoded value
242        // carries a quote + newline would otherwise close the string and inject
243        // a config key.
244        assert_eq!(
245            escape_toml_basic_string("q\nkey = \"injected"),
246            "q\\nkey = \\\"injected"
247        );
248        assert_eq!(escape_toml_basic_string("a\\b"), "a\\\\b");
249        assert_eq!(escape_toml_basic_string("a\tb"), "a\\tb");
250    }
251
252    #[test]
253    fn set_main_changes_only_the_main_entry() {
254        let mut doc = TomlDocument::new(
255            "Generated by bynkc — do not edit by hand.",
256            vec![
257                TomlEntry::kv("name", TomlValue::str("api")),
258                TomlEntry::kv("main", TomlValue::str("index.ts")),
259            ],
260        );
261        doc.push_block(TomlBlock::table(
262            "triggers",
263            vec![TomlEntry::kv(
264                "crons",
265                TomlValue::Array(vec![TomlValue::str("*/5 * * * *")]),
266            )],
267        ));
268
269        assert!(doc.set_main("index.js"));
270
271        let text = print_toml_document(&doc);
272        let parsed: toml::Table = text.parse().expect("valid TOML");
273        assert_eq!(parsed["main"].as_str(), Some("index.js"));
274        assert_eq!(parsed["name"].as_str(), Some("api"));
275        let crons = parsed["triggers"]["crons"].as_array().expect("crons");
276        assert_eq!(crons[0].as_str(), Some("*/5 * * * *"));
277    }
278
279    #[test]
280    fn set_main_reports_a_missing_root_main_entry_rather_than_swallowing_it() {
281        let mut doc = TomlDocument::new(
282            "Generated by bynkc — do not edit by hand.",
283            vec![TomlEntry::kv("name", TomlValue::str("api"))],
284        );
285        assert!(!doc.set_main("index.js"));
286        let parsed: toml::Table = print_toml_document(&doc).parse().expect("valid TOML");
287        assert!(parsed.get("main").is_none());
288    }
289
290    #[test]
291    fn escape_toml_basic_string_passes_plain_values_through() {
292        // Ordinary cron expressions and queue names are untouched.
293        assert_eq!(escape_toml_basic_string("*/5 * * * *"), "*/5 * * * *");
294        assert_eq!(escape_toml_basic_string("order-events"), "order-events");
295    }
296
297    #[test]
298    fn escape_toml_basic_string_escapes_other_control_chars() {
299        // A NUL has no compact escape and must not appear raw in a basic string.
300        assert_eq!(escape_toml_basic_string("a\u{0}b"), "a\\u0000b");
301        assert_eq!(escape_toml_basic_string("a\u{7f}b"), "a\\u007Fb");
302    }
303
304    #[test]
305    fn escaped_value_is_valid_toml_and_round_trips() {
306        // The security invariant, enforced by a real TOML parser (not a golden
307        // byte-compare): interpolating the escaped value produces a well-formed
308        // single-key table whose decoded value is *exactly* the input — no
309        // injected keys, no broken string. Covers the injection payload from the
310        // defect report plus a control char that takes the `\uXXXX` fallback.
311        for input in ["q\nkey = \"injected", "*/5 * * * *\\\"", "a\u{0}b\ttail"] {
312            let doc = format!("queue = \"{}\"", escape_toml_basic_string(input));
313            let table: toml::Table = doc
314                .parse()
315                .unwrap_or_else(|e| panic!("escaped {input:?} is invalid TOML: {e} ({doc:?})"));
316            assert_eq!(
317                table.len(),
318                1,
319                "escaped {input:?} injected extra keys: {table:?}"
320            );
321            assert_eq!(
322                table["queue"].as_str(),
323                Some(input),
324                "escaped {input:?} did not round-trip"
325            );
326        }
327    }
328
329    #[test]
330    fn print_toml_document_renders_a_representative_document_and_round_trips() {
331        // Not a golden byte-compare (that's `bless_positive_fixtures`'s job) —
332        // this proves the printer's *general* shape (root block, a `[[…]]`
333        // array table, a `[…]` table, an array value, a commented entry) is
334        // well-formed TOML a real parser accepts, with every value surviving
335        // exactly. `TomlBlock`/`TomlEntry` construction mirrors
336        // `emit_wrangler_toml`'s own shape one-for-one.
337        //
338        // Review of #1304, finding 1: the `name` and the one `crons` element
339        // below are the injection payload from the defect report, not an
340        // ordinary identifier — every value the *golden* corpus carries today
341        // is compiler-derived and never needs escaping, which means a
342        // zero-diff `bless_positive_fixtures` run cannot tell an escaping
343        // `render_value` from a `render_value` that stopped escaping
344        // entirely. This is the one test standing between "the printer
345        // escapes every string" (this module's whole point) and that claim
346        // quietly going false — it has to drive a hostile value *through*
347        // `print_toml_document`, not just through `escape_toml_basic_string`
348        // directly (the tests above) or a hand-built fragment (the test
349        // below).
350        let hostile = "q\nkey = \"injected";
351        let mut doc = TomlDocument::new(
352            "Generated by bynkc — do not edit by hand.",
353            vec![
354                TomlEntry::kv("name", TomlValue::str(hostile)),
355                TomlEntry::kv("main", TomlValue::str("index.ts")),
356            ],
357        );
358        doc.push_block(TomlBlock::array_table(
359            "services",
360            vec![
361                TomlEntry::kv("binding", TomlValue::str("COMMERCE_PAYMENT")),
362                TomlEntry::kv("service", TomlValue::str("commerce-payment")),
363            ],
364        ));
365        doc.push_block(TomlBlock::array_table(
366            "kv_namespaces",
367            vec![
368                TomlEntry::kv("binding", TomlValue::str("BYNK_KV")),
369                TomlEntry::with_comment(
370                    "id",
371                    TomlValue::str("<KV_NAMESPACE_ID>"),
372                    "set at deploy time",
373                ),
374            ],
375        ));
376        doc.push_block(TomlBlock::table(
377            "triggers",
378            vec![TomlEntry::kv(
379                "crons",
380                TomlValue::Array(vec![TomlValue::str("*/5 * * * *"), TomlValue::str(hostile)]),
381            )],
382        ));
383
384        let text = print_toml_document(&doc);
385        let parsed: toml::Table = text
386            .parse()
387            .unwrap_or_else(|e| panic!("printer produced invalid TOML: {e}\n{text}"));
388
389        assert_eq!(
390            parsed.len(),
391            5,
392            "the hostile `name` value injected extra root keys: {parsed:?}"
393        );
394        assert_eq!(parsed["name"].as_str(), Some(hostile));
395        assert_eq!(parsed["main"].as_str(), Some("index.ts"));
396        let services = parsed["services"].as_array().expect("services array");
397        assert_eq!(services.len(), 1);
398        assert_eq!(services[0]["binding"].as_str(), Some("COMMERCE_PAYMENT"));
399        let kv = parsed["kv_namespaces"].as_array().expect("kv array");
400        assert_eq!(kv[0]["id"].as_str(), Some("<KV_NAMESPACE_ID>"));
401        let crons = parsed["triggers"]["crons"].as_array().expect("crons array");
402        assert_eq!(crons.len(), 2, "the hostile crons element broke the array");
403        assert_eq!(crons[0].as_str(), Some("*/5 * * * *"));
404        assert_eq!(crons[1].as_str(), Some(hostile));
405
406        // Every block, including the last, is followed by exactly one blank
407        // line — the shape every current golden `wrangler.toml` fixture has.
408        assert!(text.ends_with("\n\n"));
409    }
410}