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` version 1
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
35use std::collections::BTreeMap;
36use std::sync::{Arc, Mutex, MutexGuard};
37
38use blue_lang_waku::{Capability, Provided};
39use serde_json::{json, Value as Json};
40
41use crate::Pid;
42
43/// The format name every receipt carries.
44pub const FORMAT: &str = "blue-receipt";
45/// The version this crate writes.
46pub const VERSION: u64 = 1;
47
48fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
49    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
50}
51
52/// A hold as an artifact names it.
53#[derive(Clone, Debug, Default, PartialEq, Eq)]
54pub struct Frame {
55    /// Host capabilities granted from the host.
56    pub granted: Vec<Capability>,
57    /// The pure providers standing in for host capabilities, each for the
58    /// whole program or, under `for`, for the packages it names.
59    pub provided: Vec<Provided>,
60    /// Host capabilities neither granted nor provided: a call is a breach.
61    pub withheld: Vec<Capability>,
62}
63
64fn labels(caps: &[Capability]) -> Json {
65    Json::Array(caps.iter().map(|c| json!(c.label())).collect())
66}
67
68fn capability(label: &str) -> Option<Capability> {
69    Capability::host_effects()
70        .into_iter()
71        .find(|c| c.label() == label)
72}
73
74fn capabilities(j: Option<&Json>, what: &str) -> Result<Vec<Capability>, String> {
75    j.and_then(Json::as_array)
76        .ok_or_else(|| format!("a hold without `{what}`"))?
77        .iter()
78        .map(|l| {
79            l.as_str()
80                .and_then(capability)
81                .ok_or_else(|| format!("`{l}` in `{what}` is not a host capability"))
82        })
83        .collect()
84}
85
86impl Frame {
87    /// The receipt's frame block: `{"granted":[…],"withheld":[…],"host_io":n}`.
88    #[must_use]
89    pub fn block(&self, host_io: u64) -> Json {
90        json!({
91            "granted": labels(&self.granted),
92            "withheld": labels(&self.withheld),
93            "host_io": host_io,
94        })
95    }
96
97    /// The providers as the lock records them: the provider, its settings,
98    /// and `for` with the packages it serves when it is scoped.
99    #[must_use]
100    pub fn provided_json(&self) -> Json {
101        Json::Array(
102            self.provided
103                .iter()
104                .map(|p| serde_json::to_value(p).unwrap_or(Json::Null))
105                .collect(),
106        )
107    }
108
109    /// The hold as a crash trace writes it.
110    #[must_use]
111    pub fn to_json(&self) -> Json {
112        json!({
113            "granted": labels(&self.granted),
114            "provided": self.provided_json(),
115            "withheld": labels(&self.withheld),
116        })
117    }
118
119    /// Read a hold back.
120    ///
121    /// # Errors
122    ///
123    /// When a list is missing, a label names no host capability, or a
124    /// provider does not parse.
125    pub fn from_json(j: &Json) -> Result<Self, String> {
126        let provided = j
127            .get("provided")
128            .and_then(Json::as_array)
129            .ok_or("a hold without `provided`")?
130            .iter()
131            .map(|p| serde_json::from_value(p.clone()).map_err(|e| format!("a provider: {e}")))
132            .collect::<Result<_, _>>()?;
133        Ok(Self {
134            granted: capabilities(j.get("granted"), "granted")?,
135            provided,
136            withheld: capabilities(j.get("withheld"), "withheld")?,
137        })
138    }
139
140    /// Every host name the hold withholds, with its capability.
141    #[must_use]
142    pub fn withheld_names(&self) -> Vec<(&'static str, Capability)> {
143        self.withheld
144            .iter()
145            .flat_map(|c| c.names().into_iter().map(move |n| (n, *c)))
146            .collect()
147    }
148}
149
150#[derive(Debug, Default)]
151struct Ledger {
152    current: Pid,
153    total: u64,
154    by_pid: BTreeMap<Pid, u64>,
155}
156
157/// Host calls made through granted host names, per process of one node.
158#[derive(Clone, Debug, Default)]
159pub struct HostIo(Arc<Mutex<Ledger>>);
160
161impl HostIo {
162    #[must_use]
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// One host call, by the process running now.
168    pub fn tally(&self) {
169        let mut l = locked(&self.0);
170        l.total += 1;
171        let pid = l.current;
172        *l.by_pid.entry(pid).or_default() += 1;
173    }
174
175    /// Every call tallied, by every process.
176    #[must_use]
177    pub fn total(&self) -> u64 {
178        locked(&self.0).total
179    }
180
181    /// The calls `pid` made since its incarnation began.
182    #[must_use]
183    pub fn of(&self, pid: Pid) -> u64 {
184        locked(&self.0).by_pid.get(&pid).copied().unwrap_or(0)
185    }
186
187    /// `pid` runs now: what is tallied is its.
188    pub fn enter(&self, pid: Pid) {
189        locked(&self.0).current = pid;
190    }
191
192    /// `pid`'s count, which starts again at zero.
193    pub fn take(&self, pid: Pid) -> u64 {
194        locked(&self.0).by_pid.remove(&pid).unwrap_or(0)
195    }
196}
197
198type Deferred<T> = Box<dyn FnOnce() -> T + Send>;
199
200enum Table<T> {
201    Unknown,
202    Deferred(Deferred<T>),
203    Known(T),
204}
205
206/// A table the runtime hands a node unfinished: computed the first time an
207/// artifact asks, and never when none does.
208struct Lazy<T>(Arc<Mutex<Table<T>>>);
209
210impl<T> Clone for Lazy<T> {
211    fn clone(&self) -> Self {
212        Self(Arc::clone(&self.0))
213    }
214}
215
216impl<T> Default for Lazy<T> {
217    fn default() -> Self {
218        Self(Arc::new(Mutex::new(Table::Unknown)))
219    }
220}
221
222impl<T> Lazy<T> {
223    fn defer(&self, compute: impl FnOnce() -> T + Send + 'static) {
224        *locked(&self.0) = Table::Deferred(Box::new(compute));
225    }
226
227    fn read<R>(&self, f: impl FnOnce(&T) -> Option<R>) -> Option<R> {
228        let mut state = locked(&self.0);
229        if let Table::Deferred(_) = &*state {
230            if let Table::Deferred(compute) = std::mem::replace(&mut *state, Table::Unknown) {
231                *state = Table::Known(compute());
232            }
233        }
234        match &*state {
235            Table::Known(table) => f(table),
236            Table::Unknown | Table::Deferred(_) => None,
237        }
238    }
239
240    fn state(&self) -> &'static str {
241        match &*locked(&self.0) {
242            Table::Unknown => "unknown",
243            Table::Deferred(_) => "deferred",
244            Table::Known(_) => "known",
245        }
246    }
247}
248
249/// Each definition key's `def_hash` (`b3:<hex>`), computed on first ask.
250#[derive(Clone, Default)]
251pub struct Identities(Lazy<BTreeMap<String, String>>);
252
253impl std::fmt::Debug for Identities {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        write!(f, "Identities({})", self.0.state())
256    }
257}
258
259impl Identities {
260    /// Compute the table with `hash` when the first identity is asked for.
261    pub fn defer(&self, hash: impl FnOnce() -> BTreeMap<String, String> + Send + 'static) {
262        self.0.defer(hash);
263    }
264
265    /// The `def_hash` of the definition bound to `key`, if it has one.
266    #[must_use]
267    pub fn of(&self, key: &str) -> Option<String> {
268        self.0.read(|table| table.get(key).cloned())
269    }
270}
271
272/// The file a definition was read from: the name a reader opens it by, and
273/// the text its spans index.
274#[derive(Clone, Debug)]
275pub struct Source {
276    pub file: String,
277    pub text: Arc<str>,
278}
279
280/// A position a reader can open: a file, and a 1-based line and column.
281#[derive(Clone, Debug, PartialEq, Eq)]
282pub struct Place {
283    pub file: String,
284    pub line: usize,
285    pub column: usize,
286}
287
288impl std::fmt::Display for Place {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        write!(f, "{}:{}:{}", self.file, self.line, self.column)
291    }
292}
293
294/// Each definition key's [`Source`], computed on first ask: what turns a
295/// span in a crashed process's code into a [`Place`].
296#[derive(Clone, Default)]
297pub struct Places(Lazy<BTreeMap<String, Source>>);
298
299impl std::fmt::Debug for Places {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        write!(f, "Places({})", self.0.state())
302    }
303}
304
305impl Places {
306    /// Compute the table with `read` when the first place is asked for.
307    pub fn defer(&self, read: impl FnOnce() -> BTreeMap<String, Source> + Send + 'static) {
308        self.0.defer(read);
309    }
310
311    /// Where `span` is, inside the definition bound to `key`. `None` for a
312    /// synthetic span, a key with no source, or a span past the end of the
313    /// key's file: an offset outside the file is proof it indexes another
314    /// one, and answering it anyway would name a line that is not the place.
315    #[must_use]
316    pub fn locate(&self, key: &str, span: tatara_lisp::Span) -> Option<Place> {
317        if span.is_synthetic() {
318            return None;
319        }
320        self.0.read(|table| {
321            let source = table.get(key)?;
322            (span.end <= source.text.len()).then(|| {
323                let (line, column) = tatara_lisp::Span::line_col(&source.text, span.start);
324                Place {
325                    file: source.file.clone(),
326                    line,
327                    column,
328                }
329            })
330        })
331    }
332}
333
334/// What a receipt attests about.
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub enum Subject {
337    /// One incarnation of a process.
338    Actor { pid: Pid, incarnation: usize },
339    /// A whole run, its root and every process it started.
340    Run,
341}
342
343/// One actor's or one run's account of itself.
344#[derive(Clone, Debug, PartialEq, Eq)]
345pub struct Receipt {
346    pub subject: Subject,
347    /// The definition key it ran.
348    pub definition: Option<String>,
349    /// That definition's `def_hash`.
350    pub def_hash: Option<String>,
351    pub frame: Frame,
352    /// Calls through granted host names.
353    pub host_io: u64,
354    pub exit_kind: String,
355    pub exit_text: String,
356}
357
358impl Receipt {
359    /// This receipt as `blue-receipt` JSON.
360    #[must_use]
361    pub fn to_json(&self) -> Json {
362        let (subject, pid, incarnation) = match self.subject {
363            Subject::Actor { pid, incarnation } => ("actor", pid.0, incarnation),
364            Subject::Run => ("run", crate::ROOT.0, 0),
365        };
366        json!({
367            "format": FORMAT,
368            "version": VERSION,
369            "subject": subject,
370            "pid": pid,
371            "incarnation": incarnation,
372            "definition": self.definition,
373            "def_hash": self.def_hash,
374            "exit": { "kind": self.exit_kind, "text": self.exit_text },
375            "frame": self.frame.block(self.host_io),
376            "provided": self.frame.provided_json(),
377        })
378    }
379
380    /// The bytes a receipt is: its JSON, canonical.
381    #[must_use]
382    pub fn bytes(&self) -> Vec<u8> {
383        canonical(&self.to_json()).into_bytes()
384    }
385}
386
387/// Where a node hands each actor's receipt.
388pub type ReceiptSink = Arc<dyn Fn(&Receipt) + Send + Sync>;
389
390/// `j` with every object's keys sorted and no whitespace: one text per value.
391#[must_use]
392pub fn canonical(j: &Json) -> String {
393    let mut out = String::new();
394    write_canonical(j, &mut out);
395    out
396}
397
398fn write_canonical(j: &Json, out: &mut String) {
399    match j {
400        Json::Object(m) => {
401            let mut keys: Vec<&String> = m.keys().collect();
402            keys.sort();
403            out.push('{');
404            for (i, k) in keys.into_iter().enumerate() {
405                if i > 0 {
406                    out.push(',');
407                }
408                out.push_str(&Json::String(k.clone()).to_string());
409                out.push(':');
410                write_canonical(&m[k], out);
411            }
412            out.push('}');
413        }
414        Json::Array(items) => {
415            out.push('[');
416            for (i, v) in items.iter().enumerate() {
417                if i > 0 {
418                    out.push(',');
419                }
420                write_canonical(v, out);
421            }
422            out.push(']');
423        }
424        other => out.push_str(&other.to_string()),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn frame() -> Frame {
433        Frame {
434            granted: vec![Capability::FileSystem],
435            provided: vec![
436                blue_lang_waku::Provision::DeterministicStepped {
437                    at_ms: 5,
438                    step_ms: 1,
439                }
440                .into(),
441                Provided {
442                    provision: blue_lang_waku::Provision::NullSink,
443                    scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
444                },
445            ],
446            withheld: vec![
447                Capability::Process,
448                Capability::Environment,
449                Capability::Network,
450                Capability::Entropy,
451            ],
452        }
453    }
454
455    #[test]
456    fn the_frame_block_is_granted_withheld_and_host_io_and_nothing_else() {
457        let block = frame().block(3);
458        let keys: Vec<&String> = block.as_object().expect("object").keys().collect();
459        let mut sorted = keys.clone();
460        sorted.sort();
461        assert_eq!(sorted, ["granted", "host_io", "withheld"]);
462        assert_eq!(
463            canonical(&block),
464            r#"{"granted":["filesystem"],"host_io":3,"withheld":["process","environment","network","entropy"]}"#
465        );
466    }
467
468    #[test]
469    fn a_hold_survives_the_trace_format() {
470        let f = frame();
471        assert_eq!(Frame::from_json(&f.to_json()).expect("parse"), f);
472        let mut bad = f.to_json();
473        bad["withheld"] = json!(["disk"]);
474        assert!(Frame::from_json(&bad).unwrap_err().contains("disk"));
475        assert!(Frame::from_json(&json!({})).is_err());
476    }
477
478    #[test]
479    fn withheld_names_are_every_name_of_every_withheld_capability() {
480        let names = frame().withheld_names();
481        assert!(names.contains(&("getenv", Capability::Environment)));
482        assert!(names.iter().all(|(_, c)| *c != Capability::FileSystem));
483        assert_eq!(
484            names.len(),
485            frame()
486                .withheld
487                .iter()
488                .map(|c| c.names().len())
489                .sum::<usize>()
490        );
491        assert!(Frame::default().withheld_names().is_empty());
492    }
493
494    #[test]
495    fn the_ledger_tallies_to_the_process_running_and_takes_per_incarnation() {
496        let io = HostIo::new();
497        io.tally();
498        io.enter(Pid(3));
499        io.tally();
500        io.tally();
501        io.enter(crate::ROOT);
502        io.tally();
503        assert_eq!(io.total(), 4);
504        assert_eq!(io.of(crate::ROOT), 2);
505        assert_eq!(io.of(Pid(3)), 2);
506        assert_eq!(io.take(Pid(3)), 2);
507        assert_eq!(io.of(Pid(3)), 0);
508        assert_eq!(io.total(), 4, "the run total keeps every call");
509        assert_eq!(HostIo::new().total(), 0);
510    }
511
512    #[test]
513    fn identities_are_hashed_once_and_only_when_asked() {
514        let ids = Identities::default();
515        assert_eq!(ids.of("%root/f"), None);
516        let calls = Arc::new(Mutex::new(0));
517        let c = Arc::clone(&calls);
518        ids.defer(move || {
519            *locked(&c) += 1;
520            BTreeMap::from([("%root/f".to_string(), "b3:00".to_string())])
521        });
522        assert_eq!(*locked(&calls), 0);
523        assert_eq!(ids.of("%root/f").as_deref(), Some("b3:00"));
524        assert_eq!(ids.clone().of("%root/g"), None);
525        assert_eq!(*locked(&calls), 1);
526    }
527
528    #[test]
529    fn a_place_is_read_once_and_only_inside_its_own_file() {
530        let places = Places::default();
531        let at = tatara_lisp::Span::new(10, 14);
532        assert_eq!(places.locate("%root/f", at), None);
533        let calls = Arc::new(Mutex::new(0));
534        let c = Arc::clone(&calls);
535        places.defer(move || {
536            *locked(&c) += 1;
537            BTreeMap::from([(
538                "%root/f".to_string(),
539                Source {
540                    file: "w.b".into(),
541                    text: Arc::from("def f()\n  g(1)\nend\n"),
542                },
543            )])
544        });
545        assert_eq!(*locked(&calls), 0);
546        let place = places.locate("%root/f", at).expect("in its file");
547        assert_eq!(place.to_string(), "w.b:2:3");
548        assert_eq!(
549            places.locate("%root/f", tatara_lisp::Span::new(10, 99)),
550            None
551        );
552        assert_eq!(
553            places.locate("%root/f", tatara_lisp::Span::synthetic()),
554            None
555        );
556        assert_eq!(places.clone().locate("%root/g", at), None);
557        assert_eq!(*locked(&calls), 1);
558    }
559
560    #[test]
561    fn canonical_text_sorts_keys_at_every_depth_and_has_no_whitespace() {
562        let j = json!({"b": [ {"z": 1, "a": "x y"} ], "a": null});
563        assert_eq!(canonical(&j), r#"{"a":null,"b":[{"a":"x y","z":1}]}"#);
564        let r = Receipt {
565            subject: Subject::Actor {
566                pid: Pid(2),
567                incarnation: 1,
568            },
569            definition: Some("%root/w".into()),
570            def_hash: Some("b3:ab".into()),
571            frame: frame(),
572            host_io: 7,
573            exit_kind: "normal".into(),
574            exit_text: "normal".into(),
575        };
576        let text = String::from_utf8(r.bytes()).expect("utf8");
577        assert!(!text.contains(' ') && !text.contains('\n'), "{text}");
578        let back: Json = serde_json::from_str(&text).expect("json");
579        assert_eq!(canonical(&back), text);
580        assert_eq!(back["frame"]["host_io"], 7);
581        assert_eq!(back["subject"], "actor");
582        assert_eq!(back["provided"][0]["provider"], "deterministic-stepped");
583        assert!(
584            back["provided"][0].get("for").is_none(),
585            "a whole-program provider"
586        );
587        assert_eq!(
588            back["provided"][1],
589            json!({"provider": "null-sink", "for": ["kazu"]})
590        );
591    }
592}