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. With a `fault` (`spec-0.4.0`, see
21    /// [`check_envelope_extras`]), the cell is a computed cell whose evaluation faulted.
22    NotCovered,
23    /// 403: the requester may not receive this cell.
24    Unlicensed,
25    /// 402: available for the quoted price.
26    PaymentRequired,
27    /// 429: too many requests.
28    RateLimited,
29    /// 400: the cell URI does not parse.
30    BadRequest,
31    /// 5xx: the mesh failed.
32    Error,
33}
34
35impl Outcome {
36    /// The outcome as written in JSON.
37    pub const fn as_str(self) -> &'static str {
38        match self {
39            Outcome::Ok => "ok",
40            Outcome::Stale => "stale",
41            Outcome::NotCovered => "not_covered",
42            Outcome::Unlicensed => "unlicensed",
43            Outcome::PaymentRequired => "payment_required",
44            Outcome::RateLimited => "rate_limited",
45            Outcome::BadRequest => "bad_request",
46            Outcome::Error => "error",
47        }
48    }
49
50    fn from_name(s: &str) -> Option<Outcome> {
51        Some(match s {
52            "ok" => Outcome::Ok,
53            "stale" => Outcome::Stale,
54            "not_covered" => Outcome::NotCovered,
55            "unlicensed" => Outcome::Unlicensed,
56            "payment_required" => Outcome::PaymentRequired,
57            "rate_limited" => Outcome::RateLimited,
58            "bad_request" => Outcome::BadRequest,
59            "error" => Outcome::Error,
60            _ => return None,
61        })
62    }
63
64    /// Required and optional members besides `outcome`.
65    fn members(self) -> (&'static [&'static str], &'static [&'static str]) {
66        match self {
67            Outcome::Ok => (&["receipt", "trust"], &["superseded_by", "reason"]),
68            Outcome::Stale => (&["receipt", "trust", "age_seconds"], &["superseded_by", "reason"]),
69            Outcome::NotCovered => (&[], &["fault"]),
70            Outcome::Unlicensed => (&[], &[]),
71            Outcome::PaymentRequired => (&["quote"], &[]),
72            Outcome::RateLimited => (&["retry_after_seconds"], &[]),
73            Outcome::BadRequest | Outcome::Error => (&["error"], &[]),
74        }
75    }
76
77    fn status_ok(self, status: u16) -> bool {
78        match self {
79            Outcome::Ok | Outcome::Stale => status == 200,
80            Outcome::NotCovered => status == 404,
81            Outcome::Unlicensed => status == 403,
82            Outcome::PaymentRequired => status == 402,
83            Outcome::RateLimited => status == 429,
84            Outcome::BadRequest => status == 400,
85            Outcome::Error => (500..=599).contains(&status),
86        }
87    }
88}
89
90/// The mesh's trust label (5.5).
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
92pub enum Trust {
93    /// Signed by the namespace's host, with no inputs.
94    Primary,
95    /// The mesh says it recomputed the receipt.
96    Verified,
97    /// Neither.
98    Unverified,
99}
100
101impl Trust {
102    /// The label as written in JSON.
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Trust::Primary => "primary",
106            Trust::Verified => "verified",
107            Trust::Unverified => "unverified",
108        }
109    }
110}
111
112/// A formula evaluation fault (SPEC 5.5, 15.11; `spec-0.4.0`): the optional `fault` member
113/// of a `not_covered` envelope. The cell has no value, deterministically; this is not
114/// missing data. These are formula codes, not verification codes, so they are not
115/// [`ErrorCode`]s.
116#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
117#[non_exhaustive]
118pub enum Fault {
119    /// `E_FORMULA_DIV_ZERO`
120    DivZero,
121    /// `E_FORMULA_DOMAIN`
122    Domain,
123    /// `E_FORMULA_RANGE`
124    Range,
125    /// `E_FORMULA_EMPTY`
126    Empty,
127    /// `E_FORMULA_ABSENT`
128    Absent,
129    /// `E_FORMULA_FUTURE`
130    Future,
131}
132
133impl Fault {
134    /// Every fault an envelope may carry, in the order of SPEC 5.5.
135    pub const ALL: &'static [Fault] =
136        &[Fault::DivZero, Fault::Domain, Fault::Range, Fault::Empty, Fault::Absent, Fault::Future];
137
138    /// The code as written, e.g. `"E_FORMULA_DIV_ZERO"`.
139    pub const fn as_str(self) -> &'static str {
140        match self {
141            Fault::DivZero => "E_FORMULA_DIV_ZERO",
142            Fault::Domain => "E_FORMULA_DOMAIN",
143            Fault::Range => "E_FORMULA_RANGE",
144            Fault::Empty => "E_FORMULA_EMPTY",
145            Fault::Absent => "E_FORMULA_ABSENT",
146            Fault::Future => "E_FORMULA_FUTURE",
147        }
148    }
149
150    /// Looks a fault up by its code; `None` for any other text (a budget, a cycle or too deep
151    /// an evaluation is not a fault but the mesh's failure, SPEC 5.5).
152    pub fn from_code(text: &str) -> Option<Fault> {
153        Fault::ALL.iter().copied().find(|f| f.as_str() == text)
154    }
155}
156
157impl core::fmt::Display for Fault {
158    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
159        f.write_str(self.as_str())
160    }
161}
162
163/// The members of a checked envelope that [`Envelope`] has no field for, because they were
164/// added to SPEC 5.5 after it was frozen (0.3.0): see [`check_envelope_extras`].
165#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
166#[non_exhaustive]
167pub struct EnvelopeExtras {
168    /// The formula evaluation fault of a `not_covered` envelope (`spec-0.4.0`).
169    pub fault: Option<Fault>,
170}
171
172/// A price quote (`payment_required`).
173#[derive(Clone, Debug, PartialEq, Eq, Hash)]
174pub struct Quote {
175    /// A canonical decimal greater than zero.
176    pub amount: String,
177    /// A currency code from `units.md`.
178    pub currency: String,
179    /// Where to pay: an https URL.
180    pub authorise: String,
181}
182
183/// What the client asked for, for the binding of 5.5 step 5.
184#[derive(Clone, Debug, PartialEq, Eq, Hash)]
185pub struct Request {
186    cell_id: String,
187    asof: Option<String>,
188    job: Option<String>,
189}
190
191/// A malformed request description (5.5 step 5): the caller's mistake, not an `E_*` code.
192#[derive(Clone, Debug, PartialEq, Eq, Hash)]
193pub struct InvalidRequest(String);
194
195impl core::fmt::Display for InvalidRequest {
196    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
197        write!(f, "invalid request: {}", self.0)
198    }
199}
200
201impl std::error::Error for InvalidRequest {}
202
203impl Request {
204    /// A request for the cell with id `cell_id` (64 lowercase hex digits) as of `asof` (an
205    /// instant) or now. Anything else is [`InvalidRequest`]: SPEC 5.5 makes a malformed
206    /// request the caller's error, reported outside the `E_*` codes.
207    pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
208        if lexical::check_hex(cell_id, 64, "").is_err() {
209            return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
210        }
211        if let Some(a) = asof {
212            if !lexical::is_instant(a) {
213                return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
214            }
215        }
216        Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from), job: None })
217    }
218
219    /// The request description for `GET` of a job's `poll` URL (5.5, `spec-0.4.1`): the
220    /// original request (`cell_id`, `asof`) and the job polled, 32 to 64 lowercase hex digits
221    /// (otherwise [`InvalidRequest`]). With it, an envelope may be `bad_request` with
222    /// `E_JOB_UNKNOWN`, and a `pending` one must carry this `job`; any other answer is bound
223    /// to the original request, as a `/cell` answer is.
224    pub fn with_job(cell_id: &str, asof: Option<&str>, job: &str) -> core::result::Result<Request, InvalidRequest> {
225        if !is_job(job) {
226            return Err(InvalidRequest(format!("job must be 32 to 64 lowercase hex digits: {job:?}")));
227        }
228        let mut r = Request::new(cell_id, asof)?;
229        r.job = Some(job.into());
230        Ok(r)
231    }
232
233    /// The job polled, for a request made at a job URL ([`Request::with_job`]).
234    pub fn job(&self) -> Option<&str> {
235        self.job.as_deref()
236    }
237
238    /// The requested cell id.
239    pub fn cell_id(&self) -> &str {
240        &self.cell_id
241    }
242
243    /// The requested `asof`, if any.
244    pub fn asof(&self) -> Option<&str> {
245        self.asof.as_deref()
246    }
247}
248
249/// A checked envelope.
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct Envelope {
252    /// The outcome.
253    pub outcome: Outcome,
254    /// The receipt (`ok`, `stale`), signature-valid and bound to the request.
255    pub receipt: Option<Receipt>,
256    /// The trust label (`ok`, `stale`).
257    pub trust: Option<Trust>,
258    /// Seconds since the mesh last confirmed the receipt (`stale`).
259    pub age_seconds: Option<u64>,
260    /// Hash of a newer receipt for the same cell.
261    pub superseded_by: Option<String>,
262    /// Why the receipt was superseded.
263    pub reason: Option<String>,
264    /// The quote (`payment_required`).
265    pub quote: Option<Quote>,
266    /// Seconds to wait (`rate_limited`).
267    pub retry_after_seconds: Option<u64>,
268    /// The failure code (`bad_request`, `error`).
269    pub error: Option<ErrorCode>,
270}
271
272const BAD_REQUEST_CODES: [ErrorCode; 15] = [
273    ErrorCode::ParseUri,
274    ErrorCode::CanonName,
275    ErrorCode::CanonArgName,
276    ErrorCode::CanonArgDuplicate,
277    ErrorCode::CanonArgOrder,
278    ErrorCode::CanonPercent,
279    ErrorCode::CanonType,
280    ErrorCode::CanonNumber,
281    ErrorCode::CanonDate,
282    ErrorCode::CanonInstant,
283    ErrorCode::CanonString,
284    ErrorCode::CanonBlob,
285    ErrorCode::CanonLimit,
286    ErrorCode::ArgUnknown,
287    ErrorCode::ArgMissing,
288];
289
290fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
291    err(ErrorCode::Envelope, path, detail)
292}
293
294/// `0` or `[1-9][0-9]*`, at most 2^53 - 1, judged on the number's text.
295fn integer_member(v: &Value, path: &str) -> Result<u64> {
296    let Some(t) = v.as_number_text() else {
297        return env_err(path, "must be a non-negative integer");
298    };
299    let ok =
300        !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
301    match (ok, t.parse::<u64>()) {
302        (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
303        _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
304    }
305}
306
307/// Checks a response envelope (5.5) in the order of its procedure: strict JSON, members,
308/// HTTP status, the receipt through step 6 of 10.1, the binding to `request`, then `trust`
309/// and `superseded_by`. Never touches the network. A `not_covered` envelope's `fault`
310/// (`spec-0.4.0`) is checked and accepted; [`check_envelope_extras`] also returns it.
311pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
312    check_envelope_extras(status, body, keys, request).map(|(env, _)| env)
313}
314
315/// [`check_envelope`], also returning the members [`Envelope`] has no field for
316/// ([`EnvelopeExtras`]): the `fault` of a `not_covered` envelope, one of the six formula
317/// evaluation faults of SPEC 5.5 (`E_ENVELOPE` for any other value, or on another outcome).
318///
319/// Its [`Envelope`] has no `pending` outcome, so a `pending` envelope is `E_ENVELOPE` here, as
320/// in 0.3.4; [`check_envelope_any`] accepts it.
321pub fn check_envelope_extras(
322    status: u16,
323    body: &[u8],
324    keys: &Keys,
325    request: &Request,
326) -> Result<(Envelope, EnvelopeExtras)> {
327    // 1.
328    let v = json::parse(body)?;
329    check_parsed(status, &v, keys, request)
330}
331
332/// Checks a response envelope (5.5) of any of the nine outcomes, `pending` included
333/// (`spec-0.4.1`), in the order of its procedure, and binds it to `request`: a `pending`
334/// envelope's echoed request to the request made (and its `job` to the job polled, when
335/// `request` has one), any other outcome to the original request's cell id and `asof`,
336/// whether it answers the `/cell` request or the job URL. `E_JOB_UNKNOWN` is accepted only in a
337/// `bad_request` answering a job URL (`request` with a job). Never touches the network.
338pub fn check_envelope_any(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<AnyEnvelope> {
339    let v = json::parse(body)?;
340    if let Value::Object(m) = &v {
341        if schema::opt(m, "outcome").and_then(Value::as_str) == Some("pending") {
342            return check_pending(status, m, request).map(AnyEnvelope::Pending);
343        }
344    }
345    check_parsed(status, &v, keys, request).map(|(e, x)| AnyEnvelope::Envelope(e, x))
346}
347
348fn check_parsed(status: u16, v: &Value, keys: &Keys, request: &Request) -> Result<(Envelope, EnvelopeExtras)> {
349    // 2.
350    let m = match v {
351        Value::Object(m) if matches!(schema::opt(m, "outcome"), Some(Value::String(_))) => m,
352        _ => return env_err("$", "an envelope is an object with a string outcome"),
353    };
354    let oname = schema::get(m, "outcome").as_str().unwrap_or("");
355    let Some(outcome) = Outcome::from_name(oname) else {
356        return env_err("$.outcome", format!("unknown outcome {oname:?}"));
357    };
358    let (req, optional) = outcome.members();
359    let mut allowed: Vec<&str> = vec!["outcome"];
360    allowed.extend_from_slice(req);
361    schema::exact(m, &allowed, optional, "$", ErrorCode::Envelope)?;
362    let mut env = Envelope {
363        outcome,
364        receipt: None,
365        trust: None,
366        age_seconds: None,
367        superseded_by: None,
368        reason: None,
369        quote: None,
370        retry_after_seconds: None,
371        error: None,
372    };
373    let receipt_v = schema::opt(m, "receipt");
374    if let Some(r) = receipt_v {
375        if !matches!(r, Value::Object(_)) {
376            return env_err("$.receipt", "receipt is a receipt object");
377        }
378    }
379    if let Some(t) = schema::opt(m, "trust") {
380        env.trust = Some(match t.as_str() {
381            Some("primary") => Trust::Primary,
382            Some("verified") => Trust::Verified,
383            Some("unverified") => Trust::Unverified,
384            _ => return env_err("$.trust", "trust is primary, verified or unverified"),
385        });
386    }
387    if let Some(a) = schema::opt(m, "age_seconds") {
388        env.age_seconds = Some(integer_member(a, "$.age_seconds")?);
389    }
390    if let Some(a) = schema::opt(m, "retry_after_seconds") {
391        env.retry_after_seconds = Some(integer_member(a, "$.retry_after_seconds")?);
392    }
393    if let Some(s) = schema::opt(m, "superseded_by") {
394        let s = s.as_str().unwrap_or("");
395        lexical::check_hex(s, 64, "$.superseded_by").map_err(|e| e.recode(ErrorCode::Envelope))?;
396        env.superseded_by = Some(s.into());
397    }
398    if let Some(r) = schema::opt(m, "reason") {
399        let Some(r) = r.as_str() else { return env_err("$.reason", "reason must be a string") };
400        if schema::opt(m, "superseded_by").is_none() {
401            return env_err("$.reason", "reason is only present with superseded_by");
402        }
403        lexical::check_text(r, limits::SUMMARY, false, "$.reason").map_err(|e| e.recode(ErrorCode::Envelope))?;
404        if r.is_empty() {
405            return env_err("$.reason", "reason must not be empty");
406        }
407        env.reason = Some(r.into());
408    }
409    if let Some(q) = schema::opt(m, "quote") {
410        env.quote = Some(quote(q)?);
411    }
412    if let Some(e) = schema::opt(m, "error") {
413        let code = e.as_str().and_then(ErrorCode::from_code);
414        let ok = match (outcome, code) {
415            // E_JOB_UNKNOWN answers a job URL only (spec-0.4.1).
416            (Outcome::BadRequest, Some(ErrorCode::JobUnknown)) => request.job.is_some(),
417            (Outcome::BadRequest, Some(c)) => BAD_REQUEST_CODES.contains(&c),
418            // Any code of 10.2 but the client's E_ENVELOPE and the job URL's E_JOB_UNKNOWN;
419            // E_EVAL_UNAVAILABLE included since spec-0.3.0.
420            (_, Some(c)) => c != ErrorCode::Envelope && c != ErrorCode::JobUnknown,
421            (_, None) => false,
422        };
423        if !ok {
424            return env_err("$.error", format!("{e:?} is not a code this outcome can carry"));
425        }
426        env.error = code;
427    }
428    let mut extras = EnvelopeExtras::default();
429    if let Some(f) = schema::opt(m, "fault") {
430        let Some(fault) = f.as_str().and_then(Fault::from_code) else {
431            return env_err("$.fault", format!("{f:?} is not a formula evaluation fault"));
432        };
433        extras.fault = Some(fault);
434    }
435    // 3.
436    if !outcome.status_ok(status) {
437        return env_err("$.outcome", format!("status {status} does not match outcome {}", outcome.as_str()));
438    }
439    let Some(rv) = receipt_v else { return Ok((env, extras)) };
440    // 4.
441    let r = crate::verify::signature_valid_value(rv, keys).map_err(|(e, _)| {
442        let inner = e.path().strip_prefix('$').unwrap_or(e.path());
443        Error::new(e.code(), format!("$.receipt{inner}"), e.detail())
444    })?;
445    // 5.
446    if r.cell().id() != request.cell_id {
447        return env_err("$.receipt.cell", "the receipt is for another cell than the one requested");
448    }
449    if let Some(asked) = &request.asof {
450        // D31 (revised in spec-0.3.0): at or before the requested asof, for ok and stale
451        // alike; instants are canonical, so text order is time order.
452        if r.asof() > asked.as_str() {
453            return env_err("$.receipt.asof", "the receipt's asof is after the requested asof");
454        }
455    }
456    // 6.
457    let primary = r.signer_host() == lexical::host_of(&r.cell().ns) && r.inputs().is_empty();
458    if (env.trust == Some(Trust::Primary)) != primary {
459        return env_err(
460            "$.trust",
461            if primary { "a primary receipt is labelled primary" } else { "the receipt is not primary" },
462        );
463    }
464    if env.superseded_by.as_deref() == Some(r.hash()) {
465        return env_err("$.superseded_by", "a receipt cannot supersede itself");
466    }
467    env.receipt = Some(r);
468    Ok((env, extras))
469}
470
471/// `[0-9a-f]{32,64}`.
472fn is_job(s: &str) -> bool {
473    (32..=64).contains(&s.len()) && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
474}
475
476/// The answer to a request: a [`Pending`] envelope, or one of the eight outcomes an
477/// [`Envelope`] holds, with its [`EnvelopeExtras`] (see [`check_envelope_any`]).
478// Not boxed: one value per checked response, and matching on it stays plain.
479#[allow(clippy::large_enum_variant)]
480#[derive(Clone, Debug, PartialEq, Eq)]
481#[non_exhaustive]
482pub enum AnyEnvelope {
483    /// `ok`, `stale`, `not_covered`, `unlicensed`, `payment_required`, `rate_limited`,
484    /// `bad_request` or `error`.
485    Envelope(Envelope, EnvelopeExtras),
486    /// `pending` (202): the mesh is still computing; the answer will be at `poll`.
487    Pending(Pending),
488}
489
490impl AnyEnvelope {
491    /// The outcome as written in JSON, `"pending"` included.
492    pub fn outcome_str(&self) -> &'static str {
493        match self {
494            AnyEnvelope::Envelope(e, _) => e.outcome.as_str(),
495            AnyEnvelope::Pending(_) => "pending",
496        }
497    }
498}
499
500/// A `pending` envelope (5.5, `spec-0.4.1`): the job, an estimate, the URL to poll and the
501/// original request echoed. Build one with [`Pending::new`] (a mesh), or get one from
502/// [`check_envelope_any`] (a client).
503#[derive(Clone, Debug, PartialEq, Eq, Hash)]
504#[non_exhaustive]
505pub struct Pending {
506    /// The job id: 32 to 64 lowercase hex digits, a capability.
507    pub job: String,
508    /// The mesh's estimate of the seconds until the result is ready; a hint.
509    pub estimate_seconds: u64,
510    /// The job's URL: `https://` and printable ASCII, at most 512 characters, ending
511    /// `/jobs/<job>`.
512    pub poll: String,
513    /// The original request, echoed: its `cell_id` and `asof` (never a job).
514    pub request: Request,
515}
516
517impl Pending {
518    /// The HTTP status of a `pending` envelope.
519    pub const STATUS: u16 = 202;
520
521    /// A `pending` envelope answering `request` (its `cell_id` and `asof` are echoed; a job
522    /// in it is not), checked by the rules of 5.5: `job` 32 to 64 lowercase hex digits,
523    /// `estimate_seconds` at most 2^53 - 1, `poll` an `https://` URL of printable ASCII of at
524    /// most 512 characters whose host (up to the first `/`) is a DNS name (2.1), ending
525    /// `/jobs/<job>`. That `poll` names the mesh's own host is the caller's to ensure. A failure is `E_ENVELOPE`, at the member's
526    /// path.
527    pub fn new(job: &str, estimate_seconds: u64, poll: &str, request: Request) -> Result<Pending> {
528        check_job_and_poll(job, poll)?;
529        if estimate_seconds > limits::BLOB_LENGTH {
530            return env_err("$.estimate_seconds", "estimate_seconds is at most 2^53 - 1");
531        }
532        let request = Request { job: None, ..request };
533        Ok(Pending { job: job.into(), estimate_seconds, poll: poll.into(), request })
534    }
535
536    /// The JSON form of the envelope.
537    pub fn to_value(&self) -> Value {
538        // Value has no public number constructor: the canonical text, parsed.
539        json::parse(&self.to_json()).unwrap_or(Value::Null)
540    }
541
542    /// The envelope's body: its canonical JSON text (JCS), served with [`Pending::STATUS`].
543    pub fn to_json(&self) -> Vec<u8> {
544        let mut out = String::new();
545        out.push_str("{\"estimate_seconds\":");
546        out.push_str(&self.estimate_seconds.to_string());
547        out.push_str(",\"job\":");
548        json::write_string(&self.job, &mut out);
549        out.push_str(",\"outcome\":\"pending\",\"poll\":");
550        json::write_string(&self.poll, &mut out);
551        out.push_str(",\"request\":{");
552        if let Some(a) = &self.request.asof {
553            out.push_str("\"asof\":");
554            json::write_string(a, &mut out);
555            out.push(',');
556        }
557        out.push_str("\"cell_id\":");
558        json::write_string(&self.request.cell_id, &mut out);
559        out.push_str("}}");
560        out.into_bytes()
561    }
562}
563
564fn check_job_and_poll(job: &str, poll: &str) -> Result<()> {
565    if !is_job(job) {
566        return env_err("$.job", "job is 32 to 64 lowercase hex digits");
567    }
568    let url_ok =
569        poll.strip_prefix("https://").is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
570            && poll.len() <= limits::LICENCE_URL;
571    if !url_ok {
572        return env_err("$.poll", "poll is an https URL of printable ASCII, at most 512 characters");
573    }
574    // The text between https:// and the next / is a DNS name (2.1; 3f79313).
575    let host = poll["https://".len()..].split('/').next().unwrap_or("");
576    if !poll["https://".len()..].contains('/') || lexical::check_dns_name(host, "").is_err() {
577        return env_err("$.poll", "poll's host is a DNS name");
578    }
579    if !poll.ends_with(&format!("/jobs/{job}")) {
580        return env_err("$.poll", "poll ends /jobs/<job>");
581    }
582    Ok(())
583}
584
585/// A `pending` envelope (5.5 steps 2, 3 and 5; it has no receipt, so step 4 and 6 do not
586/// apply).
587fn check_pending(status: u16, m: &[(String, Value)], request: &Request) -> Result<Pending> {
588    // 2.
589    schema::exact(m, &["estimate_seconds", "job", "outcome", "poll", "request"], &[], "$", ErrorCode::Envelope)?;
590    let estimate = integer_member(schema::get(m, "estimate_seconds"), "$.estimate_seconds")?;
591    let Some(job) = schema::get(m, "job").as_str() else { return env_err("$.job", "job must be a string") };
592    let Some(poll) = schema::get(m, "poll").as_str() else { return env_err("$.poll", "poll must be a string") };
593    check_job_and_poll(job, poll)?;
594    let rm = schema::object_or(schema::get(m, "request"), "$.request", ErrorCode::Envelope, "request")?;
595    schema::exact(rm, &["cell_id"], &["asof"], "$.request", ErrorCode::Envelope)?;
596    let cell_id = schema::string_or(rm, "cell_id", "$.request", ErrorCode::Envelope)?;
597    let asof = match schema::opt(rm, "asof") {
598        None => None,
599        Some(_) => Some(schema::string_or(rm, "asof", "$.request", ErrorCode::Envelope)?),
600    };
601    let echoed =
602        Request::new(cell_id, asof).map_err(|e| Error::new(ErrorCode::Envelope, "$.request", e.to_string()))?;
603    // 3.
604    if status != Pending::STATUS {
605        return env_err("$.outcome", format!("status {status} does not match outcome pending"));
606    }
607    // 5.
608    if echoed.cell_id != request.cell_id || echoed.asof != request.asof {
609        return env_err("$.request", "the echoed request is not the request made");
610    }
611    if let Some(polled) = &request.job {
612        if job != polled {
613            return env_err("$.job", "the envelope is for another job than the one polled");
614        }
615    }
616    Ok(Pending { job: job.into(), estimate_seconds: estimate, poll: poll.into(), request: echoed })
617}
618
619fn quote(q: &Value) -> Result<Quote> {
620    let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
621    schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
622    let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
623    let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
624    let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
625    if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
626        return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
627    }
628    let url_ok = authorise
629        .strip_prefix("https://")
630        .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
631        && authorise.len() <= limits::LICENCE_URL;
632    if !url_ok {
633        return env_err("$.quote.authorise", "authorise is an https URL");
634    }
635    if !lexical::is_currency(currency) {
636        return env_err("$.quote.currency", "currency is a currency code from units.md");
637    }
638    Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
639}