1use bynk_syntax::ast::{Block, Expr, ExprKind};
24use bynk_syntax::error::CompileError;
25use bynk_syntax::span::Span;
26
27const SECRETS_CAPABILITY: &str = "Secrets";
31
32#[derive(Debug, Default, PartialEq, Eq)]
34pub struct SecretReads {
35 pub names: std::collections::BTreeSet<String>,
37 pub complete: bool,
40}
41
42impl SecretReads {
43 fn none() -> Self {
46 Self {
47 names: std::collections::BTreeSet::new(),
48 complete: true,
49 }
50 }
51}
52
53fn 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
65pub 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
88fn 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
105fn 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 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
140fn 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 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 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 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}