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///
29/// The members are private and read through getters, so the cached [`payload`](Self::payload)
30/// and [`hash`](Self::hash) always describe them. A `Receipt` comes only from
31/// [`from_json`](Self::from_json) (or [`from_value`](Self::from_value) for JSON already
32/// parsed) and, with feature `sign`, from `ReceiptBuilder::sign`.
33#[derive(Clone, Debug, PartialEq, Eq, Hash)]
34pub struct Receipt {
35    asof: String,
36    cell: Cell,
37    computed_by: String,
38    inputs: Vec<String>,
39    issued: String,
40    licence: String,
41    publisher: String,
42    sig: String,
43    summary: String,
44    tx: String,
45    value: Scalar,
46    valid: Args,
47    payload: Vec<u8>,
48    hash: String,
49}
50
51impl Receipt {
52    /// Parses a receipt and runs verification steps 1-4 (10.1).
53    pub fn from_json(bytes: &[u8]) -> Result<Receipt> {
54        Receipt::from_value(&json::parse(bytes)?)
55    }
56
57    /// Runs verification steps 2-4 on a parsed JSON value.
58    pub fn from_value(v: &Value) -> Result<Receipt> {
59        json::check_value_depth(v)?;
60        structure(v)?;
61        let r = canonical(v)?;
62        r.consistency()?;
63        Ok(r)
64    }
65
66    /// The signed payload: canonical bytes of the receipt without `sig` (8.3).
67    pub fn payload(&self) -> &[u8] {
68        &self.payload
69    }
70
71    /// The receipt hash: SHA-256 of the payload, lowercase hex (8.3).
72    pub fn hash(&self) -> &str {
73        &self.hash
74    }
75
76    /// The transaction time answered as of.
77    pub fn asof(&self) -> &str {
78        &self.asof
79    }
80
81    /// The cell answered (no slices).
82    pub fn cell(&self) -> &Cell {
83        &self.cell
84    }
85
86    /// `<host>#<key id>` of the signing key.
87    pub fn computed_by(&self) -> &str {
88        &self.computed_by
89    }
90
91    /// The inputs entries, ascending: receipt hashes and, since `spec-0.4.0`, slice entries
92    /// `slice:<slice id>:<members hash>` (which sort after every receipt hash).
93    pub fn inputs(&self) -> &[String] {
94        &self.inputs
95    }
96
97    /// When the receipt was signed.
98    pub fn issued(&self) -> &str {
99        &self.issued
100    }
101
102    /// An SPDX identifier or an https URL.
103    pub fn licence(&self) -> &str {
104        &self.licence
105    }
106
107    /// The namespace that owns the function; equals `cell.ns`.
108    pub fn publisher(&self) -> &str {
109        &self.publisher
110    }
111
112    /// The Ed25519 signature, 128 lowercase hex digits.
113    pub fn sig(&self) -> &str {
114        &self.sig
115    }
116
117    /// One human sentence.
118    pub fn summary(&self) -> &str {
119        &self.summary
120    }
121
122    /// When the value was recorded.
123    pub fn tx(&self) -> &str {
124        &self.tx
125    }
126
127    /// The value, typed.
128    pub fn value(&self) -> &Scalar {
129        &self.value
130    }
131
132    /// The valid-time arguments, echoed from `cell.args`.
133    pub fn valid(&self) -> &Args {
134        &self.valid
135    }
136
137    /// The value's type (the `type` member).
138    pub fn ty(&self) -> Type {
139        self.value.ty()
140    }
141
142    /// The host of `computed_by`.
143    pub fn signer_host(&self) -> &str {
144        self.computed_by.split_once('#').map_or("", |(h, _)| h)
145    }
146
147    /// The key id of `computed_by`.
148    pub fn key_id(&self) -> &str {
149        self.computed_by.split_once('#').map_or("", |(_, k)| k)
150    }
151
152    /// The same receipt with `sig` replaced; payload and hash do not cover `sig`.
153    #[cfg(feature = "sign")]
154    pub(crate) fn with_sig(mut self, sig: String) -> Receipt {
155        self.sig = sig;
156        self
157    }
158
159    fn consistency(&self) -> Result<()> {
160        for (k, a) in &self.cell.args {
161            if *a == Arg::Slice {
162                return err(ErrorCode::Slice, &member("$.cell.args", k), "a receipt is for one cell, not a slice");
163            }
164        }
165        for (k, a) in &self.valid {
166            if *a == Arg::Slice {
167                return err(ErrorCode::Slice, &member("$.valid", k), "a receipt is for one cell, not a slice");
168            }
169        }
170        if self.publisher != self.cell.ns {
171            return err(ErrorCode::PublisherMismatch, "$.publisher", "publisher must equal cell.ns");
172        }
173        for (k, a) in &self.valid {
174            if self.cell.args.get(k) != Some(a) {
175                return err(ErrorCode::ValidMismatch, "$.valid", format!("valid.{k} does not echo cell.args.{k}"));
176            }
177            if !a.scalar().is_some_and(|s| s.ty().may_be_valid_time()) {
178                return err(ErrorCode::ValidMismatch, "$.valid", "valid-time arguments are dates, instants or strings");
179            }
180        }
181        if !(self.tx <= self.asof && self.asof <= self.issued) {
182            return err(ErrorCode::TimeOrder, "$", "requires tx <= asof <= issued");
183        }
184        Ok(())
185    }
186}
187
188fn structure(v: &Value) -> Result<()> {
189    let m = schema::object(v, "$")?;
190    schema::exact(m, &MEMBERS, &[], "$", ErrorCode::Schema)?;
191    if schema::get(m, "proto").as_str() != Some("datumesh/1") {
192        return err(ErrorCode::Schema, "$.proto", "proto must be 'datumesh/1'");
193    }
194    for (k, val) in jcs_sorted(m) {
195        let p = member("$", k);
196        match k.as_str() {
197            "cell" => model::cell_structure(val, &p)?,
198            "valid" => model::args_structure(val, &p)?,
199            "inputs" => {
200                if !matches!(val, Value::Array(a) if a.iter().all(|x| matches!(x, Value::String(_)))) {
201                    return err(ErrorCode::Schema, &p, "inputs must be an array of strings");
202                }
203            }
204            "value" => {
205                if !matches!(val, Value::String(_) | Value::Bool(_)) {
206                    return err(ErrorCode::Schema, &p, "value must be a string or a boolean");
207                }
208            }
209            _ => {
210                if !matches!(val, Value::String(_)) {
211                    return err(ErrorCode::Schema, &p, format!("{k} must be a string"));
212                }
213            }
214        }
215    }
216    Ok(())
217}
218
219fn canonical(v: &Value) -> Result<Receipt> {
220    let m = schema::object(v, "$")?;
221    let s = |name: &str| schema::string(m, name, "$");
222    let asof = s("asof")?;
223    lexical::check_instant(asof, "$.asof")?;
224    let cell = model::cell_canonical(schema::get(m, "cell"), "$.cell")?;
225    let computed_by = s("computed_by")?;
226    lexical::check_key_ref(computed_by, "$.computed_by")?;
227    let inputs: Vec<&str> = match schema::get(m, "inputs") {
228        Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
229        _ => Vec::new(),
230    };
231    for (i, h) in inputs.iter().enumerate() {
232        lexical::check_input_entry(h, &index("$.inputs", i))?;
233    }
234    if inputs.windows(2).any(|w| w[0] >= w[1]) {
235        return err(ErrorCode::CanonSet, "$.inputs", "entries must be strictly ascending");
236    }
237    if inputs.len() > limits::INPUTS {
238        return err(ErrorCode::CanonLimit, "$.inputs", "more than 4096 inputs");
239    }
240    let issued = s("issued")?;
241    lexical::check_instant(issued, "$.issued")?;
242    let licence = s("licence")?;
243    lexical::check_licence(licence, "$.licence")?;
244    let publisher = s("publisher")?;
245    lexical::check_namespace(publisher, "$.publisher")?;
246    let sig = s("sig")?;
247    lexical::check_hex(sig, 128, "$.sig")?;
248    let summary = s("summary")?;
249    lexical::check_text(summary, limits::SUMMARY, false, "$.summary")?;
250    if summary.is_empty() {
251        return err(ErrorCode::CanonString, "$.summary", "summary must not be empty");
252    }
253    let tx = s("tx")?;
254    lexical::check_instant(tx, "$.tx")?;
255    let ty = Type::parse(s("type")?, "$.type")?;
256    let valid = model::args_canonical(schema::get(m, "valid"), "$.valid")?;
257    let value = match (ty, schema::get(m, "value")) {
258        (Type::Boolean, Value::Bool(b)) => Scalar::Boolean(*b),
259        (Type::Boolean, _) => return err(ErrorCode::CanonType, "$.value", "boolean value must be JSON true or false"),
260        (t, Value::String(text)) => {
261            model::check_lexical(t, text, "$.value")?;
262            model::unchecked(t, text)
263        }
264        (t, _) => return err(ErrorCode::CanonType, "$.value", format!("{} value must be a JSON string", t.as_str())),
265    };
266    let without_sig: Vec<(String, Value)> = m.iter().filter(|(k, _)| k != "sig").cloned().collect();
267    let payload = Value::Object(without_sig).to_jcs();
268    let hash = crate::crypto::sha256_hex(&payload);
269    Ok(Receipt {
270        asof: asof.into(),
271        cell,
272        computed_by: computed_by.into(),
273        inputs: inputs.iter().map(|x| x.to_string()).collect(),
274        issued: issued.into(),
275        licence: licence.into(),
276        publisher: publisher.into(),
277        sig: sig.into(),
278        summary: summary.into(),
279        tx: tx.into(),
280        value,
281        valid,
282        payload,
283        hash,
284    })
285}
286
287/// The canonical bytes of a receipt without `sig` (8.3), after steps 1-4.
288pub fn receipt_payload(bytes: &[u8]) -> Result<Vec<u8>> {
289    Ok(Receipt::from_json(bytes)?.payload)
290}
291
292/// The receipt hash (8.3), after steps 1-4.
293pub fn receipt_hash(bytes: &[u8]) -> Result<String> {
294    Ok(Receipt::from_json(bytes)?.hash)
295}