bynk_emit/emitter/secrets.rs
1//! `bynk-secrets.json` generation per Worker (v0.172, ADR 0195).
2//!
3//! The **declared** secret names a context's handlers will read from `env` at
4//! runtime — an `actor`'s `auth = Bearer(secret = "…")` / `Signature(secret =
5//! "…")`, including the members of a multi-actor sum. `deploy` reads this file
6//! to know which secrets it must see set before it pushes.
7//!
8//! The file carries **two kinds of knowledge, which are not equally strong**
9//! (ADR 0196):
10//!
11//! - `declared` — an `actor`'s auth secret. A literal fixed at parse time,
12//! required at compile time, and **fail-closed**: unset, the Worker answers
13//! 401 to every request. `deploy` refuses to ship without a value.
14//! - `read` — a literal `bynk.Secrets` name (`Secrets.get("X")`). `get` returns
15//! `Option`, so absence is a legitimate handled outcome — these are
16//! **advisory**, and `deploy` warns rather than failing.
17//!
18//! And `read_complete`, which is the honesty. `Secrets.get` takes an ordinary
19//! `String` expression, so a computed name is invisible to any pass: where one
20//! is seen, the context warns (`bynk.secrets.computed_name`) and this flag goes
21//! false. `declared` is a **floor, not a census** (ADR 0195 D2); `read` is a
22//! census only while `read_complete` holds, and says so when it does not.
23//!
24//! Why a file rather than an API: the driver has two compile paths, and under a
25//! `bynkc` override the compiler is a child process handing back an exit status
26//! — there is no in-memory model to consult. A name the compiler knows must
27//! reach the driver in the build output, or not at all (ADR 0195 D5).
28//!
29//! P5.5 (`design/tracks/semantics-in-the-checker.md` §6, §9): the checking
30//! half — `SecretReads`, the `Secrets.get` AST walk, and the
31//! `bynk.secrets.computed_name` diagnostic itself — moved to
32//! `bynk_check::secrets`, a real gap this track's settling pass had not
33//! scoped (see that module's own doc). What stays here is emission-only:
34//! rendering the manifest `bynk deploy` reads, which is not this crate's
35//! business to ask `bynk-check` to do.
36
37use std::collections::BTreeSet;
38
39use bynk_project::json_string;
40
41use bynk_check::actors::SumMemberSeam;
42use bynk_check::secrets::SecretReads;
43
44use crate::project::UnitTable;
45
46/// The file the driver reads, beside each Worker's `wrangler.toml`.
47pub const SECRETS_MANIFEST: &str = "bynk-secrets.json";
48
49/// The manifest schema version. Bumped only by a breaking shape change; the
50/// driver refuses a version it does not know rather than guessing, as the
51/// deploy ledger does.
52///
53/// **2** (v0.173, ADR 0196) added `read` and `read_complete`. The bump is
54/// deliberate rather than a default-on-absence read: a v1 manifest carries no
55/// evidence either way about computed names, and defaulting `read_complete` to
56/// `true` for it would be the manifest's one claim that could be silently wrong.
57const MANIFEST_VERSION: u32 = 2;
58
59/// Every secret name this context's handlers will read from `env`.
60///
61/// Enumerated over exactly the handlers the entry emitter lowers seams for
62/// (`table.services`, which is where a `from websocket` service lives too), and
63/// resolved with exactly the same `bynk_check::actors` functions — so the
64/// manifest cannot describe a Worker other than the one emitted beside it. An
65/// actor that is declared but named by no handler's `by` clause resolves no
66/// seam and contributes nothing: the Worker never reads it.
67///
68/// `Oidc` names no secret — its trust root is the provider's published JWKS, not
69/// a shared value — and a sum's `None` member (a catch-all such as `Visitor`)
70/// verifies nothing. Both are skipped rather than defaulted: inventing a name
71/// for them would ask the user to set a secret that nothing reads.
72pub(crate) fn declared_secrets(table: &UnitTable) -> BTreeSet<String> {
73 let mut names = BTreeSet::new();
74 for handler in table.services.values().flat_map(|s| s.handlers.iter()) {
75 if let Some(seam) = bynk_check::actors::bearer_seam_for(handler, &table.actors) {
76 names.insert(seam.secret);
77 }
78 if let Some(seam) = bynk_check::actors::signature_seam_for(handler, &table.actors) {
79 names.insert(seam.secret);
80 }
81 for member in bynk_check::actors::sum_members_for(handler, &table.actors)
82 .into_iter()
83 .flatten()
84 {
85 match member.seam {
86 SumMemberSeam::Bearer { secret, .. } => {
87 names.insert(secret);
88 }
89 SumMemberSeam::Signature(seam) => {
90 names.insert(seam.secret);
91 }
92 SumMemberSeam::None => {}
93 }
94 }
95 }
96 names
97}
98
99/// The reads half of what a context knows about its own `bynk.Secrets` use —
100/// `declared_secrets`'s counterpart, read from the AST rather than derived
101/// from actor bindings. The walk itself, its `bynk.secrets.computed_name`
102/// diagnostic, and the `reads_secrets_of_bynk` capability-resolution guard
103/// now live in [`bynk_check::secrets::secret_reads_of`] (P5.5) — this wrapper
104/// calls it qualified rather than duplicating it, discarding the warnings
105/// `build_output` has no use for (`run_checks` raises them, via
106/// `bynk_check::project_model::phase_secrets_computed_name`, its own caller
107/// of the same function).
108pub(crate) fn secret_reads(
109 table: &UnitTable,
110 flattened: &std::collections::HashMap<String, String>,
111) -> (SecretReads, Vec<bynk_syntax::CompileError>) {
112 bynk_check::secrets::secret_reads_of(
113 table.services.values().flat_map(|s| s.handlers.iter()),
114 flattened,
115 )
116}
117
118/// Render the manifest for a context, or `None` when there is nothing to say.
119///
120/// Emitted when **anything** is known ([DECISION E]): a declared secret, a read
121/// name, or the fact that a name is computed. Slice 3's rule — emit only for a
122/// non-empty `declared` — would have stayed silent for a context that reads
123/// secrets but declares none, which is exactly the context this file now exists
124/// to describe.
125pub(crate) fn emit_secrets_manifest(table: &UnitTable, reads: &SecretReads) -> Option<String> {
126 render(&declared_secrets(table), reads)
127}
128
129/// Render a resolved name set. Split from the derivation so the file's shape is
130/// tested without building a project model.
131///
132/// Absent rather than empty: a project with no declared secret must not grow a
133/// file into every worker directory for a feature it does not use — and "no
134/// file" is the same answer as "an empty list" to a driver that must tolerate a
135/// build tree from a compiler predating this file anyway.
136fn render(declared: &BTreeSet<String>, reads: &SecretReads) -> Option<String> {
137 // `complete` is the third thing worth saying: a context that reads one
138 // computed name and nothing else knows something — that it does not know —
139 // and the file has to carry it or the driver cannot.
140 if declared.is_empty() && reads.names.is_empty() && reads.complete {
141 return None;
142 }
143 // Hand-rendered rather than via serde: this crate does not depend on
144 // serde_json, and the shape is three fields.
145 Some(format!(
146 "{{\n \"version\": {MANIFEST_VERSION},\n \"declared\": {},\n \"read\": {},\n \"read_complete\": {}\n}}\n",
147 json_array(declared),
148 json_array(&reads.names),
149 reads.complete,
150 ))
151}
152
153/// A JSON array of names, one per line, or `[]`.
154fn json_array(names: &BTreeSet<String>) -> String {
155 if names.is_empty() {
156 return "[]".to_string();
157 }
158 let rendered: Vec<String> = names
159 .iter()
160 .map(|n| format!(" {}", json_string(n)))
161 .collect();
162 format!("[\n{}\n ]", rendered.join(",\n"))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn set(names: &[&str]) -> BTreeSet<String> {
170 names.iter().map(|n| n.to_string()).collect()
171 }
172
173 #[test]
174 fn a_name_is_escaped_as_a_json_string() {
175 assert_eq!(json_string("AUTH_JWT_SECRET"), "\"AUTH_JWT_SECRET\"");
176 // A secret name is a Bynk string literal, so it is arbitrary text; the
177 // manifest must stay parseable rather than usually-parseable.
178 assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
179 assert_eq!(json_string("a\\b"), "\"a\\\\b\"");
180 assert_eq!(json_string("a\nb"), "\"a\\nb\"");
181 assert_eq!(json_string("a\u{1}b"), "\"a\\u0001b\"");
182 }
183
184 fn reads(names: &[&str], complete: bool) -> SecretReads {
185 SecretReads {
186 names: set(names),
187 complete,
188 }
189 }
190
191 /// [DECISION E]: the file appears when **anything** is known, and only then.
192 #[test]
193 fn a_manifest_appears_when_anything_is_known_and_not_otherwise() {
194 // Nothing at all — no file. A project with no secrets must not grow one
195 // into every worker directory.
196 assert_eq!(render(&set(&[]), &reads(&[], true)), None);
197
198 // A read with no declared secret is exactly the context slice 3's rule
199 // would have stayed silent about — and exactly the one worth describing.
200 assert!(render(&set(&[]), &reads(&["API_KEY"], true)).is_some());
201
202 // Knowing that you *don't* know is knowledge too: a context whose only
203 // secret is computed still emits, or the driver cannot say its list is
204 // incomplete.
205 assert!(render(&set(&[]), &reads(&[], false)).is_some());
206 }
207
208 /// The committed shape, byte for byte — it is a file a reviewer reads in a
209 /// fixture diff and a driver parses. Asserted here rather than round-tripped
210 /// through a parser because this crate deliberately carries three
211 /// dependencies and `serde_json` is not among them; that the bytes *parse*
212 /// is asserted driver-side, where the reader lives.
213 #[test]
214 fn the_manifest_is_sorted_and_pinned() {
215 // A `BTreeSet` orders the names, so the file is byte-stable for a given
216 // context rather than dependent on handler iteration order.
217 assert_eq!(
218 render(&set(&["B_SECRET", "A_SECRET"]), &reads(&["R"], true))
219 .expect("a non-empty set renders"),
220 "{\n \"version\": 2,\n \"declared\": [\n \"A_SECRET\",\n \"B_SECRET\"\n ],\n \
221 \"read\": [\n \"R\"\n ],\n \"read_complete\": true\n}\n",
222 );
223 // The empty-list and false-flag shapes, which the fixtures also carry.
224 assert_eq!(
225 render(&set(&["ONLY"]), &reads(&[], false)).expect("renders"),
226 "{\n \"version\": 2,\n \"declared\": [\n \"ONLY\"\n ],\n \
227 \"read\": [],\n \"read_complete\": false\n}\n",
228 );
229 }
230}