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