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}
189
190#[derive(Clone, Debug, PartialEq, Eq, Hash)]
192pub struct InvalidRequest(String);
193
194impl core::fmt::Display for InvalidRequest {
195 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
196 write!(f, "invalid request: {}", self.0)
197 }
198}
199
200impl std::error::Error for InvalidRequest {}
201
202impl Request {
203 pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
207 if lexical::check_hex(cell_id, 64, "").is_err() {
208 return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
209 }
210 if let Some(a) = asof {
211 if !lexical::is_instant(a) {
212 return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
213 }
214 }
215 Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from) })
216 }
217
218 pub fn cell_id(&self) -> &str {
220 &self.cell_id
221 }
222
223 pub fn asof(&self) -> Option<&str> {
225 self.asof.as_deref()
226 }
227}
228
229#[derive(Clone, Debug, PartialEq, Eq)]
231pub struct Envelope {
232 pub outcome: Outcome,
234 pub receipt: Option<Receipt>,
236 pub trust: Option<Trust>,
238 pub age_seconds: Option<u64>,
240 pub superseded_by: Option<String>,
242 pub reason: Option<String>,
244 pub quote: Option<Quote>,
246 pub retry_after_seconds: Option<u64>,
248 pub error: Option<ErrorCode>,
250}
251
252const BAD_REQUEST_CODES: [ErrorCode; 15] = [
253 ErrorCode::ParseUri,
254 ErrorCode::CanonName,
255 ErrorCode::CanonArgName,
256 ErrorCode::CanonArgDuplicate,
257 ErrorCode::CanonArgOrder,
258 ErrorCode::CanonPercent,
259 ErrorCode::CanonType,
260 ErrorCode::CanonNumber,
261 ErrorCode::CanonDate,
262 ErrorCode::CanonInstant,
263 ErrorCode::CanonString,
264 ErrorCode::CanonBlob,
265 ErrorCode::CanonLimit,
266 ErrorCode::ArgUnknown,
267 ErrorCode::ArgMissing,
268];
269
270fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
271 err(ErrorCode::Envelope, path, detail)
272}
273
274fn integer_member(v: &Value, path: &str) -> Result<u64> {
276 let Some(t) = v.as_number_text() else {
277 return env_err(path, "must be a non-negative integer");
278 };
279 let ok =
280 !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
281 match (ok, t.parse::<u64>()) {
282 (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
283 _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
284 }
285}
286
287pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
292 check_envelope_extras(status, body, keys, request).map(|(env, _)| env)
293}
294
295pub fn check_envelope_extras(
299 status: u16,
300 body: &[u8],
301 keys: &Keys,
302 request: &Request,
303) -> Result<(Envelope, EnvelopeExtras)> {
304 let v = json::parse(body)?;
306 let m = match &v {
308 Value::Object(m) if matches!(schema::opt(m, "outcome"), Some(Value::String(_))) => m,
309 _ => return env_err("$", "an envelope is an object with a string outcome"),
310 };
311 let oname = schema::get(m, "outcome").as_str().unwrap_or("");
312 let Some(outcome) = Outcome::from_name(oname) else {
313 return env_err("$.outcome", format!("unknown outcome {oname:?}"));
314 };
315 let (req, optional) = outcome.members();
316 let mut allowed: Vec<&str> = vec!["outcome"];
317 allowed.extend_from_slice(req);
318 schema::exact(m, &allowed, optional, "$", ErrorCode::Envelope)?;
319 let mut env = Envelope {
320 outcome,
321 receipt: None,
322 trust: None,
323 age_seconds: None,
324 superseded_by: None,
325 reason: None,
326 quote: None,
327 retry_after_seconds: None,
328 error: None,
329 };
330 let receipt_v = schema::opt(m, "receipt");
331 if let Some(r) = receipt_v {
332 if !matches!(r, Value::Object(_)) {
333 return env_err("$.receipt", "receipt is a receipt object");
334 }
335 }
336 if let Some(t) = schema::opt(m, "trust") {
337 env.trust = Some(match t.as_str() {
338 Some("primary") => Trust::Primary,
339 Some("verified") => Trust::Verified,
340 Some("unverified") => Trust::Unverified,
341 _ => return env_err("$.trust", "trust is primary, verified or unverified"),
342 });
343 }
344 if let Some(a) = schema::opt(m, "age_seconds") {
345 env.age_seconds = Some(integer_member(a, "$.age_seconds")?);
346 }
347 if let Some(a) = schema::opt(m, "retry_after_seconds") {
348 env.retry_after_seconds = Some(integer_member(a, "$.retry_after_seconds")?);
349 }
350 if let Some(s) = schema::opt(m, "superseded_by") {
351 let s = s.as_str().unwrap_or("");
352 lexical::check_hex(s, 64, "$.superseded_by").map_err(|e| e.recode(ErrorCode::Envelope))?;
353 env.superseded_by = Some(s.into());
354 }
355 if let Some(r) = schema::opt(m, "reason") {
356 let Some(r) = r.as_str() else { return env_err("$.reason", "reason must be a string") };
357 if schema::opt(m, "superseded_by").is_none() {
358 return env_err("$.reason", "reason is only present with superseded_by");
359 }
360 lexical::check_text(r, limits::SUMMARY, false, "$.reason").map_err(|e| e.recode(ErrorCode::Envelope))?;
361 if r.is_empty() {
362 return env_err("$.reason", "reason must not be empty");
363 }
364 env.reason = Some(r.into());
365 }
366 if let Some(q) = schema::opt(m, "quote") {
367 env.quote = Some(quote(q)?);
368 }
369 if let Some(e) = schema::opt(m, "error") {
370 let code = e.as_str().and_then(ErrorCode::from_code);
371 let ok = match (outcome, code) {
372 (Outcome::BadRequest, Some(c)) => BAD_REQUEST_CODES.contains(&c),
373 (_, Some(c)) => c != ErrorCode::Envelope,
376 (_, None) => false,
377 };
378 if !ok {
379 return env_err("$.error", format!("{e:?} is not a code this outcome can carry"));
380 }
381 env.error = code;
382 }
383 let mut extras = EnvelopeExtras::default();
384 if let Some(f) = schema::opt(m, "fault") {
385 let Some(fault) = f.as_str().and_then(Fault::from_code) else {
386 return env_err("$.fault", format!("{f:?} is not a formula evaluation fault"));
387 };
388 extras.fault = Some(fault);
389 }
390 if !outcome.status_ok(status) {
392 return env_err("$.outcome", format!("status {status} does not match outcome {}", outcome.as_str()));
393 }
394 let Some(rv) = receipt_v else { return Ok((env, extras)) };
395 let r = crate::verify::signature_valid_value(rv, keys).map_err(|(e, _)| {
397 let inner = e.path().strip_prefix('$').unwrap_or(e.path());
398 Error::new(e.code(), format!("$.receipt{inner}"), e.detail())
399 })?;
400 if r.cell().id() != request.cell_id {
402 return env_err("$.receipt.cell", "the receipt is for another cell than the one requested");
403 }
404 if let Some(asked) = &request.asof {
405 if r.asof() > asked.as_str() {
408 return env_err("$.receipt.asof", "the receipt's asof is after the requested asof");
409 }
410 }
411 let primary = r.signer_host() == lexical::host_of(&r.cell().ns) && r.inputs().is_empty();
413 if (env.trust == Some(Trust::Primary)) != primary {
414 return env_err(
415 "$.trust",
416 if primary { "a primary receipt is labelled primary" } else { "the receipt is not primary" },
417 );
418 }
419 if env.superseded_by.as_deref() == Some(r.hash()) {
420 return env_err("$.superseded_by", "a receipt cannot supersede itself");
421 }
422 env.receipt = Some(r);
423 Ok((env, extras))
424}
425
426fn quote(q: &Value) -> Result<Quote> {
427 let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
428 schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
429 let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
430 let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
431 let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
432 if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
433 return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
434 }
435 let url_ok = authorise
436 .strip_prefix("https://")
437 .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
438 && authorise.len() <= limits::LICENCE_URL;
439 if !url_ok {
440 return env_err("$.quote.authorise", "authorise is an https URL");
441 }
442 if !lexical::is_currency(currency) {
443 return env_err("$.quote.currency", "currency is a currency code from units.md");
444 }
445 Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
446}