Skip to main content

bynk_emit/emitter/
wrangler.rs

1//! `wrangler.toml` generation per Worker (v0.8 §4.4 / §4.5).
2//!
3//! Each context becomes a Cloudflare Worker with its own wrangler config.
4//! Service Bindings are declared for every consumed context. Durable
5//! Object bindings + migrations are declared for every agent.
6
7use std::fmt::Write as _;
8
9use crate::project::{UnitTable, worker_dir_name};
10
11/// Compile-time pinned compatibility date. Cloudflare uses this to lock
12/// Workers runtime behaviour. Bump cautiously when changing the runtime
13/// dependencies.
14const COMPATIBILITY_DATE: &str = "2024-11-01";
15
16/// Events track, slice 0 (spine #936, ADR 0284): the class name of a
17/// publishing context's fan-out Durable Object (`emitter::events_fanout`).
18/// Shared with `emitter::workers` (the `Env` field name + `deps.
19/// __eventsDispatch` call it drives) and `emitter::events_fanout` (the class
20/// this name must actually export) so the three can never drift apart.
21///
22/// Double-underscore-prefixed, matching every other compiler-synthesised
23/// identifier in emitted output (`__events`, `__eventsDispatch`,
24/// `__makeLedger`, …) — a Bynk `agent` name can never start with `_` (a
25/// parse error, checked directly: `agent _Foo { … }` fails with
26/// `expected identifier after \`agent\`, found \`_\``), so an agent
27/// coincidentally named the same as this synthetic class is structurally
28/// impossible, not merely unlikely.
29pub(crate) const EVENTS_FANOUT_CLASS_NAME: &str = "__EventsFanout";
30
31/// Deploy-time sentinel in generated Worker configuration. The driver replaces
32/// this with the persistent Cloudflare KV namespace id immediately before a
33/// remote Wrangler command runs.
34pub const KV_NAMESPACE_ID_PLACEHOLDER: &str = "<KV_NAMESPACE_ID>";
35
36/// P6.x cutover slice 2 (#1191, narrowed from #1187): `crons`/`queues` arrive
37/// pre-collected, sorted and deduped by the caller (`project.rs`'s own
38/// `emit_wrangler_toml` call site) rather than being walked here off
39/// `table.services`. Both used to match a handler's cron-schedule kind and a
40/// service's queue-binding protocol directly, straight off the syntax tree —
41/// this file's entire raw-syntax footprint, per #1191's own grounding.
42/// `table` already only needs `agents`/`unit_table_uses_emit` (neither
43/// syntax-typed from this file's perspective), so relocating just these two
44/// matches removes `wrangler.rs` from the `ast_importers` probe outright — no
45/// `bynk-emit::ir` equivalent exists to route through instead (#1191's
46/// Framing: no project-wide `IrItem::Service` is built at the call site, and
47/// `IrHandler::kind` reuses the syntax-tree handler kind unchanged even where
48/// one is).
49pub(crate) fn emit_wrangler_toml(
50    context: &str,
51    table: &UnitTable,
52    consumes: &[String],
53    // v0.19 (C1): this Worker's closure reaches bynk.cloudflare — declare the
54    // KV namespace binding (the `id` is a deploy-time placeholder).
55    needs_kv: bool,
56    // v0.10a: every `on cron "expr"` schedule in the context, sorted+deduped.
57    // Sorting is load-bearing, not cosmetic (review of #1192): the caller
58    // walks `table.services`, a `HashMap`, so unsorted input makes
59    // `wrangler.toml` non-reproducible across runs.
60    crons: &[String],
61    // v0.10b/v0.44: every `from queue("name")` service's bound queue name,
62    // sorted+deduped (same reproducibility requirement as `crons`).
63    queues: &[String],
64    // #1187's slice 6 plumbing: `unit_table_uses_emit(table, callees)`,
65    // precomputed by the caller — passing a bare `bool` rather than the
66    // `Callee` map itself keeps this file's own hard-won zero `bynk_syntax::
67    // ast` footprint (#1191) intact; the map's own element type would have
68    // reintroduced exactly the literal spelling that slice removed.
69    uses_emit: bool,
70) -> String {
71    let name = worker_dir_name(context);
72    let mut out = String::new();
73    let _ = writeln!(out, "# Generated by bynkc — do not edit by hand.");
74    let _ = writeln!(out, "name = \"{name}\"");
75    let _ = writeln!(out, "main = \"index.ts\"");
76    let _ = writeln!(out, "compatibility_date = \"{COMPATIBILITY_DATE}\"");
77    writeln!(out).unwrap();
78
79    let mut sorted_consumes: Vec<&String> = consumes.iter().collect();
80    sorted_consumes.sort();
81    for target in &sorted_consumes {
82        let binding = consumed_binding_name(target);
83        let service = worker_dir_name(target);
84        let _ = writeln!(out, "[[services]]");
85        let _ = writeln!(out, "binding = \"{binding}\"");
86        let _ = writeln!(out, "service = \"{service}\"");
87        writeln!(out).unwrap();
88    }
89
90    if needs_kv {
91        let _ = writeln!(out, "[[kv_namespaces]]");
92        let _ = writeln!(
93            out,
94            "binding = \"{}\"",
95            bynk_check::firstparty::KV_BINDING_NAME
96        );
97        let _ = writeln!(
98            out,
99            "id = \"{KV_NAMESPACE_ID_PLACEHOLDER}\" # set at deploy time"
100        );
101        writeln!(out).unwrap();
102    }
103
104    // Agents → Durable Object bindings + migrations. Events track, slice 0
105    // (spine #936, ADR 0284): a context whose handlers emit gets its own
106    // fan-out DO folded into the same bindings/migration blocks — Cloudflare
107    // only cares that `index.ts` (this Worker's `main`) exports a class with
108    // this name, not which generated file it came from.
109    let mut class_names: Vec<String> = table.agents.keys().cloned().collect();
110    if uses_emit {
111        class_names.push(EVENTS_FANOUT_CLASS_NAME.to_string());
112    }
113    class_names.sort();
114    for class_name in &class_names {
115        let binding = agent_binding_name(class_name);
116        let _ = writeln!(out, "[[durable_objects.bindings]]");
117        let _ = writeln!(out, "name = \"{binding}\"");
118        let _ = writeln!(out, "class_name = \"{class_name}\"");
119        writeln!(out).unwrap();
120    }
121    if !class_names.is_empty() {
122        let _ = writeln!(out, "[[migrations]]");
123        let _ = writeln!(out, "tag = \"v1\"");
124        let classes: Vec<String> = class_names.iter().map(|n| format!("\"{n}\"")).collect();
125        let _ = writeln!(out, "new_classes = [{}]", classes.join(", "));
126        writeln!(out).unwrap();
127    }
128
129    // v0.10a: cron triggers. Cloudflare uses a single `[triggers]` table with a
130    // `crons` array aggregating every `on cron` schedule in the context.
131    // Already sorted+deduped by the caller (#1191).
132    if !crons.is_empty() {
133        let quoted: Vec<String> = crons
134            .iter()
135            .map(|e| format!("\"{}\"", escape_toml_basic_string(e)))
136            .collect();
137        let _ = writeln!(out, "[triggers]");
138        let _ = writeln!(out, "crons = [{}]", quoted.join(", "));
139        writeln!(out).unwrap();
140    }
141
142    // v0.10b: queue consumers. Each `on queue "name"` becomes a
143    // `[[queues.consumers]]` binding. Already sorted+deduped by the caller
144    // (#1191).
145    for name in queues {
146        let _ = writeln!(out, "[[queues.consumers]]");
147        let _ = writeln!(out, "queue = \"{}\"", escape_toml_basic_string(name));
148        let _ = writeln!(out, "max_batch_size = 10");
149        writeln!(out).unwrap();
150    }
151
152    out
153}
154
155/// Escape a source string literal for interpolation into a TOML *basic* string
156/// (the `"…"` form). Queue names and cron expressions come from user string
157/// literals, which can decode to contain `"`, `\`, newline and tab
158/// (`bynk-syntax/src/lexer.rs`) — all of which would otherwise break out of the
159/// TOML string and inject config keys. Every character we escape maps to a
160/// valid TOML compact escape; remaining control characters fall back to the
161/// `\uXXXX` form so the output is always a well-formed basic string.
162fn escape_toml_basic_string(s: &str) -> String {
163    let mut out = String::with_capacity(s.len());
164    for c in s.chars() {
165        match c {
166            '\\' => out.push_str("\\\\"),
167            '"' => out.push_str("\\\""),
168            '\n' => out.push_str("\\n"),
169            '\t' => out.push_str("\\t"),
170            '\r' => out.push_str("\\r"),
171            // Control characters have no compact TOML escape besides the ones
172            // above and must not appear raw in a basic string.
173            c if (c as u32) < 0x20 || c == '\u{7f}' => {
174                let _ = write!(out, "\\u{:04X}", c as u32);
175            }
176            c => out.push(c),
177        }
178    }
179    out
180}
181
182/// Service Binding identifier for a consumed context: uppercase with
183/// underscores. `commerce.payment` → `COMMERCE_PAYMENT`.
184pub(crate) fn consumed_binding_name(target: &str) -> String {
185    target.replace('.', "_").to_uppercase()
186}
187
188/// Durable Object binding identifier for an agent class. We use the
189/// class name in screaming snake case so handlers can grab it by a
190/// predictable name (`OrderEntity` → `ORDER_ENTITY`).
191pub(crate) fn agent_binding_name(class_name: &str) -> String {
192    let mut out = String::new();
193    for (i, ch) in class_name.chars().enumerate() {
194        if i > 0 && ch.is_uppercase() {
195            out.push('_');
196        }
197        out.push(ch.to_ascii_uppercase());
198    }
199    out
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn escape_toml_basic_string_neutralises_injection() {
208        // The trigger from the defect report: a queue name whose decoded value
209        // carries a quote + newline would otherwise close the string and inject
210        // a config key.
211        assert_eq!(
212            escape_toml_basic_string("q\nkey = \"injected"),
213            "q\\nkey = \\\"injected"
214        );
215        assert_eq!(escape_toml_basic_string("a\\b"), "a\\\\b");
216        assert_eq!(escape_toml_basic_string("a\tb"), "a\\tb");
217    }
218
219    #[test]
220    fn escape_toml_basic_string_passes_plain_values_through() {
221        // Ordinary cron expressions and queue names are untouched.
222        assert_eq!(escape_toml_basic_string("*/5 * * * *"), "*/5 * * * *");
223        assert_eq!(escape_toml_basic_string("order-events"), "order-events");
224    }
225
226    #[test]
227    fn escape_toml_basic_string_escapes_other_control_chars() {
228        // A NUL has no compact escape and must not appear raw in a basic string.
229        assert_eq!(escape_toml_basic_string("a\u{0}b"), "a\\u0000b");
230        assert_eq!(escape_toml_basic_string("a\u{7f}b"), "a\\u007Fb");
231    }
232
233    #[test]
234    fn escaped_value_is_valid_toml_and_round_trips() {
235        // The security invariant, enforced by a real TOML parser (not a golden
236        // byte-compare): interpolating the escaped value produces a well-formed
237        // single-key table whose decoded value is *exactly* the input — no
238        // injected keys, no broken string. Covers the injection payload from the
239        // defect report plus a control char that takes the `\uXXXX` fallback.
240        for input in ["q\nkey = \"injected", "*/5 * * * *\\\"", "a\u{0}b\ttail"] {
241            let doc = format!("queue = \"{}\"", escape_toml_basic_string(input));
242            let table: toml::Table = doc
243                .parse()
244                .unwrap_or_else(|e| panic!("escaped {input:?} is invalid TOML: {e} ({doc:?})"));
245            assert_eq!(
246                table.len(),
247                1,
248                "escaped {input:?} injected extra keys: {table:?}"
249            );
250            assert_eq!(
251                table["queue"].as_str(),
252                Some(input),
253                "escaped {input:?} did not round-trip"
254            );
255        }
256    }
257}