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//!
60//! Version 4 is version 1 or 3 with `carried`, written only for a plain
61//! process that arrived from a held node with its host-call count: the
62//! calls it made before the move, which `frame.host_io` includes, and the
63//! `blue-image` they came in. A count smaller than what came is refused.
64//!
65//! ```text
66//! { …, "carried": { "host_io": n, "image": "b3:…" }, …, "version": 4 }
67//! ```
68//!
69//! Version 5 is version 2 or 3 with `carried`, written only for a
70//! `spawn_server` incarnation that arrived from a held node with its
71//! host-call count and the root of the state it began with there: its
72//! `input_root` is that root, which `carried.input_root` names again, so the
73//! receipt covers the incarnation from its first message on the first node
74//! to its last on the node it ended on. A carried root that is not the
75//! receipt's `input_root` is refused, as is a count smaller than what came.
76//!
77//! ```text
78//! { …, "carried": { "host_io": n, "image": "b3:…", "input_root": "b3:…" },
79//!   "input_root": "b3:…", "output_root": "b3:…", …, "version": 5 }
80//! ```
81
82use 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
90/// The format name every receipt carries.
91pub const FORMAT: &str = "blue-receipt";
92/// The version a receipt with state roots carries.
93pub const VERSION: u64 = 2;
94/// The version a receipt without state roots carries.
95pub const VERSION_UNROOTED: u64 = 1;
96pub const VERSION_STEPPED: u64 = 3;
97pub const VERSION_CARRIED: u64 = 4;
98pub const VERSION_CARRIED_ROOTED: u64 = 5;
99/// Every version [`check`] accepts.
100pub const READS: [u64; 5] = [
101    VERSION_UNROOTED,
102    VERSION,
103    VERSION_STEPPED,
104    VERSION_CARRIED,
105    VERSION_CARRIED_ROOTED,
106];
107/// The keys a server's receipt has from version 2 on.
108pub 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
129/// The root of a server's state: `b3:` and the BLAKE3 hash of its canonical
130/// JSON. The runtime computes it (`blue_lang_runtime::json::state_root`); a
131/// node only calls it.
132pub type StateRoot = fn(&tatara_lisp_eval::Value) -> String;
133
134/// A server incarnation's state roots: before its first message, and after
135/// the last one its handler returned from.
136#[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
151/// Whether `receipt` has the form its `version` names: version 1 carries no
152/// state root, version 2 carries both, each `b3:` and 64 lowercase hex
153/// digits, version 3 carries `steps`, a count, with both roots or neither,
154/// version 4 carries `carried`, no root, and `steps` or not, and version 5
155/// carries both roots and `carried` with the `input_root` the receipt names,
156/// and `steps` or not.
157///
158/// # Errors
159///
160/// Naming a version this blue does not read, or the root, count or carried
161/// block that is missing, present where it may not be, not a hash or a
162/// count, or more than the receipt's own count.
163pub 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/// A hold as an artifact names it.
280#[derive(Clone, Debug, Default, PartialEq, Eq)]
281pub struct Frame {
282    /// Host capabilities granted from the host.
283    pub granted: Vec<Capability>,
284    /// The pure providers standing in for host capabilities, each for the
285    /// whole program or, under `for`, for the packages it names.
286    pub provided: Vec<Provided>,
287    /// Host capabilities neither granted nor provided: a call is a breach.
288    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    /// The receipt's frame block: `{"granted":[…],"withheld":[…],"host_io":n}`.
315    #[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    /// The providers as the lock records them: the provider, its settings,
325    /// and `for` with the packages it serves when it is scoped.
326    #[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    /// The hold as a crash trace writes it.
337    #[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    /// Read a hold back.
347    ///
348    /// # Errors
349    ///
350    /// When a list is missing, a label names no host capability, or a
351    /// provider does not parse.
352    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    /// Every host name the hold withholds, with its capability.
368    #[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/// Host calls made through granted host names, per process of one node.
385#[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    /// One host call, by the process running now.
395    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    /// Every call tallied, by every process.
403    #[must_use]
404    pub fn total(&self) -> u64 {
405        locked(&self.0).total
406    }
407
408    /// The calls `pid` made since its incarnation began.
409    #[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    /// `pid` runs now: what is tallied is its.
415    pub fn enter(&self, pid: Pid) {
416        locked(&self.0).current = pid;
417    }
418
419    /// `pid`'s count, which starts again at zero.
420    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
437/// A table the runtime hands a node unfinished: computed the first time an
438/// artifact asks, and never when none does.
439struct 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/// Each definition key's `def_hash` (`b3:<hex>`), computed on first ask.
481#[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    /// Compute the table with `hash` when the first identity is asked for.
492    pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
493        self.0.defer(hash);
494    }
495
496    /// The `def_hash` of the definition bound to `key`, if it has one.
497    #[must_use]
498    pub fn of(&self, key: &str) -> Option<String> {
499        self.0.read(|table| table.get(key).cloned())
500    }
501}
502
503/// The file a definition was read from: the name a reader opens it by, and
504/// the text its spans index.
505#[derive(Clone, Debug)]
506pub struct Source {
507    pub file: String,
508    pub text: Arc<str>,
509}
510
511/// A position a reader can open: a file, and a 1-based line and column.
512#[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/// Each definition key's [`Source`], computed on first ask: what turns a
526/// span in a crashed process's code into a [`Place`].
527#[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    /// Compute the table with `read` when the first place is asked for.
538    pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
539        self.0.defer(read);
540    }
541
542    /// Where `span` is, inside the definition bound to `key`. `None` for a
543    /// synthetic span, a key with no source, or a span past the end of the
544    /// key's file: an offset outside the file is proof it indexes another
545    /// one, and answering it anyway would name a line that is not the place.
546    #[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/// What a receipt attests about.
566#[derive(Clone, Copy, Debug, PartialEq, Eq)]
567pub enum Subject {
568    /// One incarnation of a process.
569    Actor { pid: Pid, incarnation: usize },
570    /// A whole run, its root and every process it started.
571    Run,
572}
573
574/// One actor's or one run's account of itself.
575#[derive(Clone, Debug, PartialEq, Eq)]
576pub struct Receipt {
577    pub subject: Subject,
578    /// The definition key it ran.
579    pub definition: Option<String>,
580    /// That definition's `def_hash`.
581    pub def_hash: Option<String>,
582    pub frame: Frame,
583    /// Calls through granted host names.
584    pub host_io: u64,
585    pub exit_kind: String,
586    pub exit_text: String,
587    /// A server incarnation's state roots; `None` for any other subject,
588    /// and then the receipt is version 1.
589    pub roots: Option<Roots>,
590    pub steps: Option<u64>,
591    pub carried: Option<CarriedIo>,
592}
593
594impl Receipt {
595    /// This receipt as `blue-receipt` JSON: version 2 with its roots, version
596    /// 1 without.
597    #[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    /// The bytes a receipt is: its JSON, canonical.
636    #[must_use]
637    pub fn bytes(&self) -> Vec<u8> {
638        canonical(&self.to_json()).into_bytes()
639    }
640}
641
642/// Where a node hands each actor's receipt.
643pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
644
645/// `j` with every object's keys sorted and no whitespace: one text per value.
646#[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}