Skip to main content

datumesh_core/
envelope.rs

1//! Response envelopes (5.5): the mesh's unsigned statement around a receipt.
2
3use crate::error::{err, Error, ErrorCode, Result};
4use crate::json::{self, Value};
5use crate::keys::Keys;
6use crate::lexical::{self, limits};
7use crate::receipt::Receipt;
8use crate::schema;
9
10/// The eight outcomes of 5.5.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Outcome {
13    /// 200: the receipt answers the cell as of the requested `asof`: its `asof` is at or
14    /// before the requested one, and the mesh asserts it is the cell's latest version known
15    /// as of then.
16    Ok,
17    /// 200: as `Ok`, except that the mesh cannot assert the receipt is current; it serves the
18    /// last receipt it knows.
19    Stale,
20    /// 404: no receipt for this cell.
21    NotCovered,
22    /// 403: the requester may not receive this cell.
23    Unlicensed,
24    /// 402: available for the quoted price.
25    PaymentRequired,
26    /// 429: too many requests.
27    RateLimited,
28    /// 400: the cell URI does not parse.
29    BadRequest,
30    /// 5xx: the mesh failed.
31    Error,
32}
33
34impl Outcome {
35    /// The outcome as written in JSON.
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Outcome::Ok => "ok",
39            Outcome::Stale => "stale",
40            Outcome::NotCovered => "not_covered",
41            Outcome::Unlicensed => "unlicensed",
42            Outcome::PaymentRequired => "payment_required",
43            Outcome::RateLimited => "rate_limited",
44            Outcome::BadRequest => "bad_request",
45            Outcome::Error => "error",
46        }
47    }
48
49    fn from_name(s: &str) -> Option<Outcome> {
50        Some(match s {
51            "ok" => Outcome::Ok,
52            "stale" => Outcome::Stale,
53            "not_covered" => Outcome::NotCovered,
54            "unlicensed" => Outcome::Unlicensed,
55            "payment_required" => Outcome::PaymentRequired,
56            "rate_limited" => Outcome::RateLimited,
57            "bad_request" => Outcome::BadRequest,
58            "error" => Outcome::Error,
59            _ => return None,
60        })
61    }
62
63    /// Required and optional members besides `outcome`.
64    fn members(self) -> (&'static [&'static str], &'static [&'static str]) {
65        match self {
66            Outcome::Ok => (&["receipt", "trust"], &["superseded_by", "reason"]),
67            Outcome::Stale => (&["receipt", "trust", "age_seconds"], &["superseded_by", "reason"]),
68            Outcome::NotCovered | Outcome::Unlicensed => (&[], &[]),
69            Outcome::PaymentRequired => (&["quote"], &[]),
70            Outcome::RateLimited => (&["retry_after_seconds"], &[]),
71            Outcome::BadRequest | Outcome::Error => (&["error"], &[]),
72        }
73    }
74
75    fn status_ok(self, status: u16) -> bool {
76        match self {
77            Outcome::Ok | Outcome::Stale => status == 200,
78            Outcome::NotCovered => status == 404,
79            Outcome::Unlicensed => status == 403,
80            Outcome::PaymentRequired => status == 402,
81            Outcome::RateLimited => status == 429,
82            Outcome::BadRequest => status == 400,
83            Outcome::Error => (500..=599).contains(&status),
84        }
85    }
86}
87
88/// The mesh's trust label (5.5).
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub enum Trust {
91    /// Signed by the namespace's host, with no inputs.
92    Primary,
93    /// The mesh says it recomputed the receipt.
94    Verified,
95    /// Neither.
96    Unverified,
97}
98
99impl Trust {
100    /// The label as written in JSON.
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Trust::Primary => "primary",
104            Trust::Verified => "verified",
105            Trust::Unverified => "unverified",
106        }
107    }
108}
109
110/// A price quote (`payment_required`).
111#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112pub struct Quote {
113    /// A canonical decimal greater than zero.
114    pub amount: String,
115    /// A currency code from `units.md`.
116    pub currency: String,
117    /// Where to pay: an https URL.
118    pub authorise: String,
119}
120
121/// What the client asked for, for the binding of 5.5 step 5.
122#[derive(Clone, Debug, PartialEq, Eq, Hash)]
123pub struct Request {
124    cell_id: String,
125    asof: Option<String>,
126}
127
128/// A malformed request description (5.5 step 5): the caller's mistake, not an `E_*` code.
129#[derive(Clone, Debug, PartialEq, Eq, Hash)]
130pub struct InvalidRequest(String);
131
132impl core::fmt::Display for InvalidRequest {
133    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134        write!(f, "invalid request: {}", self.0)
135    }
136}
137
138impl std::error::Error for InvalidRequest {}
139
140impl Request {
141    /// A request for the cell with id `cell_id` (64 lowercase hex digits) as of `asof` (an
142    /// instant) or now. Anything else is [`InvalidRequest`]: SPEC 5.5 makes a malformed
143    /// request the caller's error, reported outside the `E_*` codes.
144    pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
145        if lexical::check_hex(cell_id, 64, "").is_err() {
146            return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
147        }
148        if let Some(a) = asof {
149            if !lexical::is_instant(a) {
150                return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
151            }
152        }
153        Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from) })
154    }
155
156    /// The requested cell id.
157    pub fn cell_id(&self) -> &str {
158        &self.cell_id
159    }
160
161    /// The requested `asof`, if any.
162    pub fn asof(&self) -> Option<&str> {
163        self.asof.as_deref()
164    }
165}
166
167/// A checked envelope.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct Envelope {
170    /// The outcome.
171    pub outcome: Outcome,
172    /// The receipt (`ok`, `stale`), signature-valid and bound to the request.
173    pub receipt: Option<Receipt>,
174    /// The trust label (`ok`, `stale`).
175    pub trust: Option<Trust>,
176    /// Seconds since the mesh last confirmed the receipt (`stale`).
177    pub age_seconds: Option<u64>,
178    /// Hash of a newer receipt for the same cell.
179    pub superseded_by: Option<String>,
180    /// Why the receipt was superseded.
181    pub reason: Option<String>,
182    /// The quote (`payment_required`).
183    pub quote: Option<Quote>,
184    /// Seconds to wait (`rate_limited`).
185    pub retry_after_seconds: Option<u64>,
186    /// The failure code (`bad_request`, `error`).
187    pub error: Option<ErrorCode>,
188}
189
190const BAD_REQUEST_CODES: [ErrorCode; 15] = [
191    ErrorCode::ParseUri,
192    ErrorCode::CanonName,
193    ErrorCode::CanonArgName,
194    ErrorCode::CanonArgDuplicate,
195    ErrorCode::CanonArgOrder,
196    ErrorCode::CanonPercent,
197    ErrorCode::CanonType,
198    ErrorCode::CanonNumber,
199    ErrorCode::CanonDate,
200    ErrorCode::CanonInstant,
201    ErrorCode::CanonString,
202    ErrorCode::CanonBlob,
203    ErrorCode::CanonLimit,
204    ErrorCode::ArgUnknown,
205    ErrorCode::ArgMissing,
206];
207
208fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
209    err(ErrorCode::Envelope, path, detail)
210}
211
212/// `0` or `[1-9][0-9]*`, at most 2^53 - 1, judged on the number's text.
213fn integer_member(v: &Value, path: &str) -> Result<u64> {
214    let Some(t) = v.as_number_text() else {
215        return env_err(path, "must be a non-negative integer");
216    };
217    let ok =
218        !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
219    match (ok, t.parse::<u64>()) {
220        (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
221        _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
222    }
223}
224
225/// Checks a response envelope (5.5) in the order of its procedure: strict JSON, members,
226/// HTTP status, the receipt through step 6 of 10.1, the binding to `request`, then `trust`
227/// and `superseded_by`. Never touches the network.
228pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
229    // 1.
230    let v = json::parse(body)?;
231    // 2.
232    let m = match &v {
233        Value::Object(m) if matches!(schema::opt(m, "outcome"), Some(Value::String(_))) => m,
234        _ => return env_err("$", "an envelope is an object with a string outcome"),
235    };
236    let oname = schema::get(m, "outcome").as_str().unwrap_or("");
237    let Some(outcome) = Outcome::from_name(oname) else {
238        return env_err("$.outcome", format!("unknown outcome {oname:?}"));
239    };
240    let (req, optional) = outcome.members();
241    let mut allowed: Vec<&str> = vec!["outcome"];
242    allowed.extend_from_slice(req);
243    schema::exact(m, &allowed, optional, "$", ErrorCode::Envelope)?;
244    let mut env = Envelope {
245        outcome,
246        receipt: None,
247        trust: None,
248        age_seconds: None,
249        superseded_by: None,
250        reason: None,
251        quote: None,
252        retry_after_seconds: None,
253        error: None,
254    };
255    let receipt_v = schema::opt(m, "receipt");
256    if let Some(r) = receipt_v {
257        if !matches!(r, Value::Object(_)) {
258            return env_err("$.receipt", "receipt is a receipt object");
259        }
260    }
261    if let Some(t) = schema::opt(m, "trust") {
262        env.trust = Some(match t.as_str() {
263            Some("primary") => Trust::Primary,
264            Some("verified") => Trust::Verified,
265            Some("unverified") => Trust::Unverified,
266            _ => return env_err("$.trust", "trust is primary, verified or unverified"),
267        });
268    }
269    if let Some(a) = schema::opt(m, "age_seconds") {
270        env.age_seconds = Some(integer_member(a, "$.age_seconds")?);
271    }
272    if let Some(a) = schema::opt(m, "retry_after_seconds") {
273        env.retry_after_seconds = Some(integer_member(a, "$.retry_after_seconds")?);
274    }
275    if let Some(s) = schema::opt(m, "superseded_by") {
276        let s = s.as_str().unwrap_or("");
277        lexical::check_hex(s, 64, "$.superseded_by").map_err(|e| e.recode(ErrorCode::Envelope))?;
278        env.superseded_by = Some(s.into());
279    }
280    if let Some(r) = schema::opt(m, "reason") {
281        let Some(r) = r.as_str() else { return env_err("$.reason", "reason must be a string") };
282        if schema::opt(m, "superseded_by").is_none() {
283            return env_err("$.reason", "reason is only present with superseded_by");
284        }
285        lexical::check_text(r, limits::SUMMARY, false, "$.reason").map_err(|e| e.recode(ErrorCode::Envelope))?;
286        if r.is_empty() {
287            return env_err("$.reason", "reason must not be empty");
288        }
289        env.reason = Some(r.into());
290    }
291    if let Some(q) = schema::opt(m, "quote") {
292        env.quote = Some(quote(q)?);
293    }
294    if let Some(e) = schema::opt(m, "error") {
295        let code = e.as_str().and_then(ErrorCode::from_code);
296        let ok = match (outcome, code) {
297            (Outcome::BadRequest, Some(c)) => BAD_REQUEST_CODES.contains(&c),
298            // Any code of 10.2 but the client's E_ENVELOPE; E_EVAL_UNAVAILABLE included since
299            // spec-0.3.0.
300            (_, Some(c)) => c != ErrorCode::Envelope,
301            (_, None) => false,
302        };
303        if !ok {
304            return env_err("$.error", format!("{e:?} is not a code this outcome can carry"));
305        }
306        env.error = code;
307    }
308    // 3.
309    if !outcome.status_ok(status) {
310        return env_err("$.outcome", format!("status {status} does not match outcome {}", outcome.as_str()));
311    }
312    let Some(rv) = receipt_v else { return Ok(env) };
313    // 4.
314    let r = crate::verify::signature_valid_value(rv, keys).map_err(|(e, _)| {
315        let inner = e.path().strip_prefix('$').unwrap_or(e.path());
316        Error::new(e.code(), format!("$.receipt{inner}"), e.detail())
317    })?;
318    // 5.
319    if r.cell().id() != request.cell_id {
320        return env_err("$.receipt.cell", "the receipt is for another cell than the one requested");
321    }
322    if let Some(asked) = &request.asof {
323        // D31 (revised in spec-0.3.0): at or before the requested asof, for ok and stale
324        // alike; instants are canonical, so text order is time order.
325        if r.asof() > asked.as_str() {
326            return env_err("$.receipt.asof", "the receipt's asof is after the requested asof");
327        }
328    }
329    // 6.
330    let primary = r.signer_host() == lexical::host_of(&r.cell().ns) && r.inputs().is_empty();
331    if (env.trust == Some(Trust::Primary)) != primary {
332        return env_err(
333            "$.trust",
334            if primary { "a primary receipt is labelled primary" } else { "the receipt is not primary" },
335        );
336    }
337    if env.superseded_by.as_deref() == Some(r.hash()) {
338        return env_err("$.superseded_by", "a receipt cannot supersede itself");
339    }
340    env.receipt = Some(r);
341    Ok(env)
342}
343
344fn quote(q: &Value) -> Result<Quote> {
345    let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
346    schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
347    let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
348    let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
349    let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
350    if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
351        return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
352    }
353    let url_ok = authorise
354        .strip_prefix("https://")
355        .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
356        && authorise.len() <= limits::LICENCE_URL;
357    if !url_ok {
358        return env_err("$.quote.authorise", "authorise is an https URL");
359    }
360    if !lexical::is_currency(currency) {
361        return env_err("$.quote.currency", "currency is a currency code from units.md");
362    }
363    Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
364}