1use std::collections::BTreeMap;
83use std::sync::{Arc, Mutex, MutexGuard};
84
85use blue_lang_waku::{Capability, Provided};
86use serde_json::{json, Value as Json};
87
88use crate::Pid;
89
90pub const FORMAT: &str = "blue-receipt";
92pub const VERSION: u64 = 2;
94pub const VERSION_UNROOTED: u64 = 1;
96pub const VERSION_STEPPED: u64 = 3;
97pub const VERSION_CARRIED: u64 = 4;
98pub const VERSION_CARRIED_ROOTED: u64 = 5;
99pub const READS: [u64; 5] = [
101 VERSION_UNROOTED,
102 VERSION,
103 VERSION_STEPPED,
104 VERSION_CARRIED,
105 VERSION_CARRIED_ROOTED,
106];
107pub const ROOT_KEYS: [&str; 2] = ["input_root", "output_root"];
109pub const STEPS_KEY: &str = "steps";
110pub const CARRIED_KEY: &str = "carried";
111
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct CarriedIo {
114 pub image: String,
115 pub host_io: u64,
116 pub input_root: Option<String>,
117}
118
119impl CarriedIo {
120 fn to_json(&self) -> Json {
121 let mut j = json!({"host_io": self.host_io, "image": self.image});
122 if let Some(root) = &self.input_root {
123 j["input_root"] = json!(root);
124 }
125 j
126 }
127}
128
129pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
133
134#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct Roots {
138 pub input: String,
139 pub output: String,
140}
141
142pub(crate) fn is_hash(text: &str) -> bool {
143 text.strip_prefix("b3:").is_some_and(|hex| {
144 hex.len() == 64
145 && hex
146 .bytes()
147 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
148 })
149}
150
151pub fn check(receipt: &Json) -> Result<(), String> {
164 let version = receipt.get("version").and_then(Json::as_u64);
165 let (rooted, stepped, carried) = match version {
166 Some(VERSION_UNROOTED) => (false, false, false),
167 Some(VERSION) => (true, false, false),
168 Some(VERSION_STEPPED) => (
169 ROOT_KEYS.iter().any(|k| receipt.get(*k).is_some()),
170 true,
171 false,
172 ),
173 Some(VERSION_CARRIED) => (false, receipt.get(STEPS_KEY).is_some(), true),
174 Some(VERSION_CARRIED_ROOTED) => (true, receipt.get(STEPS_KEY).is_some(), true),
175 _ => {
176 return Err(format!(
177 "{FORMAT} version {}; this blue reads versions {} to {VERSION_CARRIED_ROOTED}",
178 receipt.get("version").unwrap_or(&Json::Null),
179 READS[0]
180 ))
181 }
182 };
183 let v = version.unwrap_or_default();
184 for key in ROOT_KEYS {
185 match (rooted, receipt.get(key)) {
186 (false, None) => {}
187 (false, Some(_)) => {
188 return Err(format!(
189 "a version {v} receipt carries `{key}`, which only a server's receipt of version {VERSION}, {VERSION_STEPPED} or {VERSION_CARRIED_ROOTED} has"
190 ))
191 }
192 (true, None) => return Err(format!("a version {v} receipt without `{key}`")),
193 (true, Some(root)) if !root.as_str().is_some_and(is_hash) => {
194 return Err(format!("`{key}` is {root}, not b3: and 64 hex digits"))
195 }
196 (true, Some(_)) => {}
197 }
198 }
199 check_carried(receipt, v, carried, rooted && carried)?;
200 match (stepped, receipt.get(STEPS_KEY)) {
201 (false, Some(_)) => Err(format!(
202 "a version {v} receipt carries `{STEPS_KEY}`, which only version {VERSION_STEPPED}, {VERSION_CARRIED} or {VERSION_CARRIED_ROOTED} has"
203 )),
204 (true, None) => Err(format!("a version {v} receipt without `{STEPS_KEY}`")),
205 (true, Some(n)) if n.as_u64().is_none() => {
206 Err(format!("`{STEPS_KEY}` is {n}, not a count"))
207 }
208 _ => Ok(()),
209 }
210}
211
212fn check_carried(receipt: &Json, v: u64, carried: bool, rooted: bool) -> Result<(), String> {
213 let c = match (carried, receipt.get(CARRIED_KEY)) {
214 (false, None) => return Ok(()),
215 (false, Some(_)) => {
216 return Err(format!(
217 "a version {v} receipt carries `{CARRIED_KEY}`, which only version {VERSION_CARRIED} or {VERSION_CARRIED_ROOTED} has"
218 ))
219 }
220 (true, None) => return Err(format!("a version {v} receipt without `{CARRIED_KEY}`")),
221 (true, Some(c)) => c,
222 };
223 let field = |k: &str| c.get(k).unwrap_or(&Json::Null);
224 let image = field("image")
225 .as_str()
226 .filter(|h| is_hash(h))
227 .ok_or_else(|| {
228 format!(
229 "`{CARRIED_KEY}.image` is {}, not b3: and 64 hex digits",
230 field("image")
231 )
232 })?;
233 let came = field("host_io").as_u64().ok_or_else(|| {
234 format!(
235 "`{CARRIED_KEY}.host_io` is {}, not a count",
236 field("host_io")
237 )
238 })?;
239 let counted = receipt
240 .get("frame")
241 .and_then(|f| f.get("host_io"))
242 .and_then(Json::as_u64)
243 .unwrap_or(0);
244 let pid = receipt.get("pid").unwrap_or(&Json::Null);
245 if counted < came {
246 return Err(format!(
247 "its host-call count is cut: process {pid} brought a count of {came} in image {image}, and the receipt counts {counted}"
248 ));
249 }
250 let brought = match (rooted, c.get("input_root")) {
251 (false, None) => return Ok(()),
252 (false, Some(_)) => {
253 return Err(format!(
254 "a version {v} receipt's `{CARRIED_KEY}` names an `input_root`, which only version {VERSION_CARRIED_ROOTED} has"
255 ))
256 }
257 (true, None) => {
258 return Err(format!(
259 "a version {v} receipt whose `{CARRIED_KEY}` names no `input_root`"
260 ))
261 }
262 (true, Some(r)) => r.as_str().filter(|h| is_hash(h)).ok_or_else(|| {
263 format!("`{CARRIED_KEY}.input_root` is {r}, not b3: and 64 hex digits")
264 })?,
265 };
266 let named = receipt.get("input_root").unwrap_or(&Json::Null);
267 if named.as_str() != Some(brought) {
268 return Err(format!(
269 "its input root is not the one it brought: process {pid} brought {brought} in image {image}, and the receipt names {named}"
270 ));
271 }
272 Ok(())
273}
274
275fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
276 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
277}
278
279#[derive(Clone, Debug, Default, PartialEq, Eq)]
281pub struct Frame {
282 pub granted: Vec<Capability>,
284 pub provided: Vec<Provided>,
287 pub withheld: Vec<Capability>,
289}
290
291fn labels(caps: &[Capability]) -> Json {
292 Json::Array(caps.iter().map(|c| json!(c.label())).collect())
293}
294
295fn capability(label: &str) -> Option<Capability> {
296 Capability::host_effects()
297 .into_iter()
298 .find(|c| c.label() == label)
299}
300
301fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
302 j.and_then(Json::as_array)
303 .ok_or_else(|| format!("a hold without `{what}`"))?
304 .iter()
305 .map(|l| {
306 l.as_str()
307 .and_then(capability)
308 .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
309 })
310 .collect()
311}
312
313impl Frame {
314 #[must_use]
316 pub fn block(&self, host_io: u64) -> Json {
317 json!({
318 "granted": labels(&self.granted),
319 "withheld": labels(&self.withheld),
320 "host_io": host_io,
321 })
322 }
323
324 #[must_use]
327 pub fn provided_json(&self) -> Json {
328 Json::Array(
329 self.provided
330 .iter()
331 .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
332 .collect(),
333 )
334 }
335
336 #[must_use]
338 pub fn to_json(&self) -> Json {
339 json!({
340 "granted": labels(&self.granted),
341 "provided": self.provided_json(),
342 "withheld": labels(&self.withheld),
343 })
344 }
345
346 pub fn from_json(j: &Json) -> Result<Self, String> {
353 let provided = j
354 .get("provided")
355 .and_then(Json::as_array)
356 .ok_or("a hold without `provided`")?
357 .iter()
358 .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
359 .collect::<Result<_, _>>()?;
360 Ok(Self {
361 granted: capabilities(j.get("granted"), "granted")?,
362 provided,
363 withheld: capabilities(j.get("withheld"), "withheld")?,
364 })
365 }
366
367 #[must_use]
369 pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
370 self.withheld
371 .iter()
372 .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
373 .collect()
374 }
375}
376
377#[derive(Debug, Default)]
378struct Ledger {
379 current: Pid,
380 total: u64,
381 by_pid: BTreeMap<Pid, u64>,
382}
383
384#[derive(Clone, Debug, Default)]
386pub struct HostIo(Arc<Mutex<Ledger>>);
387
388impl HostIo {
389 #[must_use]
390 pub fn new() -> Self {
391 Self::default()
392 }
393
394 pub fn tally(&self) {
396 let mut l = locked(&self.0);
397 l.total += 1;
398 let pid = crate::scheduling::lane().unwrap_or(l.current);
399 *l.by_pid.entry(pid).or_default() += 1;
400 }
401
402 #[must_use]
404 pub fn total(&self) -> u64 {
405 locked(&self.0).total
406 }
407
408 #[must_use]
410 pub fn of(&self, pid: Pid) -> u64 {
411 locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
412 }
413
414 pub fn enter(&self, pid: Pid) {
416 locked(&self.0).current = pid;
417 }
418
419 pub fn take(&self, pid: Pid) -> u64 {
421 locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
422 }
423
424 pub fn carry(&self, pid: Pid, calls: u64) {
425 *locked(&self.0).by_pid.entry(pid).or_default() += calls;
426 }
427}
428
429type Deferred<T> = Box<dyn FnOnce() -> T + Send>;
430
431enum Table<T> {
432 Unknown,
433 Deferred(Deferred<T>),
434 Known(T),
435}
436
437struct Lazy<T>(Arc<Mutex<Table<T>>>);
440
441impl<T> Clone for Lazy<T> {
442 fn clone(&self) -> Self {
443 Self(Arc::clone(&self.0))
444 }
445}
446
447impl<T> Default for Lazy<T> {
448 fn default() -> Self {
449 Self(Arc::new(Mutex::new(Table::Unknown)))
450 }
451}
452
453impl<T> Lazy<T> {
454 fn defer(&self, compute: impl FnOnce() -> T + Send + 'static) {
455 *locked(&self.0) = Table::Deferred(Box::new(compute));
456 }
457
458 fn read<R>(&self, f: impl FnOnce(&T) -> Option<R>) -> Option<R> {
459 let mut state = locked(&self.0);
460 if let Table::Deferred(_) = &*state {
461 if let Table::Deferred(compute) = std::mem::replace(&mut *state, Table::Unknown) {
462 *state = Table::Known(compute());
463 }
464 }
465 match &*state {
466 Table::Known(table) => f(table),
467 Table::Unknown | Table::Deferred(_) => None,
468 }
469 }
470
471 fn state(&self) -> &'static str {
472 match &*locked(&self.0) {
473 Table::Unknown => "unknown",
474 Table::Deferred(_) => "deferred",
475 Table::Known(_) => "known",
476 }
477 }
478}
479
480#[derive(Clone, Default)]
482pub struct Identities(Lazy<BTreeMap<String, String>>);
483
484impl std::fmt::Debug for Identities {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 write!(f, "Identities({})", self.0.state())
487 }
488}
489
490impl Identities {
491 pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
493 self.0.defer(hash);
494 }
495
496 #[must_use]
498 pub fn of(&self, key: &str) -> Option<String> {
499 self.0.read(|table| table.get(key).cloned())
500 }
501}
502
503#[derive(Clone, Debug)]
506pub struct Source {
507 pub file: String,
508 pub text: Arc<str>,
509}
510
511#[derive(Clone, Debug, PartialEq, Eq)]
513pub struct Place {
514 pub file: String,
515 pub line: usize,
516 pub column: usize,
517}
518
519impl std::fmt::Display for Place {
520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521 write!(f, "{}:{}:{}", self.file, self.line, self.column)
522 }
523}
524
525#[derive(Clone, Default)]
528pub struct Places(Lazy<BTreeMap<String, Source>>);
529
530impl std::fmt::Debug for Places {
531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532 write!(f, "Places({})", self.0.state())
533 }
534}
535
536impl Places {
537 pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
539 self.0.defer(read);
540 }
541
542 #[must_use]
547 pub fn locate(&self, key: &str, span: tatara_lisp::Span) -> Option<Place> {
548 if span.is_synthetic() {
549 return None;
550 }
551 self.0.read(|table| {
552 let source = table.get(key)?;
553 (span.end <= source.text.len()).then(|| {
554 let (line, column) = tatara_lisp::Span::line_col(&source.text, span.start);
555 Place {
556 file: source.file.clone(),
557 line,
558 column,
559 }
560 })
561 })
562 }
563}
564
565#[derive(Clone, Copy, Debug, PartialEq, Eq)]
567pub enum Subject {
568 Actor { pid: Pid, incarnation: usize },
570 Run,
572}
573
574#[derive(Clone, Debug, PartialEq, Eq)]
576pub struct Receipt {
577 pub subject: Subject,
578 pub definition: Option<String>,
580 pub def_hash: Option<String>,
582 pub frame: Frame,
583 pub host_io: u64,
585 pub exit_kind: String,
586 pub exit_text: String,
587 pub roots: Option<Roots>,
590 pub steps: Option<u64>,
591 pub carried: Option<CarriedIo>,
592}
593
594impl Receipt {
595 #[must_use]
598 pub fn to_json(&self) -> Json {
599 let (subject, pid, incarnation) = match self.subject {
600 Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
601 Subject::Run => ("run", crate::ROOT.0, 0),
602 };
603 let mut j = json!({
604 "format": FORMAT,
605 "version": VERSION_UNROOTED,
606 "subject": subject,
607 "pid": pid,
608 "incarnation": incarnation,
609 "definition": self.definition,
610 "def_hash": self.def_hash,
611 "exit": { "kind": self.exit_kind, "text": self.exit_text },
612 "frame": self.frame.block(self.host_io),
613 "provided": self.frame.provided_json(),
614 });
615 if let Some(roots) = &self.roots {
616 j["version"] = json!(VERSION);
617 j["input_root"] = json!(roots.input);
618 j["output_root"] = json!(roots.output);
619 }
620 if let Some(steps) = self.steps {
621 j["version"] = json!(VERSION_STEPPED);
622 j[STEPS_KEY] = json!(steps);
623 }
624 if let Some(c) = &self.carried {
625 j["version"] = json!(if c.input_root.is_some() {
626 VERSION_CARRIED_ROOTED
627 } else {
628 VERSION_CARRIED
629 });
630 j[CARRIED_KEY] = c.to_json();
631 }
632 j
633 }
634
635 #[must_use]
637 pub fn bytes(&self) -> Vec<u8> {
638 canonical(&self.to_json()).into_bytes()
639 }
640}
641
642pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
644
645#[must_use]
647pub fn canonical(j: &Json) -> String {
648 let mut out = String::new();
649 write_canonical(j, &mut out);
650 out
651}
652
653fn write_canonical(j: &Json, out: &mut String) {
654 match j {
655 Json::Object(m) => {
656 let mut keys: Vec<&String> = m.keys().collect();
657 keys.sort();
658 out.push('{');
659 for (i, k) in keys.into_iter().enumerate() {
660 if i > 0 {
661 out.push(',');
662 }
663 out.push_str(&Json::String(k.clone()).to_string());
664 out.push(':');
665 write_canonical(&m[k], out);
666 }
667 out.push('}');
668 }
669 Json::Array(items) => {
670 out.push('[');
671 for (i, v) in items.iter().enumerate() {
672 if i > 0 {
673 out.push(',');
674 }
675 write_canonical(v, out);
676 }
677 out.push(']');
678 }
679 other => out.push_str(&other.to_string()),
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 fn frame() -> Frame {
688 Frame {
689 granted: vec![Capability::FileSystem],
690 provided: vec![
691 blue_lang_waku::Provision::DeterministicStepped {
692 at_ms: 5,
693 step_ms: 1,
694 }
695 .into(),
696 Provided {
697 provision: blue_lang_waku::Provision::NullSink,
698 scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
699 },
700 ],
701 withheld: vec![
702 Capability::Process,
703 Capability::Environment,
704 Capability::Network,
705 Capability::Entropy,
706 ],
707 }
708 }
709
710 #[test]
711 fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
712 let block = frame().block(3);
713 let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
714 let mut sorted = keys.clone();
715 sorted.sort();
716 assert_eq!(sorted, ["granted", "host_io", "withheld"]);
717 assert_eq!(
718 canonical(&block),
719 r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
720 );
721 }
722
723 #[test]
724 fn a_hold_survives_the_trace_format() {
725 let f = frame();
726 assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
727 let mut bad = f.to_json();
728 bad["withheld"] = json!(["disk"]);
729 assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
730 assert!(Frame::from_json(&json!({})).is_err());
731 }
732
733 #[test]
734 fn withheld_names_are_every_name_of_every_withheld_capability() {
735 let names = frame().withheld_names();
736 assert!(names.contains(&("getenv", Capability::Environment)));
737 assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
738 assert_eq!(
739 names.len(),
740 frame()
741 .withheld
742 .iter()
743 .map(|c| c.names().len())
744 .sum::<usize>()
745 );
746 assert!(Frame::default().withheld_names().is_empty());
747 }
748
749 #[test]
750 fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
751 let io = HostIo::new();
752 io.tally();
753 io.enter(Pid(3));
754 io.tally();
755 io.tally();
756 io.enter(crate::ROOT);
757 io.tally();
758 assert_eq!(io.total(), 4);
759 assert_eq!(io.of(crate::ROOT), 2);
760 assert_eq!(io.of(Pid(3)), 2);
761 assert_eq!(io.take(Pid(3)), 2);
762 assert_eq!(io.of(Pid(3)), 0);
763 assert_eq!(io.total(), 4, "the run total keeps every call");
764 assert_eq!(HostIo::new().total(), 0);
765 }
766
767 #[test]
768 fn identities_are_hashed_once_and_only_when_asked() {
769 let ids = Identities::default();
770 assert_eq!(ids.of("%root/f"), None);
771 let calls = Arc::new(Mutex::new(0));
772 let c = Arc::clone(&calls);
773 ids.defer(move || {
774 *locked(&c) += 1;
775 BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
776 });
777 assert_eq!(*locked(&calls), 0);
778 assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
779 assert_eq!(ids.clone().of("%root/g"), None);
780 assert_eq!(*locked(&calls), 1);
781 }
782
783 #[test]
784 fn a_place_is_read_once_and_only_inside_its_own_file() {
785 let places = Places::default();
786 let at = tatara_lisp::Span::new(10, 14);
787 assert_eq!(places.locate("%root/f", at), None);
788 let calls = Arc::new(Mutex::new(0));
789 let c = Arc::clone(&calls);
790 places.defer(move || {
791 *locked(&c) += 1;
792 BTreeMap::from([(
793 "%root/f".to_string(),
794 Source {
795 file: "w.b".into(),
796 text: Arc::from("def f()\n g(1)\nend\n"),
797 },
798 )])
799 });
800 assert_eq!(*locked(&calls), 0);
801 let place = places.locate("%root/f", at).expect("in its file");
802 assert_eq!(place.to_string(), "w.b:2:3");
803 assert_eq!(
804 places.locate("%root/f", tatara_lisp::Span::new(10, 99)),
805 None
806 );
807 assert_eq!(
808 places.locate("%root/f", tatara_lisp::Span::synthetic()),
809 None
810 );
811 assert_eq!(places.clone().locate("%root/g", at), None);
812 assert_eq!(*locked(&calls), 1);
813 }
814
815 #[test]
816 fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
817 let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
818 assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
819 let r = Receipt {
820 subject: Subject::Actor {
821 pid: Pid(2),
822 incarnation: 1,
823 },
824 definition: Some("%root/w".into()),
825 def_hash: Some("b3:ab".into()),
826 frame: frame(),
827 host_io: 7,
828 exit_kind: "normal".into(),
829 exit_text: "normal".into(),
830 roots: None,
831 steps: None,
832 carried: None,
833 };
834 let text = String::from_utf8(r.bytes()).expect("utf8");
835 assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
836 let back: Json = serde_json::from_str(&text).expect("json");
837 assert_eq!(canonical(&back), text);
838 assert_eq!(back["frame"]["host_io"], 7);
839 assert_eq!(back["subject"], "actor");
840 assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
841 assert!(
842 back["provided"][0].get("for").is_none(),
843 "a whole-program provider"
844 );
845 assert_eq!(
846 back["provided"][1],
847 json!({"provider": "null-sink", "for": ["kazu"]})
848 );
849 }
850
851 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}"#;
852
853 fn actor(roots: Option<Roots>) -> Receipt {
854 Receipt {
855 subject: Subject::Actor {
856 pid: Pid(2),
857 incarnation: 1,
858 },
859 definition: Some("%root/w".into()),
860 def_hash: Some("b3:ab".into()),
861 frame: frame(),
862 host_io: 7,
863 exit_kind: "normal".into(),
864 exit_text: "normal".into(),
865 roots,
866 steps: None,
867 carried: None,
868 }
869 }
870
871 fn root(c: char) -> String {
872 format!("b3:{}", c.to_string().repeat(64))
873 }
874
875 #[test]
876 fn a_receipt_without_roots_is_the_version_1_receipt_byte_for_byte() {
877 let r = actor(None);
878 assert_eq!(String::from_utf8(r.bytes()).expect("utf8"), V1);
879 assert_eq!(check(&r.to_json()), Ok(()));
880 }
881
882 #[test]
883 fn a_receipt_with_roots_is_version_2_and_without_them_is_version_1_again() {
884 let r = actor(Some(Roots {
885 input: root('a'),
886 output: root('b'),
887 }));
888 let j = r.to_json();
889 assert_eq!(j["version"], VERSION);
890 assert_eq!(j["input_root"], root('a'));
891 assert_eq!(j["output_root"], root('b'));
892 assert_eq!(check(&j), Ok(()));
893 let mut stripped = j.clone();
894 for key in ROOT_KEYS {
895 stripped.as_object_mut().expect("object").remove(key);
896 }
897 stripped["version"] = json!(VERSION_UNROOTED);
898 assert_eq!(canonical(&stripped), V1);
899 }
900
901 #[test]
902 fn check_reads_versions_1_and_2_and_refuses_a_root_out_of_place() {
903 let v1 = actor(None).to_json();
904 let v2 = actor(Some(Roots {
905 input: root('0'),
906 output: root('f'),
907 }))
908 .to_json();
909 let edited = |base: &Json, key: &str, value: Json| {
910 let mut j = base.clone();
911 j[key] = value;
912 j
913 };
914 let refused = |j: &Json, why: &str| {
915 let e = check(j).expect_err(why);
916 assert!(e.contains(why), "{e}");
917 };
918 refused(
919 &edited(&v1, "input_root", json!(root('a'))),
920 "carries `input_root`",
921 );
922 refused(&edited(&v1, "version", json!(6)), "reads versions 1 to 5");
923 refused(
924 &edited(&v2, "output_root", json!("b3:00")),
925 "not b3: and 64 hex",
926 );
927 refused(
928 &edited(&v2, "input_root", json!(root('A'))),
929 "not b3: and 64 hex",
930 );
931 let mut half = v2.clone();
932 half.as_object_mut().expect("object").remove("output_root");
933 refused(&half, "without `output_root`");
934 let mut unversioned = v1.clone();
935 unversioned
936 .as_object_mut()
937 .expect("object")
938 .remove("version");
939 refused(&unversioned, "reads versions 1 to 5");
940 }
941
942 const V2: &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,"input_root":"b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_root":"b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","pid":2,"provided":[{"at_ms":5,"provider":"deterministic-stepped","step_ms":1},{"for":["kazu"],"provider":"null-sink"}],"subject":"actor","version":2}"#;
943
944 fn rooted() -> Option<Roots> {
945 Some(Roots {
946 input: root('a'),
947 output: root('b'),
948 })
949 }
950
951 fn counted(roots: Option<Roots>, steps: u64) -> Receipt {
952 Receipt {
953 steps: Some(steps),
954 ..actor(roots)
955 }
956 }
957
958 fn uncounted(mut j: Json, version: u64) -> String {
959 j.as_object_mut().expect("object").remove(STEPS_KEY);
960 j["version"] = json!(version);
961 canonical(&j)
962 }
963
964 #[test]
965 fn a_receipt_that_counts_no_steps_keeps_its_version_1_or_2_bytes() {
966 assert_eq!(String::from_utf8(actor(None).bytes()).expect("utf8"), V1);
967 assert_eq!(
968 String::from_utf8(actor(rooted()).bytes()).expect("utf8"),
969 V2
970 );
971 for text in [V1, V2] {
972 let j: Json = serde_json::from_str(text).expect("json");
973 assert_eq!(check(&j), Ok(()), "{text}");
974 }
975 }
976
977 #[test]
978 fn a_receipt_with_steps_is_version_3_and_without_them_its_old_bytes() {
979 let run = counted(None, 42).to_json();
980 assert_eq!(run["version"], VERSION_STEPPED);
981 assert_eq!(run[STEPS_KEY], 42);
982 assert!(run.get("input_root").is_none());
983 assert_eq!(check(&run), Ok(()));
984 assert_eq!(uncounted(run, VERSION_UNROOTED), V1);
985 let server = counted(rooted(), 0).to_json();
986 assert_eq!(server["version"], VERSION_STEPPED);
987 assert_eq!(server[STEPS_KEY], 0);
988 assert_eq!(server["input_root"], root('a'));
989 assert_eq!(check(&server), Ok(()));
990 assert_eq!(uncounted(server, VERSION), V2);
991 }
992
993 #[test]
994 fn check_reads_version_3_and_refuses_a_count_out_of_place() {
995 let refused = |j: &Json, why: &str| {
996 let e = check(j).expect_err(why);
997 assert!(e.contains(why), "{why}: {e}");
998 };
999 let with = |base: Json, key: &str, value: Json| {
1000 let mut j = base;
1001 j[key] = value;
1002 j
1003 };
1004 let without = |base: Json, key: &str| {
1005 let mut j = base;
1006 j.as_object_mut().expect("object").remove(key);
1007 j
1008 };
1009 refused(
1010 &with(actor(None).to_json(), STEPS_KEY, json!(3)),
1011 "carries `steps`",
1012 );
1013 refused(
1014 &with(actor(rooted()).to_json(), STEPS_KEY, json!(3)),
1015 "carries `steps`",
1016 );
1017 refused(
1018 &without(counted(None, 1).to_json(), STEPS_KEY),
1019 "without `steps`",
1020 );
1021 refused(
1022 &with(counted(None, 1).to_json(), STEPS_KEY, json!(-1)),
1023 "not a count",
1024 );
1025 refused(
1026 &with(counted(None, 1).to_json(), STEPS_KEY, json!("1")),
1027 "not a count",
1028 );
1029 refused(
1030 &with(counted(None, 1).to_json(), STEPS_KEY, json!(1.5)),
1031 "not a count",
1032 );
1033 refused(
1034 &without(counted(rooted(), 1).to_json(), "input_root"),
1035 "without `input_root`",
1036 );
1037 refused(
1038 &with(
1039 counted(rooted(), 1).to_json(),
1040 "output_root",
1041 json!("b3:00"),
1042 ),
1043 "not b3: and 64 hex",
1044 );
1045 }
1046
1047 fn moved(host_io: u64, came: u64) -> Receipt {
1048 Receipt {
1049 host_io,
1050 carried: Some(CarriedIo {
1051 image: root('c'),
1052 host_io: came,
1053 input_root: None,
1054 }),
1055 ..actor(None)
1056 }
1057 }
1058
1059 #[test]
1060 fn a_receipt_with_carried_calls_is_version_4_and_a_cut_or_misplaced_block_is_refused_by_name() {
1061 let j = moved(7, 3).to_json();
1062 assert_eq!(j["version"], VERSION_CARRIED);
1063 assert_eq!(j[CARRIED_KEY], json!({"host_io": 3, "image": root('c')}));
1064 assert_eq!(check(&j), Ok(()));
1065 assert_eq!(check(&moved(3, 3).to_json()), Ok(()));
1066 let stepped = Receipt {
1067 steps: Some(9),
1068 ..moved(7, 3)
1069 }
1070 .to_json();
1071 assert_eq!(stepped["version"], VERSION_CARRIED);
1072 assert_eq!(check(&stepped), Ok(()));
1073 let mut uncarried = j.clone();
1074 uncarried
1075 .as_object_mut()
1076 .expect("object")
1077 .remove(CARRIED_KEY);
1078 uncarried["version"] = json!(VERSION_UNROOTED);
1079 assert_eq!(
1080 canonical(&uncarried),
1081 V1,
1082 "without the block, the version 1 bytes"
1083 );
1084 let refused = |j: &Json, why: &str| {
1085 let e = check(j).expect_err(why);
1086 assert!(e.contains(why), "{why}: {e}");
1087 };
1088 refused(
1089 &moved(2, 3).to_json(),
1090 &format!(
1091 "its host-call count is cut: process 2 brought a count of 3 in image {}, and the receipt counts 2",
1092 root('c')
1093 ),
1094 );
1095 let mut lowered = j.clone();
1096 lowered["version"] = json!(VERSION_STEPPED);
1097 refused(&lowered, "which only version 4 or 5 has");
1098 let mut bare = j.clone();
1099 bare.as_object_mut().expect("object").remove(CARRIED_KEY);
1100 refused(&bare, "a version 4 receipt without `carried`");
1101 let mut rooted4 = j.clone();
1102 rooted4["input_root"] = json!(root('a'));
1103 rooted4["output_root"] = json!(root('b'));
1104 refused(&rooted4, "carries `input_root`");
1105 for (key, bad, why) in [
1106 (
1107 "image",
1108 json!("b3:00"),
1109 "`carried.image` is \"b3:00\", not b3: and 64 hex digits",
1110 ),
1111 ("host_io", json!(-1), "`carried.host_io` is -1, not a count"),
1112 ] {
1113 let mut misfit = j.clone();
1114 misfit[CARRIED_KEY][key] = bad;
1115 refused(&misfit, why);
1116 }
1117 let mut named = j.clone();
1118 named[CARRIED_KEY]["input_root"] = json!(root('a'));
1119 refused(
1120 &named,
1121 "a version 4 receipt's `carried` names an `input_root`, which only version 5 has",
1122 );
1123 }
1124
1125 fn served(host_io: u64, came: u64, brought: &str) -> Receipt {
1126 Receipt {
1127 host_io,
1128 carried: Some(CarriedIo {
1129 image: root('c'),
1130 host_io: came,
1131 input_root: Some(brought.to_string()),
1132 }),
1133 ..actor(rooted())
1134 }
1135 }
1136
1137 #[test]
1138 fn a_moved_servers_receipt_is_version_5_and_a_root_it_did_not_bring_is_refused_by_name() {
1139 let j = served(7, 3, &root('a')).to_json();
1140 assert_eq!(j["version"], VERSION_CARRIED_ROOTED);
1141 assert_eq!(
1142 j[CARRIED_KEY],
1143 json!({"host_io": 3, "image": root('c'), "input_root": root('a')})
1144 );
1145 assert_eq!(
1146 (&j["input_root"], &j["output_root"]),
1147 (&json!(root('a')), &json!(root('b')))
1148 );
1149 assert_eq!(check(&j), Ok(()));
1150 let stepped = Receipt {
1151 steps: Some(9),
1152 ..served(7, 3, &root('a'))
1153 }
1154 .to_json();
1155 assert_eq!(stepped["version"], VERSION_CARRIED_ROOTED);
1156 assert_eq!(check(&stepped), Ok(()));
1157 let mut uncarried = j.clone();
1158 uncarried
1159 .as_object_mut()
1160 .expect("object")
1161 .remove(CARRIED_KEY);
1162 uncarried["version"] = json!(VERSION);
1163 assert_eq!(
1164 canonical(&uncarried),
1165 V2,
1166 "without the block, the version 2 bytes"
1167 );
1168 let refused = |j: &Json, why: &str| {
1169 let e = check(j).expect_err(why);
1170 assert!(e.contains(why), "{why}: {e}");
1171 };
1172 refused(
1173 &served(7, 3, &root('d')).to_json(),
1174 &format!(
1175 "its input root is not the one it brought: process 2 brought {} in image {}, and the receipt names \"{}\"",
1176 root('d'),
1177 root('c'),
1178 root('a')
1179 ),
1180 );
1181 refused(
1182 &served(2, 3, &root('a')).to_json(),
1183 &format!(
1184 "its host-call count is cut: process 2 brought a count of 3 in image {}, and the receipt counts 2",
1185 root('c')
1186 ),
1187 );
1188 let mut lowered = j.clone();
1189 lowered["version"] = json!(VERSION_CARRIED);
1190 refused(
1191 &lowered,
1192 "a version 4 receipt carries `input_root`, which only a server's receipt of version 2, 3 or 5 has",
1193 );
1194 let mut plain = j.clone();
1195 plain["version"] = json!(VERSION);
1196 refused(
1197 &plain,
1198 "a version 2 receipt carries `carried`, which only version 4 or 5 has",
1199 );
1200 let mut bare = j.clone();
1201 bare.as_object_mut().expect("object").remove(CARRIED_KEY);
1202 refused(&bare, "a version 5 receipt without `carried`");
1203 let mut unnamed = j.clone();
1204 unnamed[CARRIED_KEY]
1205 .as_object_mut()
1206 .expect("object")
1207 .remove("input_root");
1208 refused(
1209 &unnamed,
1210 "a version 5 receipt whose `carried` names no `input_root`",
1211 );
1212 let mut unrooted = j.clone();
1213 unrooted
1214 .as_object_mut()
1215 .expect("object")
1216 .remove("input_root");
1217 refused(&unrooted, "a version 5 receipt without `input_root`");
1218 let mut malformed = j.clone();
1219 malformed[CARRIED_KEY]["input_root"] = json!("b3:00");
1220 refused(
1221 &malformed,
1222 "`carried.input_root` is \"b3:00\", not b3: and 64 hex digits",
1223 );
1224 }
1225}