Skip to main content

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}