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,
22 Unlicensed,
24 PaymentRequired,
26 RateLimited,
28 BadRequest,
30 Error,
32}
33
34impl Outcome {
35 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
90pub enum Trust {
91 Primary,
93 Verified,
95 Unverified,
97}
98
99impl Trust {
100 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
112pub struct Quote {
113 pub amount: String,
115 pub currency: String,
117 pub authorise: String,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Hash)]
123pub struct Request {
124 cell_id: String,
125 asof: Option<String>,
126}
127
128#[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 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 pub fn cell_id(&self) -> &str {
158 &self.cell_id
159 }
160
161 pub fn asof(&self) -> Option<&str> {
163 self.asof.as_deref()
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct Envelope {
170 pub outcome: Outcome,
172 pub receipt: Option<Receipt>,
174 pub trust: Option<Trust>,
176 pub age_seconds: Option<u64>,
178 pub superseded_by: Option<String>,
180 pub reason: Option<String>,
182 pub quote: Option<Quote>,
184 pub retry_after_seconds: Option<u64>,
186 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
212fn 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
225pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
229 let v = json::parse(body)?;
231 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 (_, 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 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 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 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 if r.asof() > asked.as_str() {
326 return env_err("$.receipt.asof", "the receipt's asof is after the requested asof");
327 }
328 }
329 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}