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, 2 and 3
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//!
48//! Version 3 is version 1 or 2 with `steps`, written only when the run asks
49//! for the count (`blue run --receipt-steps`): the steps the incarnation's
50//! VM charged, or the evaluation steps the run's own interpreter spent.
51//!
52//! ```text
53//! { …, "steps": n, …, "version": 3 }
54//! ```
55//!
56//! A count moves with the compiler's instruction count, so a receipt that
57//! carries one is renamed by any change to the code blue emits; one that
58//! does not ask keeps its version 1 or 2 bytes.
59
60use std::collections::BTreeMap;
61use std::sync::{Arc, Mutex, MutexGuard};
62
63use blue_lang_waku::{Capability, Provided};
64use serde_json::{json, Value as Json};
65
66use crate::Pid;
67
68/// The format name every receipt carries.
69pub const FORMAT: &str = "blue-receipt";
70/// The version a receipt with state roots carries.
71pub const VERSION: u64 = 2;
72/// The version a receipt without state roots carries.
73pub const VERSION_UNROOTED: u64 = 1;
74pub const VERSION_STEPPED: u64 = 3;
75/// Every version [`check`] accepts.
76pub const READS: [u64; 3] = [VERSION_UNROOTED, VERSION, VERSION_STEPPED];
77/// The keys a server's receipt has from version 2 on.
78pub const ROOT_KEYS: [&str; 2] = ["input_root", "output_root"];
79pub const STEPS_KEY: &str = "steps";
80
81/// The root of a server's state: `b3:` and the BLAKE3 hash of its canonical
82/// JSON. The runtime computes it (`blue_lang_runtime::json::state_root`); a
83/// node only calls it.
84pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
85
86/// A server incarnation's state roots: before its first message, and after
87/// the last one its handler returned from.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct Roots {
90    pub input: String,
91    pub output: String,
92}
93
94fn is_root(text: &str) -> bool {
95    text.strip_prefix("b3:").is_some_and(|hex| {
96        hex.len() == 64
97            && hex
98                .bytes()
99                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
100    })
101}
102
103/// Whether `receipt` has the form its `version` names: version 1 carries no
104/// state root, version 2 carries both, each `b3:` and 64 lowercase hex
105/// digits, and version 3 carries `steps`, a count, with both roots or
106/// neither.
107///
108/// # Errors
109///
110/// Naming a version this blue does not read, or the root or count that is
111/// missing, present where it may not be, or not a hash or a count.
112pub fn check(receipt: &Json) -> Result<(), String> {
113    let version = receipt.get("version").and_then(Json::as_u64);
114    let (rooted, stepped) = match version {
115        Some(VERSION_UNROOTED) => (false, false),
116        Some(VERSION) => (true, false),
117        Some(VERSION_STEPPED) => (ROOT_KEYS.iter().any(|k| receipt.get(*k).is_some()), true),
118        _ => {
119            return Err(format!(
120                "{FORMAT} version {}; this blue reads versions {} to {VERSION_STEPPED}",
121                receipt.get("version").unwrap_or(&Json::Null),
122                READS[0]
123            ))
124        }
125    };
126    let v = version.unwrap_or_default();
127    for key in ROOT_KEYS {
128        match (rooted, receipt.get(key)) {
129            (false, None) => {}
130            (false, Some(_)) => {
131                return Err(format!(
132                    "a version {v} receipt carries `{key}`, which only a server's receipt of version {VERSION} or {VERSION_STEPPED} has"
133                ))
134            }
135            (true, None) => return Err(format!("a version {v} receipt without `{key}`")),
136            (true, Some(root)) if !root.as_str().is_some_and(is_root) => {
137                return Err(format!("`{key}` is {root}, not b3: and 64 hex digits"))
138            }
139            (true, Some(_)) => {}
140        }
141    }
142    match (stepped, receipt.get(STEPS_KEY)) {
143        (false, Some(_)) => Err(format!(
144            "a version {v} receipt carries `{STEPS_KEY}`, which only version {VERSION_STEPPED} has"
145        )),
146        (true, None) => Err(format!("a version {v} receipt without `{STEPS_KEY}`")),
147        (true, Some(n)) if n.as_u64().is_none() => {
148            Err(format!("`{STEPS_KEY}` is {n}, not a count"))
149        }
150        _ => Ok(()),
151    }
152}
153
154fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
155    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
156}
157
158/// A hold as an artifact names it.
159#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Frame {
161    /// Host capabilities granted from the host.
162    pub granted: Vec<Capability>,
163    /// The pure providers standing in for host capabilities, each for the
164    /// whole program or, under `for`, for the packages it names.
165    pub provided: Vec<Provided>,
166    /// Host capabilities neither granted nor provided: a call is a breach.
167    pub withheld: Vec<Capability>,
168}
169
170fn labels(caps: &[Capability]) -> Json {
171    Json::Array(caps.iter().map(|c| json!(c.label())).collect())
172}
173
174fn capability(label: &str) -> Option<Capability> {
175    Capability::host_effects()
176        .into_iter()
177        .find(|c| c.label() == label)
178}
179
180fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
181    j.and_then(Json::as_array)
182        .ok_or_else(|| format!("a hold without `{what}`"))?
183        .iter()
184        .map(|l| {
185            l.as_str()
186                .and_then(capability)
187                .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
188        })
189        .collect()
190}
191
192impl Frame {
193    /// The receipt's frame block: `{"granted":[…],"withheld":[…],"host_io":n}`.
194    #[must_use]
195    pub fn block(&self, host_io: u64) -> Json {
196        json!({
197            "granted": labels(&self.granted),
198            "withheld": labels(&self.withheld),
199            "host_io": host_io,
200        })
201    }
202
203    /// The providers as the lock records them: the provider, its settings,
204    /// and `for` with the packages it serves when it is scoped.
205    #[must_use]
206    pub fn provided_json(&self) -> Json {
207        Json::Array(
208            self.provided
209                .iter()
210                .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
211                .collect(),
212        )
213    }
214
215    /// The hold as a crash trace writes it.
216    #[must_use]
217    pub fn to_json(&self) -> Json {
218        json!({
219            "granted": labels(&self.granted),
220            "provided": self.provided_json(),
221            "withheld": labels(&self.withheld),
222        })
223    }
224
225    /// Read a hold back.
226    ///
227    /// # Errors
228    ///
229    /// When a list is missing, a label names no host capability, or a
230    /// provider does not parse.
231    pub fn from_json(j: &Json) -> Result<Self, String> {
232        let provided = j
233            .get("provided")
234            .and_then(Json::as_array)
235            .ok_or("a hold without `provided`")?
236            .iter()
237            .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
238            .collect::<Result<_, _>>()?;
239        Ok(Self {
240            granted: capabilities(j.get("granted"), "granted")?,
241            provided,
242            withheld: capabilities(j.get("withheld"), "withheld")?,
243        })
244    }
245
246    /// Every host name the hold withholds, with its capability.
247    #[must_use]
248    pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
249        self.withheld
250            .iter()
251            .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
252            .collect()
253    }
254}
255
256#[derive(Debug, Default)]
257struct Ledger {
258    current: Pid,
259    total: u64,
260    by_pid: BTreeMap<Pid, u64>,
261}
262
263/// Host calls made through granted host names, per process of one node.
264#[derive(Clone, Debug, Default)]
265pub struct HostIo(Arc<Mutex<Ledger>>);
266
267impl HostIo {
268    #[must_use]
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    /// One host call, by the process running now.
274    pub fn tally(&self) {
275        let mut l = locked(&self.0);
276        l.total += 1;
277        let pid = crate::scheduling::lane().unwrap_or(l.current);
278        *l.by_pid.entry(pid).or_default() += 1;
279    }
280
281    /// Every call tallied, by every process.
282    #[must_use]
283    pub fn total(&self) -> u64 {
284        locked(&self.0).total
285    }
286
287    /// The calls `pid` made since its incarnation began.
288    #[must_use]
289    pub fn of(&self, pid: Pid) -> u64 {
290        locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
291    }
292
293    /// `pid` runs now: what is tallied is its.
294    pub fn enter(&self, pid: Pid) {
295        locked(&self.0).current = pid;
296    }
297
298    /// `pid`'s count, which starts again at zero.
299    pub fn take(&self, pid: Pid) -> u64 {
300        locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
301    }
302}
303
304type Deferred<T> = Box<dyn FnOnce() -> T + Send>;
305
306enum Table<T> {
307    Unknown,
308    Deferred(Deferred<T>),
309    Known(T),
310}
311
312/// A table the runtime hands a node unfinished: computed the first time an
313/// artifact asks, and never when none does.
314struct Lazy<T>(Arc<Mutex<Table<T>>>);
315
316impl<T> Clone for Lazy<T> {
317    fn clone(&self) -> Self {
318        Self(Arc::clone(&self.0))
319    }
320}
321
322impl<T> Default for Lazy<T> {
323    fn default() -> Self {
324        Self(Arc::new(Mutex::new(Table::Unknown)))
325    }
326}
327
328impl<T> Lazy<T> {
329    fn defer(&self, compute: impl FnOnce() -> T + Send + 'static) {
330        *locked(&self.0) = Table::Deferred(Box::new(compute));
331    }
332
333    fn read<R>(&self, f: impl FnOnce(&T) -> Option<R>) -> Option<R> {
334        let mut state = locked(&self.0);
335        if let Table::Deferred(_) = &*state {
336            if let Table::Deferred(compute) = std::mem::replace(&mut *state, Table::Unknown) {
337                *state = Table::Known(compute());
338            }
339        }
340        match &*state {
341            Table::Known(table) => f(table),
342            Table::Unknown | Table::Deferred(_) => None,
343        }
344    }
345
346    fn state(&self) -> &'static str {
347        match &*locked(&self.0) {
348            Table::Unknown => "unknown",
349            Table::Deferred(_) => "deferred",
350            Table::Known(_) => "known",
351        }
352    }
353}
354
355/// Each definition key's `def_hash` (`b3:<hex>`), computed on first ask.
356#[derive(Clone, Default)]
357pub struct Identities(Lazy<BTreeMap<String, String>>);
358
359impl std::fmt::Debug for Identities {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        write!(f, "Identities({})", self.0.state())
362    }
363}
364
365impl Identities {
366    /// Compute the table with `hash` when the first identity is asked for.
367    pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
368        self.0.defer(hash);
369    }
370
371    /// The `def_hash` of the definition bound to `key`, if it has one.
372    #[must_use]
373    pub fn of(&self, key: &str) -> Option<String> {
374        self.0.read(|table| table.get(key).cloned())
375    }
376}
377
378/// The file a definition was read from: the name a reader opens it by, and
379/// the text its spans index.
380#[derive(Clone, Debug)]
381pub struct Source {
382    pub file: String,
383    pub text: Arc<str>,
384}
385
386/// A position a reader can open: a file, and a 1-based line and column.
387#[derive(Clone, Debug, PartialEq, Eq)]
388pub struct Place {
389    pub file: String,
390    pub line: usize,
391    pub column: usize,
392}
393
394impl std::fmt::Display for Place {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        write!(f, "{}:{}:{}", self.file, self.line, self.column)
397    }
398}
399
400/// Each definition key's [`Source`], computed on first ask: what turns a
401/// span in a crashed process's code into a [`Place`].
402#[derive(Clone, Default)]
403pub struct Places(Lazy<BTreeMap<String, Source>>);
404
405impl std::fmt::Debug for Places {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        write!(f, "Places({})", self.0.state())
408    }
409}
410
411impl Places {
412    /// Compute the table with `read` when the first place is asked for.
413    pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
414        self.0.defer(read);
415    }
416
417    /// Where `span` is, inside the definition bound to `key`. `None` for a
418    /// synthetic span, a key with no source, or a span past the end of the
419    /// key's file: an offset outside the file is proof it indexes another
420    /// one, and answering it anyway would name a line that is not the place.
421    #[must_use]
422    pub fn locate(&self, key: &str, span: tatara_lisp::Span) -> Option<Place> {
423        if span.is_synthetic() {
424            return None;
425        }
426        self.0.read(|table| {
427            let source = table.get(key)?;
428            (span.end <= source.text.len()).then(|| {
429                let (line, column) = tatara_lisp::Span::line_col(&source.text, span.start);
430                Place {
431                    file: source.file.clone(),
432                    line,
433                    column,
434                }
435            })
436        })
437    }
438}
439
440/// What a receipt attests about.
441#[derive(Clone, Copy, Debug, PartialEq, Eq)]
442pub enum Subject {
443    /// One incarnation of a process.
444    Actor { pid: Pid, incarnation: usize },
445    /// A whole run, its root and every process it started.
446    Run,
447}
448
449/// One actor's or one run's account of itself.
450#[derive(Clone, Debug, PartialEq, Eq)]
451pub struct Receipt {
452    pub subject: Subject,
453    /// The definition key it ran.
454    pub definition: Option<String>,
455    /// That definition's `def_hash`.
456    pub def_hash: Option<String>,
457    pub frame: Frame,
458    /// Calls through granted host names.
459    pub host_io: u64,
460    pub exit_kind: String,
461    pub exit_text: String,
462    /// A server incarnation's state roots; `None` for any other subject,
463    /// and then the receipt is version 1.
464    pub roots: Option<Roots>,
465    pub steps: Option<u64>,
466}
467
468impl Receipt {
469    /// This receipt as `blue-receipt` JSON: version 2 with its roots, version
470    /// 1 without.
471    #[must_use]
472    pub fn to_json(&self) -> Json {
473        let (subject, pid, incarnation) = match self.subject {
474            Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
475            Subject::Run => ("run", crate::ROOT.0, 0),
476        };
477        let mut j = json!({
478            "format": FORMAT,
479            "version": VERSION_UNROOTED,
480            "subject": subject,
481            "pid": pid,
482            "incarnation": incarnation,
483            "definition": self.definition,
484            "def_hash": self.def_hash,
485            "exit": { "kind": self.exit_kind, "text": self.exit_text },
486            "frame": self.frame.block(self.host_io),
487            "provided": self.frame.provided_json(),
488        });
489        if let Some(roots) = &self.roots {
490            j["version"] = json!(VERSION);
491            j["input_root"] = json!(roots.input);
492            j["output_root"] = json!(roots.output);
493        }
494        if let Some(steps) = self.steps {
495            j["version"] = json!(VERSION_STEPPED);
496            j[STEPS_KEY] = json!(steps);
497        }
498        j
499    }
500
501    /// The bytes a receipt is: its JSON, canonical.
502    #[must_use]
503    pub fn bytes(&self) -> Vec<u8> {
504        canonical(&self.to_json()).into_bytes()
505    }
506}
507
508/// Where a node hands each actor's receipt.
509pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
510
511/// `j` with every object's keys sorted and no whitespace: one text per value.
512#[must_use]
513pub fn canonical(j: &Json) -> String {
514    let mut out = String::new();
515    write_canonical(j, &mut out);
516    out
517}
518
519fn write_canonical(j: &Json, out: &mut String) {
520    match j {
521        Json::Object(m) => {
522            let mut keys: Vec<&String> = m.keys().collect();
523            keys.sort();
524            out.push('{');
525            for (i, k) in keys.into_iter().enumerate() {
526                if i > 0 {
527                    out.push(',');
528                }
529                out.push_str(&Json::String(k.clone()).to_string());
530                out.push(':');
531                write_canonical(&m[k], out);
532            }
533            out.push('}');
534        }
535        Json::Array(items) => {
536            out.push('[');
537            for (i, v) in items.iter().enumerate() {
538                if i > 0 {
539                    out.push(',');
540                }
541                write_canonical(v, out);
542            }
543            out.push(']');
544        }
545        other => out.push_str(&other.to_string()),
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn frame() -> Frame {
554        Frame {
555            granted: vec![Capability::FileSystem],
556            provided: vec![
557                blue_lang_waku::Provision::DeterministicStepped {
558                    at_ms: 5,
559                    step_ms: 1,
560                }
561                .into(),
562                Provided {
563                    provision: blue_lang_waku::Provision::NullSink,
564                    scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
565                },
566            ],
567            withheld: vec![
568                Capability::Process,
569                Capability::Environment,
570                Capability::Network,
571                Capability::Entropy,
572            ],
573        }
574    }
575
576    #[test]
577    fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
578        let block = frame().block(3);
579        let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
580        let mut sorted = keys.clone();
581        sorted.sort();
582        assert_eq!(sorted, ["granted", "host_io", "withheld"]);
583        assert_eq!(
584            canonical(&block),
585            r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
586        );
587    }
588
589    #[test]
590    fn a_hold_survives_the_trace_format() {
591        let f = frame();
592        assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
593        let mut bad = f.to_json();
594        bad["withheld"] = json!(["disk"]);
595        assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
596        assert!(Frame::from_json(&json!({})).is_err());
597    }
598
599    #[test]
600    fn withheld_names_are_every_name_of_every_withheld_capability() {
601        let names = frame().withheld_names();
602        assert!(names.contains(&("getenv", Capability::Environment)));
603        assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
604        assert_eq!(
605            names.len(),
606            frame()
607                .withheld
608                .iter()
609                .map(|c| c.names().len())
610                .sum::<usize>()
611        );
612        assert!(Frame::default().withheld_names().is_empty());
613    }
614
615    #[test]
616    fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
617        let io = HostIo::new();
618        io.tally();
619        io.enter(Pid(3));
620        io.tally();
621        io.tally();
622        io.enter(crate::ROOT);
623        io.tally();
624        assert_eq!(io.total(), 4);
625        assert_eq!(io.of(crate::ROOT), 2);
626        assert_eq!(io.of(Pid(3)), 2);
627        assert_eq!(io.take(Pid(3)), 2);
628        assert_eq!(io.of(Pid(3)), 0);
629        assert_eq!(io.total(), 4, "the run total keeps every call");
630        assert_eq!(HostIo::new().total(), 0);
631    }
632
633    #[test]
634    fn identities_are_hashed_once_and_only_when_asked() {
635        let ids = Identities::default();
636        assert_eq!(ids.of("%root/f"), None);
637        let calls = Arc::new(Mutex::new(0));
638        let c = Arc::clone(&calls);
639        ids.defer(move || {
640            *locked(&c) += 1;
641            BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
642        });
643        assert_eq!(*locked(&calls), 0);
644        assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
645        assert_eq!(ids.clone().of("%root/g"), None);
646        assert_eq!(*locked(&calls), 1);
647    }
648
649    #[test]
650    fn a_place_is_read_once_and_only_inside_its_own_file() {
651        let places = Places::default();
652        let at = tatara_lisp::Span::new(10, 14);
653        assert_eq!(places.locate("%root/f", at), None);
654        let calls = Arc::new(Mutex::new(0));
655        let c = Arc::clone(&calls);
656        places.defer(move || {
657            *locked(&c) += 1;
658            BTreeMap::from([(
659                "%root/f".to_string(),
660                Source {
661                    file: "w.b".into(),
662                    text: Arc::from("def f()\n  g(1)\nend\n"),
663                },
664            )])
665        });
666        assert_eq!(*locked(&calls), 0);
667        let place = places.locate("%root/f", at).expect("in its file");
668        assert_eq!(place.to_string(), "w.b:2:3");
669        assert_eq!(
670            places.locate("%root/f", tatara_lisp::Span::new(10, 99)),
671            None
672        );
673        assert_eq!(
674            places.locate("%root/f", tatara_lisp::Span::synthetic()),
675            None
676        );
677        assert_eq!(places.clone().locate("%root/g", at), None);
678        assert_eq!(*locked(&calls), 1);
679    }
680
681    #[test]
682    fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
683        let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
684        assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
685        let r = Receipt {
686            subject: Subject::Actor {
687                pid: Pid(2),
688                incarnation: 1,
689            },
690            definition: Some("%root/w".into()),
691            def_hash: Some("b3:ab".into()),
692            frame: frame(),
693            host_io: 7,
694            exit_kind: "normal".into(),
695            exit_text: "normal".into(),
696            roots: None,
697            steps: None,
698        };
699        let text = String::from_utf8(r.bytes()).expect("utf8");
700        assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
701        let back: Json = serde_json::from_str(&text).expect("json");
702        assert_eq!(canonical(&back), text);
703        assert_eq!(back["frame"]["host_io"], 7);
704        assert_eq!(back["subject"], "actor");
705        assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
706        assert!(
707            back["provided"][0].get("for").is_none(),
708            "a whole-program provider"
709        );
710        assert_eq!(
711            back["provided"][1],
712            json!({"provider": "null-sink", "for": ["kazu"]})
713        );
714    }
715
716    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}"#;
717
718    fn actor(roots: Option<Roots>) -> Receipt {
719        Receipt {
720            subject: Subject::Actor {
721                pid: Pid(2),
722                incarnation: 1,
723            },
724            definition: Some("%root/w".into()),
725            def_hash: Some("b3:ab".into()),
726            frame: frame(),
727            host_io: 7,
728            exit_kind: "normal".into(),
729            exit_text: "normal".into(),
730            roots,
731            steps: None,
732        }
733    }
734
735    fn root(c: char) -> String {
736        format!("b3:{}", c.to_string().repeat(64))
737    }
738
739    #[test]
740    fn a_receipt_without_roots_is_the_version_1_receipt_byte_for_byte() {
741        let r = actor(None);
742        assert_eq!(String::from_utf8(r.bytes()).expect("utf8"), V1);
743        assert_eq!(check(&r.to_json()), Ok(()));
744    }
745
746    #[test]
747    fn a_receipt_with_roots_is_version_2_and_without_them_is_version_1_again() {
748        let r = actor(Some(Roots {
749            input: root('a'),
750            output: root('b'),
751        }));
752        let j = r.to_json();
753        assert_eq!(j["version"], VERSION);
754        assert_eq!(j["input_root"], root('a'));
755        assert_eq!(j["output_root"], root('b'));
756        assert_eq!(check(&j), Ok(()));
757        let mut stripped = j.clone();
758        for key in ROOT_KEYS {
759            stripped.as_object_mut().expect("object").remove(key);
760        }
761        stripped["version"] = json!(VERSION_UNROOTED);
762        assert_eq!(canonical(&stripped), V1);
763    }
764
765    #[test]
766    fn check_reads_versions_1_and_2_and_refuses_a_root_out_of_place() {
767        let v1 = actor(None).to_json();
768        let v2 = actor(Some(Roots {
769            input: root('0'),
770            output: root('f'),
771        }))
772        .to_json();
773        let edited = |base: &Json, key: &str, value: Json| {
774            let mut j = base.clone();
775            j[key] = value;
776            j
777        };
778        let refused = |j: &Json, why: &str| {
779            let e = check(j).expect_err(why);
780            assert!(e.contains(why), "{e}");
781        };
782        refused(
783            &edited(&v1, "input_root", json!(root('a'))),
784            "carries `input_root`",
785        );
786        refused(&edited(&v1, "version", json!(4)), "reads versions 1 to 3");
787        refused(
788            &edited(&v2, "output_root", json!("b3:00")),
789            "not b3: and 64 hex",
790        );
791        refused(
792            &edited(&v2, "input_root", json!(root('A'))),
793            "not b3: and 64 hex",
794        );
795        let mut half = v2.clone();
796        half.as_object_mut().expect("object").remove("output_root");
797        refused(&half, "without `output_root`");
798        let mut unversioned = v1.clone();
799        unversioned
800            .as_object_mut()
801            .expect("object")
802            .remove("version");
803        refused(&unversioned, "reads versions 1 to 3");
804    }
805
806    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}"#;
807
808    fn rooted() -> Option<Roots> {
809        Some(Roots {
810            input: root('a'),
811            output: root('b'),
812        })
813    }
814
815    fn counted(roots: Option<Roots>, steps: u64) -> Receipt {
816        Receipt {
817            steps: Some(steps),
818            ..actor(roots)
819        }
820    }
821
822    fn uncounted(mut j: Json, version: u64) -> String {
823        j.as_object_mut().expect("object").remove(STEPS_KEY);
824        j["version"] = json!(version);
825        canonical(&j)
826    }
827
828    #[test]
829    fn a_receipt_that_counts_no_steps_keeps_its_version_1_or_2_bytes() {
830        assert_eq!(String::from_utf8(actor(None).bytes()).expect("utf8"), V1);
831        assert_eq!(
832            String::from_utf8(actor(rooted()).bytes()).expect("utf8"),
833            V2
834        );
835        for text in [V1, V2] {
836            let j: Json = serde_json::from_str(text).expect("json");
837            assert_eq!(check(&j), Ok(()), "{text}");
838        }
839    }
840
841    #[test]
842    fn a_receipt_with_steps_is_version_3_and_without_them_its_old_bytes() {
843        let run = counted(None, 42).to_json();
844        assert_eq!(run["version"], VERSION_STEPPED);
845        assert_eq!(run[STEPS_KEY], 42);
846        assert!(run.get("input_root").is_none());
847        assert_eq!(check(&run), Ok(()));
848        assert_eq!(uncounted(run, VERSION_UNROOTED), V1);
849        let server = counted(rooted(), 0).to_json();
850        assert_eq!(server["version"], VERSION_STEPPED);
851        assert_eq!(server[STEPS_KEY], 0);
852        assert_eq!(server["input_root"], root('a'));
853        assert_eq!(check(&server), Ok(()));
854        assert_eq!(uncounted(server, VERSION), V2);
855    }
856
857    #[test]
858    fn check_reads_version_3_and_refuses_a_count_out_of_place() {
859        let refused = |j: &Json, why: &str| {
860            let e = check(j).expect_err(why);
861            assert!(e.contains(why), "{why}: {e}");
862        };
863        let with = |base: Json, key: &str, value: Json| {
864            let mut j = base;
865            j[key] = value;
866            j
867        };
868        let without = |base: Json, key: &str| {
869            let mut j = base;
870            j.as_object_mut().expect("object").remove(key);
871            j
872        };
873        refused(
874            &with(actor(None).to_json(), STEPS_KEY, json!(3)),
875            "carries `steps`",
876        );
877        refused(
878            &with(actor(rooted()).to_json(), STEPS_KEY, json!(3)),
879            "carries `steps`",
880        );
881        refused(
882            &without(counted(None, 1).to_json(), STEPS_KEY),
883            "without `steps`",
884        );
885        refused(
886            &with(counted(None, 1).to_json(), STEPS_KEY, json!(-1)),
887            "not a count",
888        );
889        refused(
890            &with(counted(None, 1).to_json(), STEPS_KEY, json!("1")),
891            "not a count",
892        );
893        refused(
894            &with(counted(None, 1).to_json(), STEPS_KEY, json!(1.5)),
895            "not a count",
896        );
897        refused(
898            &without(counted(rooted(), 1).to_json(), "input_root"),
899            "without `input_root`",
900        );
901        refused(
902            &with(
903                counted(rooted(), 1).to_json(),
904                "output_root",
905                json!("b3:00"),
906            ),
907            "not b3: and 64 hex",
908        );
909    }
910}