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 /// #1796: `[parent.key]`, where `key` is chosen at emit time rather than
91 /// fixed by the generator (a Durable Object class name under `exports`).
92 /// The printer renders `key` bare when it is a TOML bare key and as an
93 /// escaped basic string otherwise, so a key, like a value, can't break
94 /// out of its header whatever it contains.
95 KeyedTable(&'static str, String),
96}
97
98impl TomlBlock {
99 pub(crate) fn table(path: &'static str, entries: Vec<TomlEntry>) -> Self {
100 Self {
101 header: TomlHeader::Table(path),
102 entries,
103 }
104 }
105
106 pub(crate) fn array_table(path: &'static str, entries: Vec<TomlEntry>) -> Self {
107 Self {
108 header: TomlHeader::ArrayTable(path),
109 entries,
110 }
111 }
112
113 /// `[parent.key]` — one entry of a table keyed by a runtime name, such as
114 /// `[exports.Alpha]` (#1796).
115 pub(crate) fn keyed_table(
116 parent: &'static str,
117 key: impl Into<String>,
118 entries: Vec<TomlEntry>,
119 ) -> Self {
120 Self {
121 header: TomlHeader::KeyedTable(parent, key.into()),
122 entries,
123 }
124 }
125}
126
127/// One `key = value` line, with an optional trailing `# comment` — TOML's
128/// own comment syntax, not an escape hatch; `wrangler.toml`'s one instance
129/// today is the KV namespace id's `# set at deploy time`.
130pub(crate) struct TomlEntry {
131 key: &'static str,
132 value: TomlValue,
133 comment: Option<&'static str>,
134}
135
136impl TomlEntry {
137 pub(crate) fn kv(key: &'static str, value: TomlValue) -> Self {
138 Self {
139 key,
140 value,
141 comment: None,
142 }
143 }
144
145 pub(crate) fn with_comment(key: &'static str, value: TomlValue, comment: &'static str) -> Self {
146 Self {
147 key,
148 value,
149 comment: Some(comment),
150 }
151 }
152}
153
154/// Exactly the value shapes `wrangler.toml` generation writes today — a
155/// basic string (always escaped on render, unconditionally), a bare
156/// integer, a bool, an array (rendered as `[a, b, …]`, TOML's inline-array
157/// form), and an inline table (`{ k = v, … }`, standard-library P3's
158/// `simple = { limit = 100, period = 60 }`). No float and no nesting beyond
159/// one section level: none of those appear in the current output, and
160/// widening this when a real future value needs it is cheap.
161pub(crate) enum TomlValue {
162 Str(String),
163 Int(i64),
164 /// #1883, slice 1b: `[assets] run_worker_first = false`.
165 Bool(bool),
166 Array(Vec<TomlValue>),
167 /// P3 (#2342): an inline table, its keys bare, in the order given.
168 InlineTable(Vec<(&'static str, TomlValue)>),
169}
170
171impl TomlValue {
172 pub(crate) fn str(s: impl Into<String>) -> Self {
173 Self::Str(s.into())
174 }
175}
176
177/// Render `doc` to TOML text. The one function in this module — and, per
178/// this file's own module doc, in `bynk-emit`'s TOML-producing surface —
179/// that calls `write!`/`writeln!`/`format!` to build TOML syntax.
180pub fn print_toml_document(doc: &TomlDocument) -> String {
181 let mut out = String::new();
182 let _ = writeln!(out, "# {}", doc.header_comment);
183 print_entries(&mut out, &doc.root);
184 let _ = writeln!(out);
185 for block in &doc.blocks {
186 match &block.header {
187 TomlHeader::Table(path) => {
188 let _ = writeln!(out, "[{path}]");
189 }
190 TomlHeader::ArrayTable(path) => {
191 let _ = writeln!(out, "[[{path}]]");
192 }
193 TomlHeader::KeyedTable(parent, key) => {
194 let _ = writeln!(out, "[{parent}.{}]", render_key(key));
195 }
196 }
197 print_entries(&mut out, &block.entries);
198 let _ = writeln!(out);
199 }
200 out
201}
202
203fn print_entries(out: &mut String, entries: &[TomlEntry]) {
204 for entry in entries {
205 let value = render_value(&entry.value);
206 match entry.comment {
207 Some(comment) => {
208 let _ = writeln!(out, "{} = {value} # {comment}", entry.key);
209 }
210 None => {
211 let _ = writeln!(out, "{} = {value}", entry.key);
212 }
213 }
214 }
215}
216
217/// A key segment, bare when TOML allows it (`A-Za-z0-9_-`, non-empty) and a
218/// quoted basic string otherwise — the same escaping [`render_value`] gives
219/// every string value.
220fn render_key(key: &str) -> String {
221 let bare = !key.is_empty()
222 && key
223 .chars()
224 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
225 if bare {
226 key.to_string()
227 } else {
228 format!("\"{}\"", escape_toml_basic_string(key))
229 }
230}
231
232fn render_value(value: &TomlValue) -> String {
233 match value {
234 TomlValue::Str(s) => format!("\"{}\"", escape_toml_basic_string(s)),
235 TomlValue::Int(n) => n.to_string(),
236 TomlValue::Bool(b) => b.to_string(),
237 TomlValue::Array(items) => {
238 let rendered: Vec<String> = items.iter().map(render_value).collect();
239 format!("[{}]", rendered.join(", "))
240 }
241 TomlValue::InlineTable(entries) => {
242 let rendered: Vec<String> = entries
243 .iter()
244 .map(|(k, v)| format!("{} = {}", render_key(k), render_value(v)))
245 .collect();
246 format!("{{ {} }}", rendered.join(", "))
247 }
248 }
249}
250
251/// Escape a source string literal for interpolation into a TOML *basic*
252/// string (the `"…"` form). Queue names and cron expressions come from user
253/// string literals, which can decode to contain `"`, `\`, newline and tab
254/// (`bynk-syntax/src/lexer.rs`) — all of which would otherwise break out of
255/// the TOML string and inject config keys. Every character we escape maps
256/// to a valid TOML compact escape; remaining control characters fall back
257/// to the `\uXXXX` form so the output is always a well-formed basic string.
258///
259/// Applied unconditionally by [`render_value`] to every [`TomlValue::Str`] —
260/// not just the values a caller happens to know are user-supplied. Relocated
261/// here from `emitter/wrangler.rs` (P7.3, #1303): escaping is the printer's
262/// job now, applied structurally to every string this module renders, not a
263/// per-call-site judgement about which particular value might need it.
264fn escape_toml_basic_string(s: &str) -> String {
265 let mut out = String::with_capacity(s.len());
266 for c in s.chars() {
267 match c {
268 '\\' => out.push_str("\\\\"),
269 '"' => out.push_str("\\\""),
270 '\n' => out.push_str("\\n"),
271 '\t' => out.push_str("\\t"),
272 '\r' => out.push_str("\\r"),
273 // Control characters have no compact TOML escape besides the ones
274 // above and must not appear raw in a basic string.
275 c if (c as u32) < 0x20 || c == '\u{7f}' => {
276 let _ = write!(out, "\\u{:04X}", c as u32);
277 }
278 c => out.push(c),
279 }
280 }
281 out
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn escape_toml_basic_string_neutralises_injection() {
290 // The trigger from the defect report: a queue name whose decoded value
291 // carries a quote + newline would otherwise close the string and inject
292 // a config key.
293 assert_eq!(
294 escape_toml_basic_string("q\nkey = \"injected"),
295 "q\\nkey = \\\"injected"
296 );
297 assert_eq!(escape_toml_basic_string("a\\b"), "a\\\\b");
298 assert_eq!(escape_toml_basic_string("a\tb"), "a\\tb");
299 }
300
301 #[test]
302 fn set_main_changes_only_the_main_entry() {
303 let mut doc = TomlDocument::new(
304 "Generated by bynkc — do not edit by hand.",
305 vec![
306 TomlEntry::kv("name", TomlValue::str("api")),
307 TomlEntry::kv("main", TomlValue::str("index.ts")),
308 ],
309 );
310 doc.push_block(TomlBlock::table(
311 "triggers",
312 vec![TomlEntry::kv(
313 "crons",
314 TomlValue::Array(vec![TomlValue::str("*/5 * * * *")]),
315 )],
316 ));
317
318 assert!(doc.set_main("index.js"));
319
320 let text = print_toml_document(&doc);
321 let parsed: toml::Table = text.parse().expect("valid TOML");
322 assert_eq!(parsed["main"].as_str(), Some("index.js"));
323 assert_eq!(parsed["name"].as_str(), Some("api"));
324 let crons = parsed["triggers"]["crons"].as_array().expect("crons");
325 assert_eq!(crons[0].as_str(), Some("*/5 * * * *"));
326 }
327
328 #[test]
329 fn set_main_reports_a_missing_root_main_entry_rather_than_swallowing_it() {
330 let mut doc = TomlDocument::new(
331 "Generated by bynkc — do not edit by hand.",
332 vec![TomlEntry::kv("name", TomlValue::str("api"))],
333 );
334 assert!(!doc.set_main("index.js"));
335 let parsed: toml::Table = print_toml_document(&doc).parse().expect("valid TOML");
336 assert!(parsed.get("main").is_none());
337 }
338
339 #[test]
340 fn escape_toml_basic_string_passes_plain_values_through() {
341 // Ordinary cron expressions and queue names are untouched.
342 assert_eq!(escape_toml_basic_string("*/5 * * * *"), "*/5 * * * *");
343 assert_eq!(escape_toml_basic_string("order-events"), "order-events");
344 }
345
346 #[test]
347 fn escape_toml_basic_string_escapes_other_control_chars() {
348 // A NUL has no compact escape and must not appear raw in a basic string.
349 assert_eq!(escape_toml_basic_string("a\u{0}b"), "a\\u0000b");
350 assert_eq!(escape_toml_basic_string("a\u{7f}b"), "a\\u007Fb");
351 }
352
353 #[test]
354 fn escaped_value_is_valid_toml_and_round_trips() {
355 // The security invariant, enforced by a real TOML parser (not a golden
356 // byte-compare): interpolating the escaped value produces a well-formed
357 // single-key table whose decoded value is *exactly* the input — no
358 // injected keys, no broken string. Covers the injection payload from the
359 // defect report plus a control char that takes the `\uXXXX` fallback.
360 for input in ["q\nkey = \"injected", "*/5 * * * *\\\"", "a\u{0}b\ttail"] {
361 let doc = format!("queue = \"{}\"", escape_toml_basic_string(input));
362 let table: toml::Table = doc
363 .parse()
364 .unwrap_or_else(|e| panic!("escaped {input:?} is invalid TOML: {e} ({doc:?})"));
365 assert_eq!(
366 table.len(),
367 1,
368 "escaped {input:?} injected extra keys: {table:?}"
369 );
370 assert_eq!(
371 table["queue"].as_str(),
372 Some(input),
373 "escaped {input:?} did not round-trip"
374 );
375 }
376 }
377
378 #[test]
379 fn print_toml_document_renders_a_representative_document_and_round_trips() {
380 // Not a golden byte-compare (that's `bless_positive_fixtures`'s job) —
381 // this proves the printer's *general* shape (root block, a `[[…]]`
382 // array table, a `[…]` table, an array value, a commented entry) is
383 // well-formed TOML a real parser accepts, with every value surviving
384 // exactly. `TomlBlock`/`TomlEntry` construction mirrors
385 // `emit_wrangler_toml`'s own shape one-for-one.
386 //
387 // Review of #1304, finding 1: the `name` and the one `crons` element
388 // below are the injection payload from the defect report, not an
389 // ordinary identifier — every value the *golden* corpus carries today
390 // is compiler-derived and never needs escaping, which means a
391 // zero-diff `bless_positive_fixtures` run cannot tell an escaping
392 // `render_value` from a `render_value` that stopped escaping
393 // entirely. This is the one test standing between "the printer
394 // escapes every string" (this module's whole point) and that claim
395 // quietly going false — it has to drive a hostile value *through*
396 // `print_toml_document`, not just through `escape_toml_basic_string`
397 // directly (the tests above) or a hand-built fragment (the test
398 // below).
399 let hostile = "q\nkey = \"injected";
400 let mut doc = TomlDocument::new(
401 "Generated by bynkc — do not edit by hand.",
402 vec![
403 TomlEntry::kv("name", TomlValue::str(hostile)),
404 TomlEntry::kv("main", TomlValue::str("index.ts")),
405 ],
406 );
407 doc.push_block(TomlBlock::array_table(
408 "services",
409 vec![
410 TomlEntry::kv("binding", TomlValue::str("COMMERCE_PAYMENT")),
411 TomlEntry::kv("service", TomlValue::str("commerce-payment")),
412 ],
413 ));
414 doc.push_block(TomlBlock::array_table(
415 "kv_namespaces",
416 vec![
417 TomlEntry::kv("binding", TomlValue::str("BYNK_KV")),
418 TomlEntry::with_comment(
419 "id",
420 TomlValue::str("<KV_NAMESPACE_ID>"),
421 "set at deploy time",
422 ),
423 ],
424 ));
425 doc.push_block(TomlBlock::table(
426 "triggers",
427 vec![TomlEntry::kv(
428 "crons",
429 TomlValue::Array(vec![TomlValue::str("*/5 * * * *"), TomlValue::str(hostile)]),
430 )],
431 ));
432
433 let text = print_toml_document(&doc);
434 let parsed: toml::Table = text
435 .parse()
436 .unwrap_or_else(|e| panic!("printer produced invalid TOML: {e}\n{text}"));
437
438 assert_eq!(
439 parsed.len(),
440 5,
441 "the hostile `name` value injected extra root keys: {parsed:?}"
442 );
443 assert_eq!(parsed["name"].as_str(), Some(hostile));
444 assert_eq!(parsed["main"].as_str(), Some("index.ts"));
445 let services = parsed["services"].as_array().expect("services array");
446 assert_eq!(services.len(), 1);
447 assert_eq!(services[0]["binding"].as_str(), Some("COMMERCE_PAYMENT"));
448 let kv = parsed["kv_namespaces"].as_array().expect("kv array");
449 assert_eq!(kv[0]["id"].as_str(), Some("<KV_NAMESPACE_ID>"));
450 let crons = parsed["triggers"]["crons"].as_array().expect("crons array");
451 assert_eq!(crons.len(), 2, "the hostile crons element broke the array");
452 assert_eq!(crons[0].as_str(), Some("*/5 * * * *"));
453 assert_eq!(crons[1].as_str(), Some(hostile));
454
455 // Every block, including the last, is followed by exactly one blank
456 // line — the shape every current golden `wrangler.toml` fixture has.
457 assert!(text.ends_with("\n\n"));
458 }
459
460 #[test]
461 fn keyed_table_keys_are_bare_when_they_can_be_and_quoted_otherwise() {
462 // #1796: `[exports.<Class>]`. A Bynk class name is always a bare key
463 // (`__EventsFanout` included), but the printer, not the caller, owns
464 // that guarantee, so a hostile key must stay inside its header too.
465 let hostile = "a.b]\nx = \"y";
466 let mut doc = TomlDocument::new("t", vec![]);
467 for key in ["Alpha", "__EventsFanout", hostile] {
468 doc.push_block(TomlBlock::keyed_table(
469 "exports",
470 key,
471 vec![TomlEntry::kv("type", TomlValue::str("durable-object"))],
472 ));
473 }
474 let text = print_toml_document(&doc);
475 assert!(text.contains("[exports.Alpha]\n"), "{text}");
476 assert!(text.contains("[exports.__EventsFanout]\n"), "{text}");
477
478 let parsed: toml::Table = text
479 .parse()
480 .unwrap_or_else(|e| panic!("printer produced invalid TOML: {e}\n{text}"));
481 assert_eq!(parsed.len(), 1, "a key escaped its header: {parsed:?}");
482 let exports = parsed["exports"].as_table().expect("exports table");
483 assert_eq!(exports.len(), 3);
484 assert_eq!(exports[hostile]["type"].as_str(), Some("durable-object"));
485 }
486}