Skip to main content

bynk_check/
secrets.rs

1//! `bynk.Secrets` read-name checking (v0.173, ADR 0196 D1).
2//!
3//! `Secrets.get` takes an ordinary `String` expression, so a computed name is
4//! invisible to any pass. Where one is seen, this module warns
5//! (`bynk.secrets.computed_name`) — a non-failing diagnostic (ADR 0117): the
6//! program is correct, `bynk deploy` simply cannot know which secret the
7//! context reads, and cannot list it in the deploy plan.
8//!
9//! P5.5 (`design/tracks/semantics-in-the-checker.md` §6, §9): relocated here
10//! from `bynk-emit/src/emitter/secrets.rs` — a real, `CompileError::new`-
11//! constructed diagnostic, previously raised only from `bynk-emit::project`'s
12//! `run_checks`, and (per that call site's own now-stale comment) never
13//! reachable from [`crate::analysis::analyse_project`] at all, since
14//! `bynk-check` cannot depend on `bynk-emit`. §9 named this an open risk
15//! rather than a scoped relocation; this module closes it, per R3.5. The
16//! manifest-only half — `declared_secrets`/`emit_secrets_manifest`/`render`,
17//! which describe `bynk-secrets.json` rather than diagnose anything — stays
18//! in `bynk-emit`, an emission concern this crate must not depend on. Its
19//! caller there now reaches [`secret_reads_of`] here, qualified, rather than
20//! duplicating the walk (the same dual-use pattern P5.4 used for
21//! `bynk-check::test_suites`).
22
23use std::collections::{HashMap, HashSet};
24
25use bynk_syntax::ast::{Block, CapRef, Expr, ExprKind, ProviderDecl};
26use bynk_syntax::error::CompileError;
27use bynk_syntax::span::Span;
28
29use crate::symbols::UnitTable;
30
31/// The capability whose `get` names a platform secret. Matched against the
32/// capability a context actually resolved, never against the spelling — see
33/// [`reads_secrets_of_bynk`].
34const SECRETS_CAPABILITY: &str = "Secrets";
35
36/// What a context's handlers read through `bynk.Secrets`.
37#[derive(Debug, Default, PartialEq, Eq)]
38pub struct SecretReads {
39    /// Literal names, sorted. A census only while `complete` holds.
40    pub names: std::collections::BTreeSet<String>,
41    /// False when at least one `Secrets.get` argument was not a literal, so no
42    /// pass could know the name.
43    pub complete: bool,
44}
45
46impl SecretReads {
47    /// A context that reads nothing: complete by vacuity, and the shape a
48    /// non-`bynk.Secrets` context gets without walking a single expression.
49    fn none() -> Self {
50        Self {
51            names: std::collections::BTreeSet::new(),
52            complete: true,
53        }
54    }
55}
56
57/// Does this context's `Secrets` resolve to **`bynk`**'s?
58///
59/// `flattened` maps a context's in-scope capability name to the unit
60/// providing it, so this asks the question that matters — *whose* `Secrets`
61/// is this? — rather than matching the identifier. A context with no
62/// `Secrets` at all answers `false` here and never gets walked.
63fn reads_secrets_of_bynk(flattened: &std::collections::HashMap<String, String>) -> bool {
64    flattened
65        .get(SECRETS_CAPABILITY)
66        .is_some_and(|unit| unit == crate::firstparty::BYNK_UNIT)
67}
68
69/// The literal `bynk.Secrets` names this context's handlers read, and whether
70/// that list is everything.
71///
72/// Split so callers can scope it differently without the *rule* differing:
73/// `bynk-emit`'s manifest wants a context's whole handler set (its names span
74/// every file), while this warning wants one file's handlers at a time,
75/// because a diagnostic sink attributes a diagnostic to a path and a merged
76/// handler set has thrown that away.
77pub fn secret_reads_of<'a>(
78    handlers: impl Iterator<Item = &'a bynk_syntax::ast::Handler>,
79    flattened: &std::collections::HashMap<String, String>,
80) -> (SecretReads, Vec<CompileError>) {
81    if !reads_secrets_of_bynk(flattened) {
82        return (SecretReads::none(), Vec::new());
83    }
84    let mut reads = SecretReads::none();
85    let mut warnings = Vec::new();
86    for handler in handlers {
87        walk_block(&handler.body, &mut reads, &mut warnings);
88    }
89    (reads, warnings)
90}
91
92/// [`secret_reads_of`] over a provider's operation bodies — the per-file
93/// warning path's twin for `provides … given Secrets { … }` (#1976).
94pub fn provider_secret_reads_of<'a>(
95    providers: impl Iterator<Item = &'a ProviderDecl>,
96    flattened: &HashMap<String, String>,
97) -> (SecretReads, Vec<CompileError>) {
98    if !reads_secrets_of_bynk(flattened) {
99        return (SecretReads::none(), Vec::new());
100    }
101    let mut reads = SecretReads::none();
102    let mut warnings = Vec::new();
103    for op in providers.flat_map(|p| p.ops.iter()) {
104        walk_block(&op.body, &mut reads, &mut warnings);
105    }
106    (reads, warnings)
107}
108
109/// Everything a **Worker** reads through `bynk.Secrets`: its own handlers
110/// (services and agents) and every provider its composition root builds
111/// (#1976).
112///
113/// The provider set mirrors what `bynk-emit`'s `emit_worker_compose`
114/// instantiates — every local provider, every capability a handler or local
115/// provider is `given`, every derived instance — closed transitively over each
116/// provider's own `given`, resolved in the provider's **own** unit exactly as
117/// `instantiate_provider_ts_expr` resolves it. Walking only handler bodies
118/// missed a `Secrets.get("X")` one `given` away, while the manifest still said
119/// `read_complete: true`.
120///
121/// An *external* provider — an adapter's binding — `given` `bynk`'s `Secrets`
122/// reads names no pass can see: its body is TypeScript. Such a Worker's list
123/// is not a census, so `complete` goes false. No warning is raised for it:
124/// nothing is computed, the reads are simply out of the compiler's sight.
125pub fn worker_secret_reads(
126    context: &str,
127    unit_tables: &HashMap<String, UnitTable>,
128    unit_consumes: &HashMap<String, Vec<String>>,
129    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
130    unit_flattened: &HashMap<String, HashMap<String, String>>,
131) -> SecretReads {
132    let empty_flat = HashMap::new();
133    let flat_of = |unit: &str| unit_flattened.get(unit).unwrap_or(&empty_flat);
134    let Some(table) = unit_tables.get(context) else {
135        return SecretReads::none();
136    };
137    let handlers = table
138        .services
139        .values()
140        .flat_map(|s| s.handlers.iter())
141        .chain(table.agents.values().flat_map(|a| a.handlers.iter()));
142    let (mut reads, _) = secret_reads_of(handlers.clone(), flat_of(context));
143
144    // Where `unit`'s `given` capability is provided — `instantiate_provider_
145    // ts_expr`'s rule: a prefix names a consumed unit, a bare name is the
146    // unit it was flattened from, and either falls back to `unit` itself.
147    let resolve = |unit: &str, given: &CapRef| -> (String, String) {
148        let target = match &given.context {
149            Some(prefix) => crate::project_model::resolve_consume_prefix(
150                &prefix.joined(),
151                unit_consumes.get(unit).map(Vec::as_slice).unwrap_or(&[]),
152                unit_consumes_aliases.get(unit).unwrap_or(&HashMap::new()),
153            ),
154            None => flat_of(unit).get(&given.name.name).cloned(),
155        };
156        (
157            target.unwrap_or_else(|| unit.to_string()),
158            given.name.name.clone(),
159        )
160    };
161
162    let mut pending: Vec<(String, String)> = Vec::new();
163    pending.extend(
164        table
165            .providers
166            .keys()
167            .map(|cap| (context.to_string(), cap.clone())),
168    );
169    for given in handlers.flat_map(|h| h.given.iter()) {
170        pending.push(resolve(context, given));
171    }
172    for inst in table.derived.values() {
173        pending.push((inst.base_unit.clone(), inst.base_cap.clone()));
174    }
175
176    let mut seen: HashSet<(String, String)> = HashSet::new();
177    while let Some((unit, cap)) = pending.pop() {
178        if !seen.insert((unit.clone(), cap.clone())) {
179            continue;
180        }
181        let Some(provider) = unit_tables.get(&unit).and_then(|t| t.providers.get(&cap)) else {
182            continue;
183        };
184        let deps: Vec<(String, String)> =
185            provider.given.iter().map(|g| resolve(&unit, g)).collect();
186        if provider.external {
187            if deps
188                .iter()
189                .any(|(u, c)| u == crate::firstparty::BYNK_UNIT && c == SECRETS_CAPABILITY)
190            {
191                reads.complete = false;
192            }
193        } else {
194            let (found, _) = provider_secret_reads_of(std::iter::once(provider), flat_of(&unit));
195            reads.names.extend(found.names);
196            reads.complete &= found.complete;
197        }
198        pending.extend(deps);
199    }
200    reads
201}
202
203/// Every expression in a handler body.
204///
205/// Reuses `statement_exprs` rather than matching `Statement` here: a
206/// `Secrets.get` in a `let`, an `expect`, a `~>` send or a bare `do` is still a
207/// read, and re-enumerating the statement kinds would be a second place to
208/// forget one the day a new kind lands.
209fn walk_block(block: &Block, reads: &mut SecretReads, warnings: &mut Vec<CompileError>) {
210    let mut exprs: Vec<&Expr> = Vec::new();
211    for statement in &block.statements {
212        bynk_syntax::ast::statement_exprs(statement, &mut exprs);
213    }
214    exprs.push(&block.tail);
215    for e in exprs {
216        walk_expr(e, reads, warnings);
217    }
218}
219
220/// Visit `e` and everything under it, recording each `Secrets.get` call.
221///
222/// Recurses through [`bynk_syntax::ast::expr_children`] rather than re-matching
223/// every `ExprKind`: a `Secrets.get` inside a `match` arm, a lambda, or an
224/// interpolation hole is still a read, and a hand-rolled visitor would be a
225/// second place to forget that.
226fn walk_expr(e: &Expr, reads: &mut SecretReads, warnings: &mut Vec<CompileError>) {
227    if let ExprKind::MethodCall {
228        receiver,
229        method,
230        args,
231        ..
232    } = &e.kind
233        && method.name == "get"
234        && matches!(&receiver.kind, ExprKind::Ident(name) if name.name == SECRETS_CAPABILITY)
235    {
236        // Arity is the checker's (`bynk.capability.op_arity`); this reads the
237        // one argument when it is there and stays quiet when it is not, rather
238        // than reporting a second diagnostic about the same call.
239        match args.first().map(|a| &a.kind) {
240            Some(ExprKind::StrLit(name)) => {
241                reads.names.insert(name.clone());
242            }
243            Some(_) => {
244                reads.complete = false;
245                warnings.push(computed_name_warning(e.span));
246            }
247            None => {}
248        }
249    }
250    for child in bynk_syntax::ast::expr_children(e) {
251        walk_expr(child, reads, warnings);
252    }
253}
254
255/// The one thing that can tell an author `deploy` has lost sight of a secret.
256///
257/// A warning, not an error ([DECISION A]): `Secrets.get(pickName())` is legal
258/// and sometimes reasonable, and making it a compile failure to serve a driver's
259/// convenience would be the language spending expressiveness it does not need to
260/// spend. The severity is carried by the code — `Severity::for_error` classifies
261/// it, and the diagnostic sink routes on that.
262fn computed_name_warning(span: Span) -> CompileError {
263    CompileError::new(
264        "bynk.secrets.computed_name",
265        span,
266        "`Secrets.get` is called with a computed name, so `bynk deploy` cannot know which secret this context reads"
267            .to_string(),
268    )
269    .with_note(
270        "the deploy plan will not list it, and will say its list of read secrets is incomplete; \
271         pass a string literal if you want it planned",
272    )
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // `secret_reads_of`'s own walk (`walk_block`/`walk_expr`) is pinned
280    // end-to-end by `bynkc/tests/fixtures/positive/374_secrets_computed_name`
281    // (`target.txt` = `workers`) — an earlier version of this comment pointed
282    // at `differential_analysis.rs`/`analysis_residual_gap.rs` instead, which
283    // was wrong: this PR's own additions to both establish that neither can
284    // observe this walk (both new sites are gap-in-name-only under the
285    // hardcoded `BuildTarget::Bundle`/`SchemaLock::Off` both analysis paths
286    // share — see their own doc comments). The tests below cover the same
287    // branches directly, now that `secret_reads_of` is `bynk-check`'s public
288    // API, by parsing real source rather than hand-building a `Handler`.
289
290    fn parse_context_handlers(src: &str) -> Vec<bynk_syntax::ast::Handler> {
291        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
292        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
293        let bynk_syntax::ast::SourceUnit::Context(ctx) = unit else {
294            panic!("expected a context unit");
295        };
296        ctx.items
297            .into_iter()
298            .filter_map(|item| match item {
299                bynk_syntax::ast::CommonsItem::Service(s) => Some(s.handlers.into_iter()),
300                _ => None,
301            })
302            .flatten()
303            .collect()
304    }
305
306    fn flattened_to_bynk() -> std::collections::HashMap<String, String> {
307        [(
308            SECRETS_CAPABILITY.to_string(),
309            crate::firstparty::BYNK_UNIT.to_string(),
310        )]
311        .into_iter()
312        .collect()
313    }
314
315    const LITERAL_READ: &str = "context net.probe\n\nconsumes bynk { Secrets }\n\nservice probe {\n  on call() -> Effect[Option[String]] given Secrets {\n    Secrets.get(\"API_KEY\")\n  }\n}\n";
316
317    #[test]
318    fn a_literal_argument_is_collected_and_warns_nothing() {
319        let handlers = parse_context_handlers(LITERAL_READ);
320        let flattened = flattened_to_bynk();
321        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
322        assert_eq!(reads.names, ["API_KEY".to_string()].into());
323        assert!(reads.complete);
324        assert!(warnings.is_empty());
325    }
326
327    const COMPUTED_READ: &str = "context net.probe\n\nconsumes bynk { Secrets }\n\nservice probe {\n  on call(key: String) -> Effect[Option[String]] given Secrets {\n    Secrets.get(key)\n  }\n}\n";
328
329    #[test]
330    fn a_computed_argument_warns_once_and_marks_the_read_set_incomplete() {
331        let handlers = parse_context_handlers(COMPUTED_READ);
332        let flattened = flattened_to_bynk();
333        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
334        assert!(reads.names.is_empty());
335        assert!(!reads.complete);
336        assert_eq!(warnings.len(), 1);
337        assert_eq!(warnings[0].category, "bynk.secrets.computed_name");
338    }
339
340    const NESTED_IN_MATCH_ARM: &str = "context net.probe\n\nconsumes bynk { Secrets }\n\ntype Choice = enum { A, B }\n\nservice probe {\n  on call(key: String, choice: Choice) -> Effect[Option[String]] given Secrets {\n    match choice {\n      A => Secrets.get(key)\n      B => Secrets.get(\"FIXED\")\n    }\n  }\n}\n";
341
342    #[test]
343    fn a_call_nested_inside_a_match_arm_is_still_found() {
344        // `expr_children` is trusted to recurse into constructs `walk_expr`
345        // never names explicitly — a `match` arm's body is the case this test
346        // pins, since `walk_expr` only ever matches `MethodCall` directly.
347        let handlers = parse_context_handlers(NESTED_IN_MATCH_ARM);
348        let flattened = flattened_to_bynk();
349        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
350        assert_eq!(reads.names, ["FIXED".to_string()].into());
351        assert!(
352            !reads.complete,
353            "the other arm's computed argument still counts"
354        );
355        assert_eq!(warnings.len(), 1);
356    }
357
358    #[test]
359    fn a_context_whose_secrets_is_not_bynks_is_never_walked() {
360        // Same computed-argument source as above — if this resolved to
361        // `bynk`'s `Secrets`, it would warn; walking nothing is the point.
362        let handlers = parse_context_handlers(COMPUTED_READ);
363        let flattened: std::collections::HashMap<String, String> =
364            [(SECRETS_CAPABILITY.to_string(), "acme.vault".to_string())]
365                .into_iter()
366                .collect();
367        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
368        assert_eq!(reads, SecretReads::none());
369        assert!(warnings.is_empty());
370    }
371
372    #[test]
373    fn reads_secrets_of_bynk_matches_on_the_resolved_unit_not_the_spelling() {
374        let to_bynk: std::collections::HashMap<String, String> = [(
375            SECRETS_CAPABILITY.to_string(),
376            crate::firstparty::BYNK_UNIT.to_string(),
377        )]
378        .into_iter()
379        .collect();
380        assert!(reads_secrets_of_bynk(&to_bynk));
381
382        let to_someone_else: std::collections::HashMap<String, String> =
383            [(SECRETS_CAPABILITY.to_string(), "acme.vault".to_string())]
384                .into_iter()
385                .collect();
386        assert!(!reads_secrets_of_bynk(&to_someone_else));
387
388        assert!(!reads_secrets_of_bynk(&std::collections::HashMap::new()));
389    }
390
391    fn parse_context_providers(src: &str) -> Vec<ProviderDecl> {
392        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
393        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
394        let bynk_syntax::ast::SourceUnit::Context(ctx) = unit else {
395            panic!("expected a context unit");
396        };
397        ctx.items
398            .into_iter()
399            .filter_map(|item| match item {
400                bynk_syntax::ast::CommonsItem::Provider(p) => Some(p),
401                _ => None,
402            })
403            .collect()
404    }
405
406    const PROVIDER_READS: &str = "context net.probe\n\nconsumes bynk { Secrets }\n\ncapability Mode {\n  fn current(key: String) -> Effect[Option[String]]\n}\n\nprovides Mode = SecretMode given Secrets {\n  fn current(key: String) -> Effect[Option[String]] {\n    let fixed <- Secrets.get(\"APP_MODE\")\n    Secrets.get(key)\n  }\n}\n";
407
408    #[test]
409    fn a_provider_body_is_walked_like_a_handler_body() {
410        // #1976: only handler bodies were walked, so a provider's literal read
411        // was missing from the manifest and its computed one went unwarned.
412        let providers = parse_context_providers(PROVIDER_READS);
413        let (reads, warnings) = provider_secret_reads_of(providers.iter(), &flattened_to_bynk());
414        assert_eq!(reads.names, ["APP_MODE".to_string()].into());
415        assert!(!reads.complete);
416        assert_eq!(warnings.len(), 1);
417        assert_eq!(warnings[0].category, "bynk.secrets.computed_name");
418
419        let elsewhere: HashMap<String, String> =
420            [(SECRETS_CAPABILITY.to_string(), "acme.vault".to_string())]
421                .into_iter()
422                .collect();
423        let (reads, warnings) = provider_secret_reads_of(providers.iter(), &elsewhere);
424        assert_eq!(reads, SecretReads::none());
425        assert!(warnings.is_empty());
426    }
427
428    #[test]
429    fn computed_name_warning_carries_the_registered_code() {
430        let w = computed_name_warning(Span::default());
431        assert_eq!(w.category, "bynk.secrets.computed_name");
432    }
433}