Skip to main content

datumesh_core/
receipt.rs

1//! Receipts (8): members, the signed payload and the receipt hash.
2
3use crate::error::{err, index, member, ErrorCode, Result};
4use crate::json::{self, jcs_sorted, Value};
5use crate::lexical::{self, limits};
6use crate::model::{self, Arg, Args, Cell, Scalar, Type};
7use crate::schema;
8
9const MEMBERS: [&str; 14] = [
10    "asof",
11    "cell",
12    "computed_by",
13    "inputs",
14    "issued",
15    "licence",
16    "proto",
17    "publisher",
18    "sig",
19    "summary",
20    "tx",
21    "type",
22    "valid",
23    "value",
24];
25
26/// A receipt (8.1) that has passed verification steps 1-4: strict JSON, structure, canonical
27/// form and consistency. Its signature has not been checked; use [`crate::verify`].
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub struct Receipt {
30    /// The transaction time answered as of.
31    pub asof: String,
32    /// The cell answered (no slices).
33    pub cell: Cell,
34    /// `<host>#<key id>` of the signing key.
35    pub computed_by: String,
36    /// Receipt hashes of the inputs, ascending.
37    pub inputs: Vec<String>,
38    /// When the receipt was signed.
39    pub issued: String,
40    /// An SPDX identifier or an https URL.
41    pub licence: String,
42    /// The namespace that owns the function; equals `cell.ns`.
43    pub publisher: String,
44    /// The Ed25519 signature, 128 lowercase hex digits.
45    pub sig: String,
46    /// One human sentence.
47    pub summary: String,
48    /// When the value was recorded.
49    pub tx: String,
50    /// The value, typed.
51    pub value: Scalar,
52    /// The valid-time arguments, echoed from `cell.args`.
53    pub valid: Args,
54    payload: Vec<u8>,
55}
56
57impl Receipt {
58    /// Parses a receipt and runs verification steps 1-4 (10.1).
59    pub fn from_json(bytes: &[u8]) -> Result<Receipt> {
60        Receipt::from_value(&json::parse(bytes)?)
61    }
62
63    /// Runs verification steps 2-4 on a parsed JSON value.
64    pub fn from_value(v: &Value) -> Result<Receipt> {
65        json::check_value_depth(v)?;
66        structure(v)?;
67        let r = canonical(v)?;
68        r.consistency()?;
69        Ok(r)
70    }
71
72    /// The signed payload: canonical bytes of the receipt without `sig` (8.3).
73    pub fn payload(&self) -> &[u8] {
74        &self.payload
75    }
76
77    /// The receipt hash: SHA-256 of the payload, lowercase hex (8.3).
78    pub fn hash(&self) -> String {
79        crate::crypto::sha256_hex(&self.payload)
80    }
81
82    /// The value's type.
83    pub fn ty(&self) -> Type {
84        self.value.ty()
85    }
86
87    /// The host of `computed_by`.
88    pub fn signer_host(&self) -> &str {
89        self.computed_by.split_once('#').map_or("", |(h, _)| h)
90    }
91
92    /// The key id of `computed_by`.
93    pub fn key_id(&self) -> &str {
94        self.computed_by.split_once('#').map_or("", |(_, k)| k)
95    }
96
97    fn consistency(&self) -> Result<()> {
98        for (k, a) in &self.cell.args {
99            if *a == Arg::Slice {
100                return err(ErrorCode::Slice, &member("$.cell.args", k), "a receipt is for one cell, not a slice");
101            }
102        }
103        for (k, a) in &self.valid {
104            if *a == Arg::Slice {
105                return err(ErrorCode::Slice, &member("$.valid", k), "a receipt is for one cell, not a slice");
106            }
107        }
108        if self.publisher != self.cell.ns {
109            return err(ErrorCode::PublisherMismatch, "$.publisher", "publisher must equal cell.ns");
110        }
111        for (k, a) in &self.valid {
112            if self.cell.args.get(k) != Some(a) {
113                return err(ErrorCode::ValidMismatch, "$.valid", format!("valid.{k} does not echo cell.args.{k}"));
114            }
115            if !a.scalar().is_some_and(|s| s.ty().may_be_valid_time()) {
116                return err(ErrorCode::ValidMismatch, "$.valid", "valid-time arguments are dates, instants or strings");
117            }
118        }
119        if !(self.tx <= self.asof && self.asof <= self.issued) {
120            return err(ErrorCode::TimeOrder, "$", "requires tx <= asof <= issued");
121        }
122        Ok(())
123    }
124}
125
126fn structure(v: &Value) -> Result<()> {
127    let m = schema::object(v, "$")?;
128    schema::exact(m, &MEMBERS, &[], "$", ErrorCode::Schema)?;
129    if schema::get(m, "proto").as_str() != Some("datumesh/1") {
130        return err(ErrorCode::Schema, "$.proto", "proto must be 'datumesh/1'");
131    }
132    for (k, val) in jcs_sorted(m) {
133        let p = member("$", k);
134        match k.as_str() {
135            "cell" => model::cell_structure(val, &p)?,
136            "valid" => model::args_structure(val, &p)?,
137            "inputs" => {
138                if !matches!(val, Value::Array(a) if a.iter().all(|x| matches!(x, Value::String(_)))) {
139                    return err(ErrorCode::Schema, &p, "inputs must be an array of strings");
140                }
141            }
142            "value" => {
143                if !matches!(val, Value::String(_) | Value::Bool(_)) {
144                    return err(ErrorCode::Schema, &p, "value must be a string or a boolean");
145                }
146            }
147            _ => {
148                if !matches!(val, Value::String(_)) {
149                    return err(ErrorCode::Schema, &p, format!("{k} must be a string"));
150                }
151            }
152        }
153    }
154    Ok(())
155}
156
157fn canonical(v: &Value) -> Result<Receipt> {
158    let m = schema::object(v, "$")?;
159    let s = |name: &str| schema::string(m, name, "$");
160    let asof = s("asof")?;
161    lexical::check_instant(asof, "$.asof")?;
162    let cell = model::cell_canonical(schema::get(m, "cell"), "$.cell")?;
163    let computed_by = s("computed_by")?;
164    lexical::check_key_ref(computed_by, "$.computed_by")?;
165    let inputs: Vec<&str> = match schema::get(m, "inputs") {
166        Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
167        _ => Vec::new(),
168    };
169    for (i, h) in inputs.iter().enumerate() {
170        lexical::check_hex(h, 64, &index("$.inputs", i))?;
171    }
172    if inputs.windows(2).any(|w| w[0] >= w[1]) {
173        return err(ErrorCode::CanonSet, "$.inputs", "entries must be strictly ascending");
174    }
175    if inputs.len() > limits::INPUTS {
176        return err(ErrorCode::CanonLimit, "$.inputs", "more than 4096 inputs");
177    }
178    let issued = s("issued")?;
179    lexical::check_instant(issued, "$.issued")?;
180    let licence = s("licence")?;
181    lexical::check_licence(licence, "$.licence")?;
182    let publisher = s("publisher")?;
183    lexical::check_namespace(publisher, "$.publisher")?;
184    let sig = s("sig")?;
185    lexical::check_hex(sig, 128, "$.sig")?;
186    let summary = s("summary")?;
187    lexical::check_text(summary, limits::SUMMARY, false, "$.summary")?;
188    if summary.is_empty() {
189        return err(ErrorCode::CanonString, "$.summary", "summary must not be empty");
190    }
191    let tx = s("tx")?;
192    lexical::check_instant(tx, "$.tx")?;
193    let ty = Type::parse(s("type")?, "$.type")?;
194    let valid = model::args_canonical(schema::get(m, "valid"), "$.valid")?;
195    let value = match (ty, schema::get(m, "value")) {
196        (Type::Boolean, Value::Bool(b)) => Scalar::Boolean(*b),
197        (Type::Boolean, _) => return err(ErrorCode::CanonType, "$.value", "boolean value must be JSON true or false"),
198        (t, Value::String(text)) => {
199            model::check_lexical(t, text, "$.value")?;
200            model::unchecked(t, text)
201        }
202        (t, _) => return err(ErrorCode::CanonType, "$.value", format!("{} value must be a JSON string", t.as_str())),
203    };
204    let without_sig: Vec<(String, Value)> = m.iter().filter(|(k, _)| k != "sig").cloned().collect();
205    let payload = Value::Object(without_sig).to_jcs();
206    Ok(Receipt {
207        asof: asof.into(),
208        cell,
209        computed_by: computed_by.into(),
210        inputs: inputs.iter().map(|x| x.to_string()).collect(),
211        issued: issued.into(),
212        licence: licence.into(),
213        publisher: publisher.into(),
214        sig: sig.into(),
215        summary: summary.into(),
216        tx: tx.into(),
217        value,
218        valid,
219        payload,
220    })
221}
222
223/// The canonical bytes of a receipt without `sig` (8.3), after steps 1-4.
224pub fn receipt_payload(bytes: &[u8]) -> Result<Vec<u8>> {
225    Ok(Receipt::from_json(bytes)?.payload)
226}
227
228/// The receipt hash (8.3), after steps 1-4.
229pub fn receipt_hash(bytes: &[u8]) -> Result<String> {
230    Ok(Receipt::from_json(bytes)?.hash())
231}