1use 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
31const SECRETS_CAPABILITY: &str = "Secrets";
35
36#[derive(Debug, Default, PartialEq, Eq)]
38pub struct SecretReads {
39 pub names: std::collections::BTreeSet<String>,
41 pub complete: bool,
44}
45
46impl SecretReads {
47 fn none() -> Self {
50 Self {
51 names: std::collections::BTreeSet::new(),
52 complete: true,
53 }
54 }
55}
56
57fn 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
69pub 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
92pub 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
109pub 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 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
203fn 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
220fn 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 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
255fn 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 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 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 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 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}