1use std::collections::{BTreeMap, BTreeSet};
50use std::sync::Arc;
51
52use beck_diag::{Diagnostic, Diagnostics, Span};
53
54use crate::check::Program;
55use crate::core::{CoreKind, Prim};
56use crate::ty::{Effect, Tier, Ty, TyDecl};
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct NotSendable {
61 pub offender: String,
63 pub path: Vec<String>,
65 pub why: &'static str,
66}
67
68impl NotSendable {
69 pub fn flow(&self) -> String {
70 self.path.join(".")
71 }
72}
73
74pub fn sendable(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Result<(), NotSendable> {
77 check(ty, types, Rule::Sendable)
78}
79
80pub fn storable(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Result<(), NotSendable> {
88 check(ty, types, Rule::Storable)
89}
90
91#[derive(Clone, Copy, PartialEq, Eq)]
92enum Rule {
93 Sendable,
94 Storable,
95}
96
97fn check(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>, rule: Rule) -> Result<(), NotSendable> {
98 fn go(
99 ty: &Ty,
100 types: &BTreeMap<Arc<str>, TyDecl>,
101 rule: Rule,
102 path: &mut Vec<String>,
103 seen: &mut BTreeSet<Arc<str>>,
104 ) -> Result<(), NotSendable> {
105 let fail = |offender: String, why: &'static str, path: &Vec<String>| {
106 Err(NotSendable {
107 offender,
108 path: path.clone(),
109 why,
110 })
111 };
112 match ty {
113 Ty::Var(_) => Ok(()),
114 Ty::Fun(..) => fail(
115 format!("{ty}"),
116 "a function is code, and code does not cross a boundary as data",
117 path,
118 ),
119 Ty::Con(name, args) => {
120 match name.as_ref() {
121 Ty::SECRET => {
122 return fail(
123 format!("{ty}"),
124 "`secret[T]` is deliberately not Sendable: that is the whole mechanism",
125 path,
126 )
127 }
128 Ty::INTERNAL if rule == Rule::Sendable => {
131 return fail(
132 format!("{ty}"),
133 "`internal[T]` is recorded and never shown: it may be written to the \
134 log and may not cross a boundary",
135 path,
136 )
137 }
138 Ty::HTML | Ty::ATTR if rule == Rule::Storable => {
139 return fail(
140 format!("{ty}"),
141 "a view is derived from state, so storing one would make replay read \
142 it back rather than recompute it",
143 path,
144 )
145 }
146 _ => {}
147 }
148 for (i, a) in args.iter().enumerate() {
149 path.push(format!("[{i}]"));
150 go(a, types, rule, path, seen)?;
151 path.pop();
152 }
153 let Some(decl) = types.get(name.as_ref()) else {
154 return Ok(());
155 };
156 if !seen.insert(name.clone()) {
157 return Ok(());
159 }
160 let out = match decl {
161 TyDecl::Model { fields, .. } => {
162 for (f, t) in fields {
163 path.push(f.to_string());
164 go(t, types, rule, path, seen)?;
165 path.pop();
166 }
167 Ok(())
168 }
169 TyDecl::Union { variants, .. } => {
170 for v in variants {
171 for (f, t) in &v.fields {
172 path.push(format!("{}.{f}", v.name));
173 go(t, types, rule, path, seen)?;
174 path.pop();
175 }
176 }
177 Ok(())
178 }
179 TyDecl::Newtype { inner, .. } | TyDecl::Alias { ty: inner, .. } => {
180 go(inner, types, rule, path, seen)
181 }
182 };
183 seen.remove(name.as_ref());
184 out
185 }
186 }
187 }
188 let mut path = vec![format!("{ty}")];
189 let mut seen = BTreeSet::new();
190 go(ty, types, rule, &mut path, &mut seen)
191}
192
193#[derive(Clone, Debug)]
195pub struct Reach {
196 pub what: Arc<str>,
197 pub tier: Tier,
198 pub blocked: Option<&'static str>,
199}
200
201pub fn flow(program: &Program, ty_name: &str) -> Vec<Reach> {
204 let mut out = Vec::new();
205 for name in &program.def_order {
206 let Some(d) = program.defs.get(name) else {
207 continue;
208 };
209 let mentions = std::iter::once(&d.ret)
210 .chain(d.params.iter().map(|(_, _, t)| t))
211 .any(|t| mentions_type(t, ty_name, &program.types));
212 if !mentions {
213 continue;
214 }
215 out.push(Reach {
216 what: d.name.clone(),
217 tier: d.tier,
218 blocked: (d.tier == Tier::Client).then_some("a client cannot hold a `secret[T]`"),
219 });
220 }
221 for s in &program.signals {
222 if !mentions_type(&s.ty, ty_name, &program.types) {
223 continue;
224 }
225 out.push(Reach {
226 what: s.name.clone(),
227 tier: s.tier,
228 blocked: (s.tier == Tier::Client).then_some("a client cannot hold a `secret[T]`"),
229 });
230 }
231 out
232}
233
234fn mentions_type(ty: &Ty, name: &str, types: &BTreeMap<Arc<str>, TyDecl>) -> bool {
235 fn go(
236 ty: &Ty,
237 name: &str,
238 types: &BTreeMap<Arc<str>, TyDecl>,
239 seen: &mut BTreeSet<Arc<str>>,
240 ) -> bool {
241 match ty {
242 Ty::Var(_) => false,
243 Ty::Fun(ps, r, _) => {
244 ps.iter().any(|p| go(p, name, types, seen)) || go(r, name, types, seen)
245 }
246 Ty::Con(n, args) => {
247 if n.as_ref() == name {
248 return true;
249 }
250 if args.iter().any(|a| go(a, name, types, seen)) {
251 return true;
252 }
253 if !seen.insert(n.clone()) {
254 return false;
255 }
256 match types.get(n.as_ref()) {
257 Some(TyDecl::Model { fields, .. }) => {
258 fields.iter().any(|(_, t)| go(t, name, types, seen))
259 }
260 Some(TyDecl::Union { variants, .. }) => variants
261 .iter()
262 .any(|v| v.fields.iter().any(|(_, t)| go(t, name, types, seen))),
263 Some(TyDecl::Newtype { inner, .. }) | Some(TyDecl::Alias { ty: inner, .. }) => {
264 go(inner, name, types, seen)
265 }
266 None => false,
267 }
268 }
269 }
270 }
271 go(ty, name, types, &mut BTreeSet::new())
272}
273
274pub fn check_security(program: &Program, diags: &mut Diagnostics) {
290 check_boundaries(program, diags);
291 check_capabilities(program, diags);
292}
293
294pub fn check_boundaries(program: &Program, diags: &mut Diagnostics) {
296 boundaries(program, diags);
297}
298
299pub fn check_capabilities(program: &Program, diags: &mut Diagnostics) {
301 capabilities(program, diags);
302}
303
304fn boundaries(program: &Program, diags: &mut Diagnostics) {
306 for s in &program.signals {
307 if s.effects.contains(&Effect::Durable) {
309 let state = element(&s.ty);
310 if let Err(bad) = storable(&state, &program.types) {
311 reject(
312 diags,
313 "B0411",
314 format!("`{}` is durable, so its state must be storable", s.name),
315 s.span,
316 &bad,
317 "the log is the only description of this program's history; a value it cannot \
318 read back is a state replay would not reproduce",
319 );
320 }
321 }
322 if s.tier == Tier::Client {
324 let carried = element(&s.ty);
325 if let Err(bad) = sendable(&carried, &program.types) {
326 reject(
327 diags,
328 "B0410",
329 format!(
330 "`{}` runs on the client, so its value must be Sendable",
331 s.name
332 ),
333 s.span,
334 &bad,
335 "this value crosses to the browser; §3.5's whole claim is that the compiler \
336 proves it cannot carry a secret",
337 );
338 }
339 }
340 }
341
342 if let Some(TyDecl::Union { .. }) = program.types.get("Command") {
344 if let Err(bad) = sendable(&Ty::con("Command"), &program.types) {
345 let span = program
346 .signals
347 .first()
348 .map(|s| s.span)
349 .unwrap_or(Span::NONE);
350 reject(
351 diags,
352 "B0410",
353 "`Command` is what clients send, so it must be Sendable".to_string(),
354 span,
355 &bad,
356 "a command is minted in the browser: a secret in one would be a secret the browser \
357 already had",
358 );
359 }
360 }
361
362 for name in &program.def_order {
364 let Some(d) = program.defs.get(name) else {
365 continue;
366 };
367 if d.tier != Tier::Client {
368 continue;
369 }
370 for t in std::iter::once(&d.ret).chain(d.params.iter().map(|(_, _, t)| t)) {
371 if let Err(bad) = sendable(t, &program.types) {
372 let kind = if bad.offender.starts_with("secret[") {
376 "a secret"
377 } else if bad.offender.starts_with("internal[") {
378 "an internal fact"
379 } else {
380 continue;
381 };
382 reject(
383 diags,
384 "B0410",
385 format!("`{}` runs on the client and handles {kind}", d.name),
386 d.span,
387 &bad,
388 "`beck explain flow` shows the whole path; the fix is to keep the \
389 definition on a tier that can hold it",
390 );
391 }
392 }
393 }
394}
395
396fn reject(
397 diags: &mut Diagnostics,
398 code: &'static str,
399 message: String,
400 span: Span,
401 bad: &NotSendable,
402 note: &str,
403) {
404 diags.push(
405 Diagnostic::error(code, message, span)
406 .with_primary_label(format!("`{}` reaches it at `{}`", bad.offender, bad.flow()))
407 .with_note(bad.why)
408 .with_note(note.to_string()),
409 );
410}
411
412fn element(t: &Ty) -> Ty {
413 match t {
414 Ty::Con(n, args)
415 if (n.as_ref() == Ty::SIGNAL || n.as_ref() == Ty::STREAM) && args.len() == 1 =>
416 {
417 args[0].clone()
418 }
419 other => other.clone(),
420 }
421}
422
423fn capabilities(program: &Program, diags: &mut Diagnostics) {
432 if !has_chokepoint(program) {
436 return;
437 }
438 let authorised = reachable_from_validator(program);
439 for name in &program.def_order {
440 let Some(d) = program.defs.get(name) else {
441 continue;
442 };
443 let caps: Vec<&Effect> = d
444 .effects
445 .iter()
446 .filter(|e| matches!(e, Effect::Cap(_)))
447 .collect();
448 if caps.is_empty() || authorised.contains(name) {
449 continue;
450 }
451 let names: Vec<String> = caps.iter().map(|e| e.name()).collect();
452 diags.push(
453 Diagnostic::error(
454 "B0412",
455 format!("`{name}` requires a capability nothing can discharge"),
456 d.span,
457 )
458 .with_primary_label(format!("needs {{{}}}", names.join(", ")))
459 .with_note(
460 "a `Session` reaches exactly one place in a Beck program: the validator `decide` is \
461 given, which is the only function handed a `Proposal`. Authority is one chokepoint \
462 (docs/03 §3.5), so a capability required outside it has no holder",
463 )
464 .with_fix(
465 "call this from `validate` — or, if it genuinely needs no authority, drop the \
466 `cap.*` from its `uses`",
467 ),
468 );
469 }
470}
471
472fn has_chokepoint(program: &Program) -> bool {
474 program.signals.iter().any(|s| {
475 matches!(
476 &s.expr.kind,
477 CoreKind::Prim {
478 op: Prim::Decide,
479 ..
480 }
481 )
482 })
483}
484
485fn reachable_from_validator(program: &Program) -> BTreeSet<Arc<str>> {
487 let mut roots: Vec<Arc<str>> = Vec::new();
488 for s in &program.signals {
489 if let CoreKind::Prim {
490 op: Prim::Decide,
491 args,
492 } = &s.expr.kind
493 {
494 if let Some(v) = args.get(2) {
495 let mut names = BTreeSet::new();
496 crate::place::mentions(v, &mut names);
497 roots.extend(names);
498 }
499 }
500 }
501 let mut out: BTreeSet<Arc<str>> = BTreeSet::new();
502 while let Some(n) = roots.pop() {
503 if !out.insert(n.clone()) {
504 continue;
505 }
506 if let Some(d) = program.defs.get(&n) {
507 let mut names = BTreeSet::new();
508 crate::place::mentions(&d.body, &mut names);
509 roots.extend(names);
510 }
511 }
512 out
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::{check_str, compile_str};
519
520 fn types() -> BTreeMap<Arc<str>, TyDecl> {
521 BTreeMap::from([
522 (
523 Arc::from("Config"),
524 TyDecl::Model {
525 name: Arc::from("Config"),
526 params: Vec::new(),
527 fields: vec![
528 (Arc::from("host"), Ty::str_()),
529 (Arc::from("key"), Ty::secret(Ty::str_())),
530 ],
531 },
532 ),
533 (
534 Arc::from("State"),
535 TyDecl::Model {
536 name: Arc::from("State"),
537 params: Vec::new(),
538 fields: vec![(Arc::from("config"), Ty::con("Config"))],
539 },
540 ),
541 ])
542 }
543
544 #[test]
545 fn a_secret_is_not_sendable_however_deeply_it_is_buried() {
546 let t = types();
547 assert!(sendable(&Ty::str_(), &t).is_ok());
548 let bad = sendable(&Ty::con("State"), &t).expect_err("State reaches a secret");
549 assert_eq!(bad.flow(), "State.config.key");
551 assert_eq!(bad.offender, "secret[Str]");
552 assert!(sendable(&Ty::list(Ty::con("Config")), &t).is_err());
554 assert!(sendable(&Ty::map(Ty::str_(), Ty::con("Config")), &t).is_err());
555 }
556
557 #[test]
558 fn a_view_may_cross_a_boundary_but_may_not_be_stored() {
559 let t = types();
563 assert!(sendable(&Ty::html(), &t).is_ok());
564 assert!(storable(&Ty::html(), &t).is_err());
565 }
566
567 #[test]
568 fn a_recursive_type_terminates() {
569 let t = BTreeMap::from([(
570 Arc::from("Tree"),
571 TyDecl::Model {
572 name: Arc::from("Tree"),
573 params: Vec::new(),
574 fields: vec![(Arc::from("kids"), Ty::list(Ty::con("Tree")))],
575 },
576 )]);
577 assert!(sendable(&Ty::con("Tree"), &t).is_ok());
578 }
579
580 #[test]
581 fn a_state_that_caches_a_view_is_refused_at_compile_time() {
582 let src = crate::split::tests::TODO.replace(
586 "model State:\n todos: Map[Id, Todo]",
587 "model State:\n todos: Map[Id, Todo]\n cached: Html",
588 );
589 let (_, d, _) = compile_str("t.beck", &src);
590 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
591 assert!(codes.contains(&"B0411"), "got {codes:?}");
592 }
593
594 #[test]
595 fn a_secret_in_the_command_union_is_refused() {
596 let src = crate::split::tests::TODO.replace(
599 "union Command:\n Add(id: Id, text: Str)",
600 "union Command:\n Add(id: Id, text: Str, token: secret[Str])",
601 );
602 let (_, d, _) = compile_str("t.beck", &src);
603 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
604 assert!(codes.contains(&"B0410"), "got {codes:?}");
605 }
606
607 #[test]
608 fn a_capability_required_outside_the_chokepoint_has_no_holder() {
609 let src = crate::split::tests::TODO.replace(
610 "def done_class(t: Todo) -> Str:",
611 "def audit(t: Todo) -> Str uses cap.admin:\n return t.text\n\n\
612 def done_class(t: Todo) -> Str:",
613 );
614 let (_, d, _) = compile_str("t.beck", &src);
615 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
616 assert!(codes.contains(&"B0412"), "got {codes:?}");
617 }
618
619 #[test]
620 fn a_capability_required_inside_the_chokepoint_is_exactly_what_it_is_for() {
621 let src = crate::split::tests::TODO
624 .replace(
625 "def owned(s: State, p: Proposal, id: Id, evs: list[Event])",
626 "def admin(p: Proposal) -> Bool uses cap.admin:\n\
627 \x20 return p.session.actor != \"\"\n\n\
628 def owned(s: State, p: Proposal, id: Id, evs: list[Event])",
629 )
630 .replace(
631 " match map_get(s.todos, id):\n case Some(value):\n if value.owner != p.session.actor:",
632 " match map_get(s.todos, id):\n case Some(value):\n if not admin(p):",
633 );
634 let (program, d, map) = check_str("t.beck", &src);
635 assert!(!d.has_errors(), "{}", d.render(&map));
636 let mut diags = Diagnostics::new();
637 let solution = crate::place::solve(&program, None);
638 let mut program = program;
639 crate::place::apply(&mut program, &solution);
640 check_security(&program, &mut diags);
641 assert!(
642 !diags.iter().any(|x| x.code == "B0412"),
643 "{}",
644 diags.render(&map)
645 );
646 assert_eq!(
647 program.defs["admin"].tier,
648 Tier::Server,
649 "only the server holds a capability"
650 );
651 }
652
653 #[test]
654 fn explain_flow_names_the_definitions_a_type_reaches() {
655 let src = "\
656model Config:
657 key: secret[Str]
658
659def load() -> Config uses env:
660 return Config(key=secret_env(\"API_KEY\"))
661
662def host(c: Config) -> Str:
663 return \"api.example.com\"
664";
665 let (program, d, map) = check_str("t.beck", src);
666 assert!(!d.has_errors(), "{}", d.render(&map));
667 let reached: Vec<String> = flow(&program, "Config")
668 .into_iter()
669 .map(|r| r.what.to_string())
670 .collect();
671 assert_eq!(reached, ["load", "host"]);
672 }
673}
674
675#[cfg(test)]
676mod quadrants {
677 use super::*;
678
679 #[test]
682 fn the_two_axes_are_independent() {
683 let types: BTreeMap<Arc<str>, TyDecl> = BTreeMap::new();
684 let quad = |t: &Ty| (sendable(t, &types).is_ok(), storable(t, &types).is_ok());
685
686 assert_eq!(quad(&Ty::str_()), (true, true), "ordinary data does both");
687 assert_eq!(
688 quad(&Ty::html()),
689 (true, false),
690 "a view crosses as patches and is never read back from the log"
691 );
692 assert_eq!(
693 quad(&Ty::internal(Ty::str_())),
694 (false, true),
695 "`internal[T]` is the quadrant `secret[T]` alone left empty"
696 );
697 assert_eq!(
698 quad(&Ty::secret(Ty::str_())),
699 (false, false),
700 "a token reaches neither the browser nor the log (§3.7 F5)"
701 );
702 assert_eq!(
703 quad(&Ty::fun(vec![Ty::int()], Ty::int())),
704 (false, false),
705 "code is not data in either direction"
706 );
707 }
708
709 #[test]
710 fn an_internal_field_is_found_however_deeply_it_is_buried() {
711 let types = BTreeMap::from([(
712 Arc::from("Suspension"),
713 TyDecl::Model {
714 name: Arc::from("Suspension"),
715 params: Vec::new(),
716 fields: vec![
717 (Arc::from("at"), Ty::int()),
718 (Arc::from("reason"), Ty::internal(Ty::str_())),
719 ],
720 },
721 )]);
722 let bad = sendable(&Ty::list(Ty::con("Suspension")), &types)
723 .expect_err("a list of them still cannot cross");
724 assert_eq!(bad.flow(), "list[Suspension].[0].reason");
725 assert!(storable(&Ty::list(Ty::con("Suspension")), &types).is_ok());
727 }
728}