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}