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