1use std::collections::BTreeMap;
49use std::sync::{Arc, Mutex, MutexGuard};
50
51use blue_lang_waku::{Capability, Provided};
52use serde_json::{json, Value as Json};
53
54use crate::Pid;
55
56pub const FORMAT: &str = "blue-receipt";
58pub const VERSION: u64 = 2;
60pub const VERSION_UNROOTED: u64 = 1;
62pub const READS: [u64; 2] = [VERSION_UNROOTED, VERSION];
64pub const ROOT_KEYS: [&str; 2] = ["input_root", "output_root"];
66
67pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
71
72#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct Roots {
76 pub input: String,
77 pub output: String,
78}
79
80fn is_root(text: &str) -> bool {
81 text.strip_prefix("b3:").is_some_and(|hex| {
82 hex.len() == 64
83 && hex
84 .bytes()
85 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
86 })
87}
88
89pub fn check(receipt: &Json) -> Result<(), String> {
98 let version = receipt.get("version").and_then(Json::as_u64);
99 let rooted = match version {
100 Some(VERSION_UNROOTED) => false,
101 Some(VERSION) => true,
102 _ => {
103 return Err(format!(
104 "{FORMAT} version {}; this blue reads versions {} to {VERSION}",
105 receipt.get("version").unwrap_or(&Json::Null),
106 READS[0]
107 ))
108 }
109 };
110 for key in ROOT_KEYS {
111 match (rooted, receipt.get(key)) {
112 (false, None) => {}
113 (false, Some(_)) => {
114 return Err(format!(
115 "a version {VERSION_UNROOTED} receipt carries `{key}`, which only version {VERSION} has"
116 ))
117 }
118 (true, None) => return Err(format!("a version {VERSION} receipt without `{key}`")),
119 (true, Some(root)) if !root.as_str().is_some_and(is_root) => {
120 return Err(format!("`{key}` is {root}, not b3: and 64 hex digits"))
121 }
122 (true, Some(_)) => {}
123 }
124 }
125 Ok(())
126}
127
128fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
129 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
130}
131
132#[derive(Clone, Debug, Default, PartialEq, Eq)]
134pub struct Frame {
135 pub granted: Vec<Capability>,
137 pub provided: Vec<Provided>,
140 pub withheld: Vec<Capability>,
142}
143
144fn labels(caps: &[Capability]) -> Json {
145 Json::Array(caps.iter().map(|c| json!(c.label())).collect())
146}
147
148fn capability(label: &str) -> Option<Capability> {
149 Capability::host_effects()
150 .into_iter()
151 .find(|c| c.label() == label)
152}
153
154fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
155 j.and_then(Json::as_array)
156 .ok_or_else(|| format!("a hold without `{what}`"))?
157 .iter()
158 .map(|l| {
159 l.as_str()
160 .and_then(capability)
161 .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
162 })
163 .collect()
164}
165
166impl Frame {
167 #[must_use]
169 pub fn block(&self, host_io: u64) -> Json {
170 json!({
171 "granted": labels(&self.granted),
172 "withheld": labels(&self.withheld),
173 "host_io": host_io,
174 })
175 }
176
177 #[must_use]
180 pub fn provided_json(&self) -> Json {
181 Json::Array(
182 self.provided
183 .iter()
184 .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
185 .collect(),
186 )
187 }
188
189 #[must_use]
191 pub fn to_json(&self) -> Json {
192 json!({
193 "granted": labels(&self.granted),
194 "provided": self.provided_json(),
195 "withheld": labels(&self.withheld),
196 })
197 }
198
199 pub fn from_json(j: &Json) -> Result<Self, String> {
206 let provided = j
207 .get("provided")
208 .and_then(Json::as_array)
209 .ok_or("a hold without `provided`")?
210 .iter()
211 .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
212 .collect::<Result<_, _>>()?;
213 Ok(Self {
214 granted: capabilities(j.get("granted"), "granted")?,
215 provided,
216 withheld: capabilities(j.get("withheld"), "withheld")?,
217 })
218 }
219
220 #[must_use]
222 pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
223 self.withheld
224 .iter()
225 .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
226 .collect()
227 }
228}
229
230#[derive(Debug, Default)]
231struct Ledger {
232 current: Pid,
233 total: u64,
234 by_pid: BTreeMap<Pid, u64>,
235}
236
237#[derive(Clone, Debug, Default)]
239pub struct HostIo(Arc<Mutex<Ledger>>);
240
241impl HostIo {
242 #[must_use]
243 pub fn new() -> Self {
244 Self::default()
245 }
246
247 pub fn tally(&self) {
249 let mut l = locked(&self.0);
250 l.total += 1;
251 let pid = l.current;
252 *l.by_pid.entry(pid).or_default() += 1;
253 }
254
255 #[must_use]
257 pub fn total(&self) -> u64 {
258 locked(&self.0).total
259 }
260
261 #[must_use]
263 pub fn of(&self, pid: Pid) -> u64 {
264 locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
265 }
266
267 pub fn enter(&self, pid: Pid) {
269 locked(&self.0).current = pid;
270 }
271
272 pub fn take(&self, pid: Pid) -> u64 {
274 locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
275 }
276}
277
278type Deferred<T> = Box<dyn FnOnce() -> T + Send>;
279
280enum Table<T> {
281 Unknown,
282 Deferred(Deferred<T>),
283 Known(T),
284}
285
286struct Lazy<T>(Arc<Mutex<Table<T>>>);
289
290impl<T> Clone for Lazy<T> {
291 fn clone(&self) -> Self {
292 Self(Arc::clone(&self.0))
293 }
294}
295
296impl<T> Default for Lazy<T> {
297 fn default() -> Self {
298 Self(Arc::new(Mutex::new(Table::Unknown)))
299 }
300}
301
302impl<T> Lazy<T> {
303 fn defer(&self, compute: impl FnOnce() -> T + Send + 'static) {
304 *locked(&self.0) = Table::Deferred(Box::new(compute));
305 }
306
307 fn read<R>(&self, f: impl FnOnce(&T) -> Option<R>) -> Option<R> {
308 let mut state = locked(&self.0);
309 if let Table::Deferred(_) = &*state {
310 if let Table::Deferred(compute) = std::mem::replace(&mut *state, Table::Unknown) {
311 *state = Table::Known(compute());
312 }
313 }
314 match &*state {
315 Table::Known(table) => f(table),
316 Table::Unknown | Table::Deferred(_) => None,
317 }
318 }
319
320 fn state(&self) -> &'static str {
321 match &*locked(&self.0) {
322 Table::Unknown => "unknown",
323 Table::Deferred(_) => "deferred",
324 Table::Known(_) => "known",
325 }
326 }
327}
328
329#[derive(Clone, Default)]
331pub struct Identities(Lazy<BTreeMap<String, String>>);
332
333impl std::fmt::Debug for Identities {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 write!(f, "Identities({})", self.0.state())
336 }
337}
338
339impl Identities {
340 pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
342 self.0.defer(hash);
343 }
344
345 #[must_use]
347 pub fn of(&self, key: &str) -> Option<String> {
348 self.0.read(|table| table.get(key).cloned())
349 }
350}
351
352#[derive(Clone, Debug)]
355pub struct Source {
356 pub file: String,
357 pub text: Arc<str>,
358}
359
360#[derive(Clone, Debug, PartialEq, Eq)]
362pub struct Place {
363 pub file: String,
364 pub line: usize,
365 pub column: usize,
366}
367
368impl std::fmt::Display for Place {
369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370 write!(f, "{}:{}:{}", self.file, self.line, self.column)
371 }
372}
373
374#[derive(Clone, Default)]
377pub struct Places(Lazy<BTreeMap<String, Source>>);
378
379impl std::fmt::Debug for Places {
380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381 write!(f, "Places({})", self.0.state())
382 }
383}
384
385impl Places {
386 pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
388 self.0.defer(read);
389 }
390
391 #[must_use]
396 pub fn locate(&self, key: &str, span: tatara_lisp::Span) -> Option<Place> {
397 if span.is_synthetic() {
398 return None;
399 }
400 self.0.read(|table| {
401 let source = table.get(key)?;
402 (span.end <= source.text.len()).then(|| {
403 let (line, column) = tatara_lisp::Span::line_col(&source.text, span.start);
404 Place {
405 file: source.file.clone(),
406 line,
407 column,
408 }
409 })
410 })
411 }
412}
413
414#[derive(Clone, Copy, Debug, PartialEq, Eq)]
416pub enum Subject {
417 Actor { pid: Pid, incarnation: usize },
419 Run,
421}
422
423#[derive(Clone, Debug, PartialEq, Eq)]
425pub struct Receipt {
426 pub subject: Subject,
427 pub definition: Option<String>,
429 pub def_hash: Option<String>,
431 pub frame: Frame,
432 pub host_io: u64,
434 pub exit_kind: String,
435 pub exit_text: String,
436 pub roots: Option<Roots>,
439}
440
441impl Receipt {
442 #[must_use]
445 pub fn to_json(&self) -> Json {
446 let (subject, pid, incarnation) = match self.subject {
447 Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
448 Subject::Run => ("run", crate::ROOT.0, 0),
449 };
450 let mut j = json!({
451 "format": FORMAT,
452 "version": VERSION_UNROOTED,
453 "subject": subject,
454 "pid": pid,
455 "incarnation": incarnation,
456 "definition": self.definition,
457 "def_hash": self.def_hash,
458 "exit": { "kind": self.exit_kind, "text": self.exit_text },
459 "frame": self.frame.block(self.host_io),
460 "provided": self.frame.provided_json(),
461 });
462 if let Some(roots) = &self.roots {
463 j["version"] = json!(VERSION);
464 j["input_root"] = json!(roots.input);
465 j["output_root"] = json!(roots.output);
466 }
467 j
468 }
469
470 #[must_use]
472 pub fn bytes(&self) -> Vec<u8> {
473 canonical(&self.to_json()).into_bytes()
474 }
475}
476
477pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
479
480#[must_use]
482pub fn canonical(j: &Json) -> String {
483 let mut out = String::new();
484 write_canonical(j, &mut out);
485 out
486}
487
488fn write_canonical(j: &Json, out: &mut String) {
489 match j {
490 Json::Object(m) => {
491 let mut keys: Vec<&String> = m.keys().collect();
492 keys.sort();
493 out.push('{');
494 for (i, k) in keys.into_iter().enumerate() {
495 if i > 0 {
496 out.push(',');
497 }
498 out.push_str(&Json::String(k.clone()).to_string());
499 out.push(':');
500 write_canonical(&m[k], out);
501 }
502 out.push('}');
503 }
504 Json::Array(items) => {
505 out.push('[');
506 for (i, v) in items.iter().enumerate() {
507 if i > 0 {
508 out.push(',');
509 }
510 write_canonical(v, out);
511 }
512 out.push(']');
513 }
514 other => out.push_str(&other.to_string()),
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn frame() -> Frame {
523 Frame {
524 granted: vec![Capability::FileSystem],
525 provided: vec![
526 blue_lang_waku::Provision::DeterministicStepped {
527 at_ms: 5,
528 step_ms: 1,
529 }
530 .into(),
531 Provided {
532 provision: blue_lang_waku::Provision::NullSink,
533 scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
534 },
535 ],
536 withheld: vec![
537 Capability::Process,
538 Capability::Environment,
539 Capability::Network,
540 Capability::Entropy,
541 ],
542 }
543 }
544
545 #[test]
546 fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
547 let block = frame().block(3);
548 let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
549 let mut sorted = keys.clone();
550 sorted.sort();
551 assert_eq!(sorted, ["granted", "host_io", "withheld"]);
552 assert_eq!(
553 canonical(&block),
554 r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
555 );
556 }
557
558 #[test]
559 fn a_hold_survives_the_trace_format() {
560 let f = frame();
561 assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
562 let mut bad = f.to_json();
563 bad["withheld"] = json!(["disk"]);
564 assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
565 assert!(Frame::from_json(&json!({})).is_err());
566 }
567
568 #[test]
569 fn withheld_names_are_every_name_of_every_withheld_capability() {
570 let names = frame().withheld_names();
571 assert!(names.contains(&("getenv", Capability::Environment)));
572 assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
573 assert_eq!(
574 names.len(),
575 frame()
576 .withheld
577 .iter()
578 .map(|c| c.names().len())
579 .sum::<usize>()
580 );
581 assert!(Frame::default().withheld_names().is_empty());
582 }
583
584 #[test]
585 fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
586 let io = HostIo::new();
587 io.tally();
588 io.enter(Pid(3));
589 io.tally();
590 io.tally();
591 io.enter(crate::ROOT);
592 io.tally();
593 assert_eq!(io.total(), 4);
594 assert_eq!(io.of(crate::ROOT), 2);
595 assert_eq!(io.of(Pid(3)), 2);
596 assert_eq!(io.take(Pid(3)), 2);
597 assert_eq!(io.of(Pid(3)), 0);
598 assert_eq!(io.total(), 4, "the run total keeps every call");
599 assert_eq!(HostIo::new().total(), 0);
600 }
601
602 #[test]
603 fn identities_are_hashed_once_and_only_when_asked() {
604 let ids = Identities::default();
605 assert_eq!(ids.of("%root/f"), None);
606 let calls = Arc::new(Mutex::new(0));
607 let c = Arc::clone(&calls);
608 ids.defer(move || {
609 *locked(&c) += 1;
610 BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
611 });
612 assert_eq!(*locked(&calls), 0);
613 assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
614 assert_eq!(ids.clone().of("%root/g"), None);
615 assert_eq!(*locked(&calls), 1);
616 }
617
618 #[test]
619 fn a_place_is_read_once_and_only_inside_its_own_file() {
620 let places = Places::default();
621 let at = tatara_lisp::Span::new(10, 14);
622 assert_eq!(places.locate("%root/f", at), None);
623 let calls = Arc::new(Mutex::new(0));
624 let c = Arc::clone(&calls);
625 places.defer(move || {
626 *locked(&c) += 1;
627 BTreeMap::from([(
628 "%root/f".to_string(),
629 Source {
630 file: "w.b".into(),
631 text: Arc::from("def f()\n g(1)\nend\n"),
632 },
633 )])
634 });
635 assert_eq!(*locked(&calls), 0);
636 let place = places.locate("%root/f", at).expect("in its file");
637 assert_eq!(place.to_string(), "w.b:2:3");
638 assert_eq!(
639 places.locate("%root/f", tatara_lisp::Span::new(10, 99)),
640 None
641 );
642 assert_eq!(
643 places.locate("%root/f", tatara_lisp::Span::synthetic()),
644 None
645 );
646 assert_eq!(places.clone().locate("%root/g", at), None);
647 assert_eq!(*locked(&calls), 1);
648 }
649
650 #[test]
651 fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
652 let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
653 assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
654 let r = Receipt {
655 subject: Subject::Actor {
656 pid: Pid(2),
657 incarnation: 1,
658 },
659 definition: Some("%root/w".into()),
660 def_hash: Some("b3:ab".into()),
661 frame: frame(),
662 host_io: 7,
663 exit_kind: "normal".into(),
664 exit_text: "normal".into(),
665 roots: None,
666 };
667 let text = String::from_utf8(r.bytes()).expect("utf8");
668 assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
669 let back: Json = serde_json::from_str(&text).expect("json");
670 assert_eq!(canonical(&back), text);
671 assert_eq!(back["frame"]["host_io"], 7);
672 assert_eq!(back["subject"], "actor");
673 assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
674 assert!(
675 back["provided"][0].get("for").is_none(),
676 "a whole-program provider"
677 );
678 assert_eq!(
679 back["provided"][1],
680 json!({"provider": "null-sink", "for": ["kazu"]})
681 );
682 }
683
684 const V1: &str = r#"{"def_hash":"b3:ab","definition":"%root/w","exit":{"kind":"normal","text":"normal"},"format":"blue-receipt","frame":{"granted":["filesystem"],"host_io":7,"withheld":["process","environment","network","entropy"]},"incarnation":1,"pid":2,"provided":[{"at_ms":5,"provider":"deterministic-stepped","step_ms":1},{"for":["kazu"],"provider":"null-sink"}],"subject":"actor","version":1}"#;
685
686 fn actor(roots: Option<Roots>) -> Receipt {
687 Receipt {
688 subject: Subject::Actor {
689 pid: Pid(2),
690 incarnation: 1,
691 },
692 definition: Some("%root/w".into()),
693 def_hash: Some("b3:ab".into()),
694 frame: frame(),
695 host_io: 7,
696 exit_kind: "normal".into(),
697 exit_text: "normal".into(),
698 roots,
699 }
700 }
701
702 fn root(c: char) -> String {
703 format!("b3:{}", c.to_string().repeat(64))
704 }
705
706 #[test]
707 fn a_receipt_without_roots_is_the_version_1_receipt_byte_for_byte() {
708 let r = actor(None);
709 assert_eq!(String::from_utf8(r.bytes()).expect("utf8"), V1);
710 assert_eq!(check(&r.to_json()), Ok(()));
711 }
712
713 #[test]
714 fn a_receipt_with_roots_is_version_2_and_without_them_is_version_1_again() {
715 let r = actor(Some(Roots {
716 input: root('a'),
717 output: root('b'),
718 }));
719 let j = r.to_json();
720 assert_eq!(j["version"], VERSION);
721 assert_eq!(j["input_root"], root('a'));
722 assert_eq!(j["output_root"], root('b'));
723 assert_eq!(check(&j), Ok(()));
724 let mut stripped = j.clone();
725 for key in ROOT_KEYS {
726 stripped.as_object_mut().expect("object").remove(key);
727 }
728 stripped["version"] = json!(VERSION_UNROOTED);
729 assert_eq!(canonical(&stripped), V1);
730 }
731
732 #[test]
733 fn check_reads_versions_1_and_2_and_refuses_a_root_out_of_place() {
734 let v1 = actor(None).to_json();
735 let v2 = actor(Some(Roots {
736 input: root('0'),
737 output: root('f'),
738 }))
739 .to_json();
740 let edited = |base: &Json, key: &str, value: Json| {
741 let mut j = base.clone();
742 j[key] = value;
743 j
744 };
745 let refused = |j: &Json, why: &str| {
746 let e = check(j).expect_err(why);
747 assert!(e.contains(why), "{e}");
748 };
749 refused(
750 &edited(&v1, "input_root", json!(root('a'))),
751 "carries `input_root`",
752 );
753 refused(&edited(&v1, "version", json!(3)), "reads versions 1 to 2");
754 refused(
755 &edited(&v2, "output_root", json!("b3:00")),
756 "not b3: and 64 hex",
757 );
758 refused(
759 &edited(&v2, "input_root", json!(root('A'))),
760 "not b3: and 64 hex",
761 );
762 let mut half = v2.clone();
763 half.as_object_mut().expect("object").remove("output_root");
764 refused(&half, "without `output_root`");
765 let mut unversioned = v1.clone();
766 unversioned
767 .as_object_mut()
768 .expect("object")
769 .remove("version");
770 refused(&unversioned, "reads versions 1 to 2");
771 }
772}