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 bynk_syntax::ast::{Block, Expr, ExprKind};
24use bynk_syntax::error::CompileError;
25use bynk_syntax::span::Span;
26
27/// The capability whose `get` names a platform secret. Matched against the
28/// capability a context actually resolved, never against the spelling — see
29/// [`reads_secrets_of_bynk`].
30const SECRETS_CAPABILITY: &str = "Secrets";
31
32/// What a context's handlers read through `bynk.Secrets`.
33#[derive(Debug, Default, PartialEq, Eq)]
34pub struct SecretReads {
35    /// Literal names, sorted. A census only while `complete` holds.
36    pub names: std::collections::BTreeSet<String>,
37    /// False when at least one `Secrets.get` argument was not a literal, so no
38    /// pass could know the name.
39    pub complete: bool,
40}
41
42impl SecretReads {
43    /// A context that reads nothing: complete by vacuity, and the shape a
44    /// non-`bynk.Secrets` context gets without walking a single expression.
45    fn none() -> Self {
46        Self {
47            names: std::collections::BTreeSet::new(),
48            complete: true,
49        }
50    }
51}
52
53/// Does this context's `Secrets` resolve to **`bynk`**'s?
54///
55/// `flattened` maps a context's in-scope capability name to the unit
56/// providing it, so this asks the question that matters — *whose* `Secrets`
57/// is this? — rather than matching the identifier. A context with no
58/// `Secrets` at all answers `false` here and never gets walked.
59fn reads_secrets_of_bynk(flattened: &std::collections::HashMap<String, String>) -> bool {
60    flattened
61        .get(SECRETS_CAPABILITY)
62        .is_some_and(|unit| unit == crate::firstparty::BYNK_UNIT)
63}
64
65/// The literal `bynk.Secrets` names this context's handlers read, and whether
66/// that list is everything.
67///
68/// Split so callers can scope it differently without the *rule* differing:
69/// `bynk-emit`'s manifest wants a context's whole handler set (its names span
70/// every file), while this warning wants one file's handlers at a time,
71/// because a diagnostic sink attributes a diagnostic to a path and a merged
72/// handler set has thrown that away.
73pub fn secret_reads_of<'a>(
74    handlers: impl Iterator<Item = &'a bynk_syntax::ast::Handler>,
75    flattened: &std::collections::HashMap<String, String>,
76) -> (SecretReads, Vec<CompileError>) {
77    if !reads_secrets_of_bynk(flattened) {
78        return (SecretReads::none(), Vec::new());
79    }
80    let mut reads = SecretReads::none();
81    let mut warnings = Vec::new();
82    for handler in handlers {
83        walk_block(&handler.body, &mut reads, &mut warnings);
84    }
85    (reads, warnings)
86}
87
88/// Every expression in a handler body.
89///
90/// Reuses `statement_exprs` rather than matching `Statement` here: a
91/// `Secrets.get` in a `let`, an `expect`, a `~>` send or a bare `do` is still a
92/// read, and re-enumerating the statement kinds would be a second place to
93/// forget one the day a new kind lands.
94fn walk_block(block: &Block, reads: &mut SecretReads, warnings: &mut Vec<CompileError>) {
95    let mut exprs: Vec<&Expr> = Vec::new();
96    for statement in &block.statements {
97        bynk_syntax::ast::statement_exprs(statement, &mut exprs);
98    }
99    exprs.push(&block.tail);
100    for e in exprs {
101        walk_expr(e, reads, warnings);
102    }
103}
104
105/// Visit `e` and everything under it, recording each `Secrets.get` call.
106///
107/// Recurses through [`bynk_syntax::ast::expr_children`] rather than re-matching
108/// every `ExprKind`: a `Secrets.get` inside a `match` arm, a lambda, or an
109/// interpolation hole is still a read, and a hand-rolled visitor would be a
110/// second place to forget that.
111fn walk_expr(e: &Expr, reads: &mut SecretReads, warnings: &mut Vec<CompileError>) {
112    if let ExprKind::MethodCall {
113        receiver,
114        method,
115        args,
116        ..
117    } = &e.kind
118        && method.name == "get"
119        && matches!(&receiver.kind, ExprKind::Ident(name) if name.name == SECRETS_CAPABILITY)
120    {
121        // Arity is the checker's (`bynk.capability.op_arity`); this reads the
122        // one argument when it is there and stays quiet when it is not, rather
123        // than reporting a second diagnostic about the same call.
124        match args.first().map(|a| &a.kind) {
125            Some(ExprKind::StrLit(name)) => {
126                reads.names.insert(name.clone());
127            }
128            Some(_) => {
129                reads.complete = false;
130                warnings.push(computed_name_warning(e.span));
131            }
132            None => {}
133        }
134    }
135    for child in bynk_syntax::ast::expr_children(e) {
136        walk_expr(child, reads, warnings);
137    }
138}
139
140/// The one thing that can tell an author `deploy` has lost sight of a secret.
141///
142/// A warning, not an error ([DECISION A]): `Secrets.get(pickName())` is legal
143/// and sometimes reasonable, and making it a compile failure to serve a driver's
144/// convenience would be the language spending expressiveness it does not need to
145/// spend. The severity is carried by the code — `Severity::for_error` classifies
146/// it, and the diagnostic sink routes on that.
147fn computed_name_warning(span: Span) -> CompileError {
148    CompileError::new(
149        "bynk.secrets.computed_name",
150        span,
151        "`Secrets.get` is called with a computed name, so `bynk deploy` cannot know which secret this context reads"
152            .to_string(),
153    )
154    .with_note(
155        "the deploy plan will not list it, and will say its list of read secrets is incomplete; \
156         pass a string literal if you want it planned",
157    )
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    // `secret_reads_of`'s own walk (`walk_block`/`walk_expr`) is pinned
165    // end-to-end by `bynkc/tests/fixtures/positive/374_secrets_computed_name`
166    // (`target.txt` = `workers`) — an earlier version of this comment pointed
167    // at `differential_analysis.rs`/`analysis_residual_gap.rs` instead, which
168    // was wrong: this PR's own additions to both establish that neither can
169    // observe this walk (both new sites are gap-in-name-only under the
170    // hardcoded `BuildTarget::Bundle`/`SchemaLock::Off` both analysis paths
171    // share — see their own doc comments). The tests below cover the same
172    // branches directly, now that `secret_reads_of` is `bynk-check`'s public
173    // API, by parsing real source rather than hand-building a `Handler`.
174
175    fn parse_context_handlers(src: &str) -> Vec<bynk_syntax::ast::Handler> {
176        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
177        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
178        let bynk_syntax::ast::SourceUnit::Context(ctx) = unit else {
179            panic!("expected a context unit");
180        };
181        ctx.items
182            .into_iter()
183            .filter_map(|item| match item {
184                bynk_syntax::ast::CommonsItem::Service(s) => Some(s.handlers.into_iter()),
185                _ => None,
186            })
187            .flatten()
188            .collect()
189    }
190
191    fn flattened_to_bynk() -> std::collections::HashMap<String, String> {
192        [(
193            SECRETS_CAPABILITY.to_string(),
194            crate::firstparty::BYNK_UNIT.to_string(),
195        )]
196        .into_iter()
197        .collect()
198    }
199
200    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";
201
202    #[test]
203    fn a_literal_argument_is_collected_and_warns_nothing() {
204        let handlers = parse_context_handlers(LITERAL_READ);
205        let flattened = flattened_to_bynk();
206        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
207        assert_eq!(reads.names, ["API_KEY".to_string()].into());
208        assert!(reads.complete);
209        assert!(warnings.is_empty());
210    }
211
212    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";
213
214    #[test]
215    fn a_computed_argument_warns_once_and_marks_the_read_set_incomplete() {
216        let handlers = parse_context_handlers(COMPUTED_READ);
217        let flattened = flattened_to_bynk();
218        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
219        assert!(reads.names.is_empty());
220        assert!(!reads.complete);
221        assert_eq!(warnings.len(), 1);
222        assert_eq!(warnings[0].category, "bynk.secrets.computed_name");
223    }
224
225    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";
226
227    #[test]
228    fn a_call_nested_inside_a_match_arm_is_still_found() {
229        // `expr_children` is trusted to recurse into constructs `walk_expr`
230        // never names explicitly — a `match` arm's body is the case this test
231        // pins, since `walk_expr` only ever matches `MethodCall` directly.
232        let handlers = parse_context_handlers(NESTED_IN_MATCH_ARM);
233        let flattened = flattened_to_bynk();
234        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
235        assert_eq!(reads.names, ["FIXED".to_string()].into());
236        assert!(
237            !reads.complete,
238            "the other arm's computed argument still counts"
239        );
240        assert_eq!(warnings.len(), 1);
241    }
242
243    #[test]
244    fn a_context_whose_secrets_is_not_bynks_is_never_walked() {
245        // Same computed-argument source as above — if this resolved to
246        // `bynk`'s `Secrets`, it would warn; walking nothing is the point.
247        let handlers = parse_context_handlers(COMPUTED_READ);
248        let flattened: std::collections::HashMap<String, String> =
249            [(SECRETS_CAPABILITY.to_string(), "acme.vault".to_string())]
250                .into_iter()
251                .collect();
252        let (reads, warnings) = secret_reads_of(handlers.iter(), &flattened);
253        assert_eq!(reads, SecretReads::none());
254        assert!(warnings.is_empty());
255    }
256
257    #[test]
258    fn reads_secrets_of_bynk_matches_on_the_resolved_unit_not_the_spelling() {
259        let to_bynk: std::collections::HashMap<String, String> = [(
260            SECRETS_CAPABILITY.to_string(),
261            crate::firstparty::BYNK_UNIT.to_string(),
262        )]
263        .into_iter()
264        .collect();
265        assert!(reads_secrets_of_bynk(&to_bynk));
266
267        let to_someone_else: std::collections::HashMap<String, String> =
268            [(SECRETS_CAPABILITY.to_string(), "acme.vault".to_string())]
269                .into_iter()
270                .collect();
271        assert!(!reads_secrets_of_bynk(&to_someone_else));
272
273        assert!(!reads_secrets_of_bynk(&std::collections::HashMap::new()));
274    }
275
276    #[test]
277    fn computed_name_warning_carries_the_registered_code() {
278        let w = computed_name_warning(Span::default());
279        assert_eq!(w.category, "bynk.secrets.computed_name");
280    }
281}