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,
15 Stale,
17 NotCovered,
19 Unlicensed,
21 PaymentRequired,
23 RateLimited,
25 BadRequest,
27 Error,
29}
30
31impl Outcome {
32 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
87pub enum Trust {
88 Primary,
90 Verified,
92 Unverified,
94}
95
96impl Trust {
97 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
109pub struct Quote {
110 pub amount: String,
112 pub currency: String,
114 pub authorise: String,
116}
117
118#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120pub struct Request {
121 cell_id: String,
122 asof: Option<String>,
123}
124
125#[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 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 pub fn cell_id(&self) -> &str {
155 &self.cell_id
156 }
157
158 pub fn asof(&self) -> Option<&str> {
160 self.asof.as_deref()
161 }
162}
163
164#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct Envelope {
167 pub outcome: Outcome,
169 pub receipt: Option<Receipt>,
171 pub trust: Option<Trust>,
173 pub age_seconds: Option<u64>,
175 pub superseded_by: Option<String>,
177 pub reason: Option<String>,
179 pub quote: Option<Quote>,
181 pub retry_after_seconds: Option<u64>,
183 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
209fn integer_member(v: &Value, path: &str) -> Result<u64> {
211 let Some(t) = v.as_number_text() else {
212 return env_err(path, "must be a non-negative integer");
213 };
214 let ok =
215 !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
216 match (ok, t.parse::<u64>()) {
217 (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
218 _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
219 }
220}
221
222pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
226 let v = json::parse(body)?;
228 let m = match &v {
230 Value::Object(m) if matches!(schema::opt(m, "outcome"), Some(Value::String(_))) => m,
231 _ => return env_err("$", "an envelope is an object with a string outcome"),
232 };
233 let oname = schema::get(m, "outcome").as_str().unwrap_or("");
234 let Some(outcome) = Outcome::from_name(oname) else {
235 return env_err("$.outcome", format!("unknown outcome {oname:?}"));
236 };
237 let (req, optional) = outcome.members();
238 let mut allowed: Vec<&str> = vec!["outcome"];
239 allowed.extend_from_slice(req);
240 schema::exact(m, &allowed, optional, "$", ErrorCode::Envelope)?;
241 let mut env = Envelope {
242 outcome,
243 receipt: None,
244 trust: None,
245 age_seconds: None,
246 superseded_by: None,
247 reason: None,
248 quote: None,
249 retry_after_seconds: None,
250 error: None,
251 };
252 let receipt_v = schema::opt(m, "receipt");
253 if let Some(r) = receipt_v {
254 if !matches!(r, Value::Object(_)) {
255 return env_err("$.receipt", "receipt is a receipt object");
256 }
257 }
258 if let Some(t) = schema::opt(m, "trust") {
259 env.trust = Some(match t.as_str() {
260 Some("primary") => Trust::Primary,
261 Some("verified") => Trust::Verified,
262 Some("unverified") => Trust::Unverified,
263 _ => return env_err("$.trust", "trust is primary, verified or unverified"),
264 });
265 }
266 if let Some(a) = schema::opt(m, "age_seconds") {
267 env.age_seconds = Some(integer_member(a, "$.age_seconds")?);
268 }
269 if let Some(a) = schema::opt(m, "retry_after_seconds") {
270 env.retry_after_seconds = Some(integer_member(a, "$.retry_after_seconds")?);
271 }
272 if let Some(s) = schema::opt(m, "superseded_by") {
273 let s = s.as_str().unwrap_or("");
274 lexical::check_hex(s, 64, "$.superseded_by").map_err(|e| e.recode(ErrorCode::Envelope))?;
275 env.superseded_by = Some(s.into());
276 }
277 if let Some(r) = schema::opt(m, "reason") {
278 let Some(r) = r.as_str() else { return env_err("$.reason", "reason must be a string") };
279 if schema::opt(m, "superseded_by").is_none() {
280 return env_err("$.reason", "reason is only present with superseded_by");
281 }
282 lexical::check_text(r, limits::SUMMARY, false, "$.reason").map_err(|e| e.recode(ErrorCode::Envelope))?;
283 if r.is_empty() {
284 return env_err("$.reason", "reason must not be empty");
285 }
286 env.reason = Some(r.into());
287 }
288 if let Some(q) = schema::opt(m, "quote") {
289 env.quote = Some(quote(q)?);
290 }
291 if let Some(e) = schema::opt(m, "error") {
292 let code = e.as_str().and_then(ErrorCode::from_code);
293 let ok = match (outcome, code) {
294 (Outcome::BadRequest, Some(c)) => BAD_REQUEST_CODES.contains(&c),
295 (_, Some(c)) => c != ErrorCode::Envelope && c != ErrorCode::EvalUnavailable,
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 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 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 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.as_str(),
321 _ => r.asof() == asked.as_str(),
322 };
323 if !bound {
324 return env_err("$.receipt.asof", "the receipt does not answer the requested asof");
325 }
326 }
327 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()) {
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}