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