1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Outcome {
13 Ok,
17 Stale,
20 NotCovered,
23 Unlicensed,
25 PaymentRequired,
27 RateLimited,
29 BadRequest,
31 Error,
33}
34
35impl Outcome {
36 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
92pub enum Trust {
93 Primary,
95 Verified,
97 Unverified,
99}
100
101impl Trust {
102 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
117#[non_exhaustive]
118pub enum Fault {
119 DivZero,
121 Domain,
123 Range,
125 Empty,
127 Absent,
129 Future,
131}
132
133impl Fault {
134 pub const ALL: &'static [Fault] =
136 &[Fault::DivZero, Fault::Domain, Fault::Range, Fault::Empty, Fault::Absent, Fault::Future];
137
138 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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
166#[non_exhaustive]
167pub struct EnvelopeExtras {
168 pub fault: Option<Fault>,
170}
171
172#[derive(Clone, Debug, PartialEq, Eq, Hash)]
174pub struct Quote {
175 pub amount: String,
177 pub currency: String,
179 pub authorise: String,
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Hash)]
185pub struct Request {
186 cell_id: String,
187 asof: Option<String>,
188 job: Option<String>,
189}
190
191#[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 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 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 pub fn job(&self) -> Option<&str> {
235 self.job.as_deref()
236 }
237
238 pub fn cell_id(&self) -> &str {
240 &self.cell_id
241 }
242
243 pub fn asof(&self) -> Option<&str> {
245 self.asof.as_deref()
246 }
247}
248
249#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct Envelope {
252 pub outcome: Outcome,
254 pub receipt: Option<Receipt>,
256 pub trust: Option<Trust>,
258 pub age_seconds: Option<u64>,
260 pub superseded_by: Option<String>,
262 pub reason: Option<String>,
264 pub quote: Option<Quote>,
266 pub retry_after_seconds: Option<u64>,
268 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
294fn 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
307pub 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
315pub fn check_envelope_extras(
322 status: u16,
323 body: &[u8],
324 keys: &Keys,
325 request: &Request,
326) -> Result<(Envelope, EnvelopeExtras)> {
327 let v = json::parse(body)?;
329 check_parsed(status, &v, keys, request)
330}
331
332pub 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 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 (Outcome::BadRequest, Some(ErrorCode::JobUnknown)) => request.job.is_some(),
417 (Outcome::BadRequest, Some(c)) => BAD_REQUEST_CODES.contains(&c),
418 (_, 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 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 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 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 if r.asof() > asked.as_str() {
453 return env_err("$.receipt.asof", "the receipt's asof is after the requested asof");
454 }
455 }
456 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
471fn 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#[allow(clippy::large_enum_variant)]
480#[derive(Clone, Debug, PartialEq, Eq)]
481#[non_exhaustive]
482pub enum AnyEnvelope {
483 Envelope(Envelope, EnvelopeExtras),
486 Pending(Pending),
488}
489
490impl AnyEnvelope {
491 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
504#[non_exhaustive]
505pub struct Pending {
506 pub job: String,
508 pub estimate_seconds: u64,
510 pub poll: String,
513 pub request: Request,
515}
516
517impl Pending {
518 pub const STATUS: u16 = 202;
520
521 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 pub fn to_value(&self) -> Value {
538 json::parse(&self.to_json()).unwrap_or(Value::Null)
540 }
541
542 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 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
585fn check_pending(status: u16, m: &[(String, Value)], request: &Request) -> Result<Pending> {
588 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 if status != Pending::STATUS {
605 return env_err("$.outcome", format!("status {status} does not match outcome pending"));
606 }
607 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}