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//! Three 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//!
17//! ## Format `blue-receipt` version 1
18//!
19//! One canonical JSON object (keys sorted, no whitespace), named by the
20//! BLAKE3 hash of its bytes (`blue_lang_runtime::receipt` writes and
21//! verifies it):
22//!
23//! ```text
24//! { "def_hash": "b3:…" | null, "definition": "%root/reader" | "%run" | null,
25//!   "exit": { "kind": "normal" | "crashed" | "runaway" | "killed" | "fatal" | "refused", "text": "…" },
26//!   "format": "blue-receipt",
27//!   "frame": { "granted": [LABEL…], "host_io": n, "withheld": [LABEL…] },
28//!   "incarnation": n, "pid": n,
29//!   "provided": [{ "provider": "deterministic-stepped", …, "for": [PACKAGE…] }…],
30//!   "subject": "actor" | "run", "version": 1 }
31//! ```
32
33use std::collections::BTreeMap;
34use std::sync::{Arc, Mutex, MutexGuard};
35
36use blue_lang_waku::{Capability, Provided};
37use serde_json::{json, Value as Json};
38
39use crate::Pid;
40
41/// The format name every receipt carries.
42pub const FORMAT: &str = "blue-receipt";
43/// The version this crate writes.
44pub const VERSION: u64 = 1;
45
46fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
47    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
48}
49
50/// A hold as an artifact names it.
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
52pub struct Frame {
53    /// Host capabilities granted from the host.
54    pub granted: Vec<Capability>,
55    /// The pure providers standing in for host capabilities, each for the
56    /// whole program or, under `for`, for the packages it names.
57    pub provided: Vec<Provided>,
58    /// Host capabilities neither granted nor provided: a call is a breach.
59    pub withheld: Vec<Capability>,
60}
61
62fn labels(caps: &[Capability]) -> Json {
63    Json::Array(caps.iter().map(|c| json!(c.label())).collect())
64}
65
66fn capability(label: &str) -> Option<Capability> {
67    Capability::host_effects()
68        .into_iter()
69        .find(|c| c.label() == label)
70}
71
72fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
73    j.and_then(Json::as_array)
74        .ok_or_else(|| format!("a hold without `{what}`"))?
75        .iter()
76        .map(|l| {
77            l.as_str()
78                .and_then(capability)
79                .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
80        })
81        .collect()
82}
83
84impl Frame {
85    /// The receipt's frame block: `{"granted":[…],"withheld":[…],"host_io":n}`.
86    #[must_use]
87    pub fn block(&self, host_io: u64) -> Json {
88        json!({
89            "granted": labels(&self.granted),
90            "withheld": labels(&self.withheld),
91            "host_io": host_io,
92        })
93    }
94
95    /// The providers as the lock records them: the provider, its settings,
96    /// and `for` with the packages it serves when it is scoped.
97    #[must_use]
98    pub fn provided_json(&self) -> Json {
99        Json::Array(
100            self.provided
101                .iter()
102                .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
103                .collect(),
104        )
105    }
106
107    /// The hold as a crash trace writes it.
108    #[must_use]
109    pub fn to_json(&self) -> Json {
110        json!({
111            "granted": labels(&self.granted),
112            "provided": self.provided_json(),
113            "withheld": labels(&self.withheld),
114        })
115    }
116
117    /// Read a hold back.
118    ///
119    /// # Errors
120    ///
121    /// When a list is missing, a label names no host capability, or a
122    /// provider does not parse.
123    pub fn from_json(j: &Json) -> Result<Self, String> {
124        let provided = j
125            .get("provided")
126            .and_then(Json::as_array)
127            .ok_or("a hold without `provided`")?
128            .iter()
129            .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
130            .collect::<Result<_, _>>()?;
131        Ok(Self {
132            granted: capabilities(j.get("granted"), "granted")?,
133            provided,
134            withheld: capabilities(j.get("withheld"), "withheld")?,
135        })
136    }
137
138    /// Every host name the hold withholds, with its capability.
139    #[must_use]
140    pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
141        self.withheld
142            .iter()
143            .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
144            .collect()
145    }
146}
147
148#[derive(Debug, Default)]
149struct Ledger {
150    current: Pid,
151    total: u64,
152    by_pid: BTreeMap<Pid, u64>,
153}
154
155/// Host calls made through granted host names, per process of one node.
156#[derive(Clone, Debug, Default)]
157pub struct HostIo(Arc<Mutex<Ledger>>);
158
159impl HostIo {
160    #[must_use]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// One host call, by the process running now.
166    pub fn tally(&self) {
167        let mut l = locked(&self.0);
168        l.total += 1;
169        let pid = l.current;
170        *l.by_pid.entry(pid).or_default() += 1;
171    }
172
173    /// Every call tallied, by every process.
174    #[must_use]
175    pub fn total(&self) -> u64 {
176        locked(&self.0).total
177    }
178
179    /// The calls `pid` made since its incarnation began.
180    #[must_use]
181    pub fn of(&self, pid: Pid) -> u64 {
182        locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
183    }
184
185    /// `pid` runs now: what is tallied is its.
186    pub fn enter(&self, pid: Pid) {
187        locked(&self.0).current = pid;
188    }
189
190    /// `pid`'s count, which starts again at zero.
191    pub fn take(&self, pid: Pid) -> u64 {
192        locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
193    }
194}
195
196type Deferred = Box<dyn FnOnce() -> BTreeMap<String, String> + Send>;
197
198#[derive(Default)]
199enum Table {
200    #[default]
201    Unknown,
202    Deferred(Deferred),
203    Known(BTreeMap<String, String>),
204}
205
206/// Each definition key's `def_hash` (`b3:<hex>`), computed on first ask.
207#[derive(Clone, Default)]
208pub struct Identities(Arc<Mutex<Table>>);
209
210impl std::fmt::Debug for Identities {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        let state = match &*locked(&self.0) {
213            Table::Unknown => "unknown",
214            Table::Deferred(_) => "deferred",
215            Table::Known(_) => "known",
216        };
217        write!(f, "Identities({state})")
218    }
219}
220
221impl Identities {
222    /// Compute the table with `hash` when the first identity is asked for.
223    pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
224        *locked(&self.0) = Table::Deferred(Box::new(hash));
225    }
226
227    /// The `def_hash` of the definition bound to `key`, if it has one.
228    #[must_use]
229    pub fn of(&self, key: &str) -> Option<String> {
230        let mut state = locked(&self.0);
231        if let Table::Deferred(_) = &*state {
232            if let Table::Deferred(hash) = std::mem::take(&mut *state) {
233                *state = Table::Known(hash());
234            }
235        }
236        match &*state {
237            Table::Known(table) => table.get(key).cloned(),
238            Table::Unknown | Table::Deferred(_) => None,
239        }
240    }
241}
242
243/// What a receipt attests about.
244#[derive(Clone, Copy, Debug, PartialEq, Eq)]
245pub enum Subject {
246    /// One incarnation of a process.
247    Actor { pid: Pid, incarnation: usize },
248    /// A whole run, its root and every process it started.
249    Run,
250}
251
252/// One actor's or one run's account of itself.
253#[derive(Clone, Debug, PartialEq, Eq)]
254pub struct Receipt {
255    pub subject: Subject,
256    /// The definition key it ran.
257    pub definition: Option<String>,
258    /// That definition's `def_hash`.
259    pub def_hash: Option<String>,
260    pub frame: Frame,
261    /// Calls through granted host names.
262    pub host_io: u64,
263    pub exit_kind: String,
264    pub exit_text: String,
265}
266
267impl Receipt {
268    /// This receipt as `blue-receipt` JSON.
269    #[must_use]
270    pub fn to_json(&self) -> Json {
271        let (subject, pid, incarnation) = match self.subject {
272            Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
273            Subject::Run => ("run", crate::ROOT.0, 0),
274        };
275        json!({
276            "format": FORMAT,
277            "version": VERSION,
278            "subject": subject,
279            "pid": pid,
280            "incarnation": incarnation,
281            "definition": self.definition,
282            "def_hash": self.def_hash,
283            "exit": { "kind": self.exit_kind, "text": self.exit_text },
284            "frame": self.frame.block(self.host_io),
285            "provided": self.frame.provided_json(),
286        })
287    }
288
289    /// The bytes a receipt is: its JSON, canonical.
290    #[must_use]
291    pub fn bytes(&self) -> Vec<u8> {
292        canonical(&self.to_json()).into_bytes()
293    }
294}
295
296/// Where a node hands each actor's receipt.
297pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
298
299/// `j` with every object's keys sorted and no whitespace: one text per value.
300#[must_use]
301pub fn canonical(j: &Json) -> String {
302    let mut out = String::new();
303    write_canonical(j, &mut out);
304    out
305}
306
307fn write_canonical(j: &Json, out: &mut String) {
308    match j {
309        Json::Object(m) => {
310            let mut keys: Vec<&String> = m.keys().collect();
311            keys.sort();
312            out.push('{');
313            for (i, k) in keys.into_iter().enumerate() {
314                if i > 0 {
315                    out.push(',');
316                }
317                out.push_str(&Json::String(k.clone()).to_string());
318                out.push(':');
319                write_canonical(&m[k], out);
320            }
321            out.push('}');
322        }
323        Json::Array(items) => {
324            out.push('[');
325            for (i, v) in items.iter().enumerate() {
326                if i > 0 {
327                    out.push(',');
328                }
329                write_canonical(v, out);
330            }
331            out.push(']');
332        }
333        other => out.push_str(&other.to_string()),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    fn frame() -> Frame {
342        Frame {
343            granted: vec![Capability::FileSystem],
344            provided: vec![
345                blue_lang_waku::Provision::DeterministicStepped {
346                    at_ms: 5,
347                    step_ms: 1,
348                }
349                .into(),
350                Provided {
351                    provision: blue_lang_waku::Provision::NullSink,
352                    scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
353                },
354            ],
355            withheld: vec![
356                Capability::Process,
357                Capability::Environment,
358                Capability::Network,
359                Capability::Entropy,
360            ],
361        }
362    }
363
364    #[test]
365    fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
366        let block = frame().block(3);
367        let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
368        let mut sorted = keys.clone();
369        sorted.sort();
370        assert_eq!(sorted, ["granted", "host_io", "withheld"]);
371        assert_eq!(
372            canonical(&block),
373            r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
374        );
375    }
376
377    #[test]
378    fn a_hold_survives_the_trace_format() {
379        let f = frame();
380        assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
381        let mut bad = f.to_json();
382        bad["withheld"] = json!(["disk"]);
383        assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
384        assert!(Frame::from_json(&json!({})).is_err());
385    }
386
387    #[test]
388    fn withheld_names_are_every_name_of_every_withheld_capability() {
389        let names = frame().withheld_names();
390        assert!(names.contains(&("getenv", Capability::Environment)));
391        assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
392        assert_eq!(
393            names.len(),
394            frame()
395                .withheld
396                .iter()
397                .map(|c| c.names().len())
398                .sum::<usize>()
399        );
400        assert!(Frame::default().withheld_names().is_empty());
401    }
402
403    #[test]
404    fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
405        let io = HostIo::new();
406        io.tally();
407        io.enter(Pid(3));
408        io.tally();
409        io.tally();
410        io.enter(crate::ROOT);
411        io.tally();
412        assert_eq!(io.total(), 4);
413        assert_eq!(io.of(crate::ROOT), 2);
414        assert_eq!(io.of(Pid(3)), 2);
415        assert_eq!(io.take(Pid(3)), 2);
416        assert_eq!(io.of(Pid(3)), 0);
417        assert_eq!(io.total(), 4, "the run total keeps every call");
418        assert_eq!(HostIo::new().total(), 0);
419    }
420
421    #[test]
422    fn identities_are_hashed_once_and_only_when_asked() {
423        let ids = Identities::default();
424        assert_eq!(ids.of("%root/f"), None);
425        let calls = Arc::new(Mutex::new(0));
426        let c = Arc::clone(&calls);
427        ids.defer(move || {
428            *locked(&c) += 1;
429            BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
430        });
431        assert_eq!(*locked(&calls), 0);
432        assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
433        assert_eq!(ids.clone().of("%root/g"), None);
434        assert_eq!(*locked(&calls), 1);
435    }
436
437    #[test]
438    fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
439        let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
440        assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
441        let r = Receipt {
442            subject: Subject::Actor {
443                pid: Pid(2),
444                incarnation: 1,
445            },
446            definition: Some("%root/w".into()),
447            def_hash: Some("b3:ab".into()),
448            frame: frame(),
449            host_io: 7,
450            exit_kind: "normal".into(),
451            exit_text: "normal".into(),
452        };
453        let text = String::from_utf8(r.bytes()).expect("utf8");
454        assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
455        let back: Json = serde_json::from_str(&text).expect("json");
456        assert_eq!(canonical(&back), text);
457        assert_eq!(back["frame"]["host_io"], 7);
458        assert_eq!(back["subject"], "actor");
459        assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
460        assert!(
461            back["provided"][0].get("for").is_none(),
462            "a whole-program provider"
463        );
464        assert_eq!(
465            back["provided"][1],
466            json!({"provider": "null-sink", "for": ["kazu"]})
467        );
468    }
469}