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