Skip to main content

blue_lang_proc/
receipt.rs

1//! What an actor or a run is held to, what it did through the host, and the
2//! receipt that says so.
3//!
4//! Four pieces a node shares with the runtime that builds it:
5//!
6//! - [`Frame`]: the hold as its artifacts name it: the host capabilities
7//!   granted from the host, those a pure provider stands in for, and those
8//!   withheld (a breach). Written into a crash trace and into a receipt.
9//! - [`HostIo`]: one ledger of host calls per node. The runtime's counting
10//!   shim (`blue_lang_runtime::Hold::bind`) tallies a call through a granted
11//!   host name; the node says whose call it is by switching the ledger's
12//!   current process with every slice. A name the hold withholds or provides
13//!   gets no shim, so a pure frame counts nothing by construction.
14//! - [`Identities`]: each definition key's `def_hash`, computed only when an
15//!   artifact asks for one.
16//! - [`Places`]: each definition key's file and text, read only when a crash
17//!   asks where its breaching call is.
18//!
19//! ## Format `blue-receipt`, versions 1 and 2
20//!
21//! One canonical JSON object (keys sorted, no whitespace), named by the
22//! BLAKE3 hash of its bytes (`blue_lang_runtime::receipt` writes and
23//! verifies it):
24//!
25//! ```text
26//! { "def_hash": "b3:…" | null, "definition": "%root/reader" | "%run" | null,
27//!   "exit": { "kind": "normal" | "crashed" | "runaway" | "killed" | "fatal" | "refused", "text": "…" },
28//!   "format": "blue-receipt",
29//!   "frame": { "granted": [LABEL…], "host_io": n, "withheld": [LABEL…] },
30//!   "incarnation": n, "pid": n,
31//!   "provided": [{ "provider": "deterministic-stepped", …, "for": [PACKAGE…] }…],
32//!   "subject": "actor" | "run", "version": 1 }
33//! ```
34//!
35//! Version 2 is version 1 with two more keys, written only for a
36//! `spawn_server` incarnation, whose state the node holds:
37//!
38//! ```text
39//! { …, "input_root": "b3:…", "output_root": "b3:…", …, "version": 2 }
40//! ```
41//!
42//! `input_root` is the [`StateRoot`] of the state the incarnation began
43//! with, before its first message; `output_root` that of the state after
44//! the last message its handler returned from. A receipt without roots is
45//! version 1, byte for byte as before version 2 existed, so the name of
46//! every receipt a run wrote before stays the name it has.
47
48use 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
56/// The format name every receipt carries.
57pub const FORMAT: &str = "blue-receipt";
58/// The version a receipt with state roots carries.
59pub const VERSION: u64 = 2;
60/// The version a receipt without state roots carries.
61pub const VERSION_UNROOTED: u64 = 1;
62/// Every version [`check`] accepts.
63pub const READS: [u64; 2] = [VERSION_UNROOTED, VERSION];
64/// The keys only a version 2 receipt has.
65pub const ROOT_KEYS: [&str; 2] = ["input_root", "output_root"];
66
67/// The root of a server's state: `b3:` and the BLAKE3 hash of its canonical
68/// JSON. The runtime computes it (`blue_lang_runtime::json::state_root`); a
69/// node only calls it.
70pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
71
72/// A server incarnation's state roots: before its first message, and after
73/// the last one its handler returned from.
74#[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
89/// Whether `receipt` has the form its `version` names: version 1 carries no
90/// state root, version 2 carries both, each `b3:` and 64 lowercase hex
91/// digits.
92///
93/// # Errors
94///
95/// Naming a version this blue does not read, or the root that is missing,
96/// present where it may not be, or not a hash.
97pub 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/// A hold as an artifact names it.
133#[derive(Clone, Debug, Default, PartialEq, Eq)]
134pub struct Frame {
135    /// Host capabilities granted from the host.
136    pub granted: Vec<Capability>,
137    /// The pure providers standing in for host capabilities, each for the
138    /// whole program or, under `for`, for the packages it names.
139    pub provided: Vec<Provided>,
140    /// Host capabilities neither granted nor provided: a call is a breach.
141    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    /// The receipt's frame block: `{"granted":[…],"withheld":[…],"host_io":n}`.
168    #[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    /// The providers as the lock records them: the provider, its settings,
178    /// and `for` with the packages it serves when it is scoped.
179    #[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    /// The hold as a crash trace writes it.
190    #[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    /// Read a hold back.
200    ///
201    /// # Errors
202    ///
203    /// When a list is missing, a label names no host capability, or a
204    /// provider does not parse.
205    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    /// Every host name the hold withholds, with its capability.
221    #[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/// Host calls made through granted host names, per process of one node.
238#[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    /// One host call, by the process running now.
248    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    /// Every call tallied, by every process.
256    #[must_use]
257    pub fn total(&self) -> u64 {
258        locked(&self.0).total
259    }
260
261    /// The calls `pid` made since its incarnation began.
262    #[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    /// `pid` runs now: what is tallied is its.
268    pub fn enter(&self, pid: Pid) {
269        locked(&self.0).current = pid;
270    }
271
272    /// `pid`'s count, which starts again at zero.
273    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
286/// A table the runtime hands a node unfinished: computed the first time an
287/// artifact asks, and never when none does.
288struct 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/// Each definition key's `def_hash` (`b3:<hex>`), computed on first ask.
330#[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    /// Compute the table with `hash` when the first identity is asked for.
341    pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
342        self.0.defer(hash);
343    }
344
345    /// The `def_hash` of the definition bound to `key`, if it has one.
346    #[must_use]
347    pub fn of(&self, key: &str) -> Option<String> {
348        self.0.read(|table| table.get(key).cloned())
349    }
350}
351
352/// The file a definition was read from: the name a reader opens it by, and
353/// the text its spans index.
354#[derive(Clone, Debug)]
355pub struct Source {
356    pub file: String,
357    pub text: Arc<str>,
358}
359
360/// A position a reader can open: a file, and a 1-based line and column.
361#[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/// Each definition key's [`Source`], computed on first ask: what turns a
375/// span in a crashed process's code into a [`Place`].
376#[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    /// Compute the table with `read` when the first place is asked for.
387    pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
388        self.0.defer(read);
389    }
390
391    /// Where `span` is, inside the definition bound to `key`. `None` for a
392    /// synthetic span, a key with no source, or a span past the end of the
393    /// key's file: an offset outside the file is proof it indexes another
394    /// one, and answering it anyway would name a line that is not the place.
395    #[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/// What a receipt attests about.
415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
416pub enum Subject {
417    /// One incarnation of a process.
418    Actor { pid: Pid, incarnation: usize },
419    /// A whole run, its root and every process it started.
420    Run,
421}
422
423/// One actor's or one run's account of itself.
424#[derive(Clone, Debug, PartialEq, Eq)]
425pub struct Receipt {
426    pub subject: Subject,
427    /// The definition key it ran.
428    pub definition: Option<String>,
429    /// That definition's `def_hash`.
430    pub def_hash: Option<String>,
431    pub frame: Frame,
432    /// Calls through granted host names.
433    pub host_io: u64,
434    pub exit_kind: String,
435    pub exit_text: String,
436    /// A server incarnation's state roots; `None` for any other subject,
437    /// and then the receipt is version 1.
438    pub roots: Option<Roots>,
439}
440
441impl Receipt {
442    /// This receipt as `blue-receipt` JSON: version 2 with its roots, version
443    /// 1 without.
444    #[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    /// The bytes a receipt is: its JSON, canonical.
471    #[must_use]
472    pub fn bytes(&self) -> Vec<u8> {
473        canonical(&self.to_json()).into_bytes()
474    }
475}
476
477/// Where a node hands each actor's receipt.
478pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
479
480/// `j` with every object's keys sorted and no whitespace: one text per value.
481#[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}