1use crate::error::{err, 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
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
53pub enum Trust {
54 Primary,
56 Verified,
58 Unverified,
60}
61
62impl Trust {
63 pub const fn as_str(self) -> &'static str {
65 match self {
66 Trust::Primary => "primary",
67 Trust::Verified => "verified",
68 Trust::Unverified => "unverified",
69 }
70 }
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
78#[non_exhaustive]
79pub enum Fault {
80 DivZero,
82 Domain,
84 Range,
86 Empty,
88 Absent,
90 Future,
92}
93
94impl Fault {
95 pub const ALL: &'static [Fault] =
97 &[Fault::DivZero, Fault::Domain, Fault::Range, Fault::Empty, Fault::Absent, Fault::Future];
98
99 pub const fn as_str(self) -> &'static str {
101 match self {
102 Fault::DivZero => "E_FORMULA_DIV_ZERO",
103 Fault::Domain => "E_FORMULA_DOMAIN",
104 Fault::Range => "E_FORMULA_RANGE",
105 Fault::Empty => "E_FORMULA_EMPTY",
106 Fault::Absent => "E_FORMULA_ABSENT",
107 Fault::Future => "E_FORMULA_FUTURE",
108 }
109 }
110
111 pub fn from_code(text: &str) -> Option<Fault> {
114 Fault::ALL.iter().copied().find(|f| f.as_str() == text)
115 }
116}
117
118impl core::fmt::Display for Fault {
119 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120 f.write_str(self.as_str())
121 }
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
129#[non_exhaustive]
130pub enum ForeignDetail {
131 Offline,
133 Network,
135 Dns,
137 Tls,
139 Timeout,
141 Refused,
143}
144
145impl ForeignDetail {
146 pub const ALL: &'static [ForeignDetail] = &[
148 ForeignDetail::Offline,
149 ForeignDetail::Network,
150 ForeignDetail::Dns,
151 ForeignDetail::Tls,
152 ForeignDetail::Timeout,
153 ForeignDetail::Refused,
154 ];
155
156 pub const fn as_str(self) -> &'static str {
158 match self {
159 ForeignDetail::Offline => "offline",
160 ForeignDetail::Network => "network",
161 ForeignDetail::Dns => "dns",
162 ForeignDetail::Tls => "tls",
163 ForeignDetail::Timeout => "timeout",
164 ForeignDetail::Refused => "refused",
165 }
166 }
167
168 pub fn from_name(text: &str) -> Option<ForeignDetail> {
170 ForeignDetail::ALL.iter().copied().find(|d| d.as_str() == text)
171 }
172}
173
174impl core::fmt::Display for ForeignDetail {
175 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
176 f.write_str(self.as_str())
177 }
178}
179
180#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
183#[non_exhaustive]
184pub struct EnvelopeExtras {
185 pub fault: Option<Fault>,
187 pub fn_hash: Option<String>,
190 pub detail: Option<ForeignDetail>,
192}
193
194#[derive(Clone, Debug, PartialEq, Eq, Hash)]
196pub struct Quote {
197 pub amount: String,
199 pub currency: String,
201 pub authorise: String,
203}
204
205#[derive(Clone, Debug, PartialEq, Eq, Hash)]
207pub struct Request {
208 pub(crate) cell_id: String,
209 pub(crate) asof: Option<String>,
210 pub(crate) job: Option<String>,
211 pub(crate) pin: Option<String>,
212 pub(crate) mesh: Option<String>,
213}
214
215#[derive(Clone, Debug, PartialEq, Eq, Hash)]
217pub struct InvalidRequest(String);
218
219impl core::fmt::Display for InvalidRequest {
220 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
221 write!(f, "invalid request: {}", self.0)
222 }
223}
224
225impl std::error::Error for InvalidRequest {}
226
227impl Request {
228 pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
232 if lexical::check_hex(cell_id, 64, "").is_err() {
233 return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
234 }
235 if let Some(a) = asof {
236 if !lexical::is_instant(a) {
237 return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
238 }
239 }
240 Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from), job: None, pin: None, mesh: None })
241 }
242
243 pub fn with_job(cell_id: &str, asof: Option<&str>, job: &str) -> core::result::Result<Request, InvalidRequest> {
249 if !is_job(job) {
250 return Err(InvalidRequest(format!("job must be 32 to 64 lowercase hex digits: {job:?}")));
251 }
252 let mut r = Request::new(cell_id, asof)?;
253 r.job = Some(job.into());
254 Ok(r)
255 }
256
257 pub fn with_pin(self, fn_hash: &str) -> core::result::Result<Request, InvalidRequest> {
261 if lexical::check_hex(fn_hash, 64, "").is_err() {
262 return Err(InvalidRequest(format!("fn_hash must be 64 lowercase hex digits: {fn_hash:?}")));
263 }
264 Ok(Request { pin: Some(fn_hash.into()), ..self })
265 }
266
267 pub fn with_mesh(self, host: &str) -> core::result::Result<Request, InvalidRequest> {
271 if lexical::check_dns_name(host, "").is_err() {
272 return Err(InvalidRequest(format!("mesh must be a DNS name: {host:?}")));
273 }
274 Ok(Request { mesh: Some(host.into()), ..self })
275 }
276
277 pub fn fn_hash(&self) -> Option<&str> {
279 self.pin.as_deref()
280 }
281
282 pub fn mesh(&self) -> Option<&str> {
284 self.mesh.as_deref()
285 }
286
287 pub fn job(&self) -> Option<&str> {
289 self.job.as_deref()
290 }
291
292 pub fn cell_id(&self) -> &str {
294 &self.cell_id
295 }
296
297 pub fn asof(&self) -> Option<&str> {
299 self.asof.as_deref()
300 }
301}
302
303#[derive(Clone, Debug, PartialEq, Eq)]
305pub struct Envelope {
306 pub outcome: Outcome,
308 pub receipt: Option<Receipt>,
310 pub trust: Option<Trust>,
312 pub age_seconds: Option<u64>,
314 pub superseded_by: Option<String>,
316 pub reason: Option<String>,
318 pub quote: Option<Quote>,
320 pub retry_after_seconds: Option<u64>,
322 pub error: Option<ErrorCode>,
324}
325
326pub(crate) const BAD_REQUEST_CODES: [ErrorCode; 15] = [
327 ErrorCode::ParseUri,
328 ErrorCode::CanonName,
329 ErrorCode::CanonArgName,
330 ErrorCode::CanonArgDuplicate,
331 ErrorCode::CanonArgOrder,
332 ErrorCode::CanonPercent,
333 ErrorCode::CanonType,
334 ErrorCode::CanonNumber,
335 ErrorCode::CanonDate,
336 ErrorCode::CanonInstant,
337 ErrorCode::CanonString,
338 ErrorCode::CanonBlob,
339 ErrorCode::CanonLimit,
340 ErrorCode::ArgUnknown,
341 ErrorCode::ArgMissing,
342];
343
344pub(crate) fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
345 err(ErrorCode::Envelope, path, detail)
346}
347
348pub(crate) fn integer_member(v: &Value, path: &str) -> Result<u64> {
350 let Some(t) = v.as_number_text() else {
351 return env_err(path, "must be a non-negative integer");
352 };
353 let ok =
354 !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
355 match (ok, t.parse::<u64>()) {
356 (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
357 _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
358 }
359}
360
361pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
366 check_envelope_extras(status, body, keys, request).map(|(env, _)| env)
367}
368
369pub fn check_envelope_extras(
380 status: u16,
381 body: &[u8],
382 keys: &Keys,
383 request: &Request,
384) -> Result<(Envelope, EnvelopeExtras)> {
385 crate::answer::check_answer(status, body, keys, request)?.into_envelope()
386}
387
388pub fn check_envelope_any(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<AnyEnvelope> {
395 let a = crate::answer::check_answer(status, body, keys, request)?;
396 if let Some(p) = a.pending {
397 return Ok(AnyEnvelope::Pending(p));
398 }
399 a.into_envelope().map(|(e, x)| AnyEnvelope::Envelope(e, x))
400}
401
402pub(crate) fn is_job(s: &str) -> bool {
404 (32..=64).contains(&s.len()) && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
405}
406
407#[allow(clippy::large_enum_variant)]
411#[derive(Clone, Debug, PartialEq, Eq)]
412#[non_exhaustive]
413pub enum AnyEnvelope {
414 Envelope(Envelope, EnvelopeExtras),
417 Pending(Pending),
419}
420
421impl AnyEnvelope {
422 pub fn outcome_str(&self) -> &'static str {
424 match self {
425 AnyEnvelope::Envelope(e, _) => e.outcome.as_str(),
426 AnyEnvelope::Pending(_) => "pending",
427 }
428 }
429}
430
431#[derive(Clone, Debug, PartialEq, Eq, Hash)]
435#[non_exhaustive]
436pub struct Pending {
437 pub job: String,
439 pub estimate_seconds: u64,
441 pub poll: String,
444 pub request: Request,
446}
447
448impl Pending {
449 pub const STATUS: u16 = 202;
451
452 pub fn new(job: &str, estimate_seconds: u64, poll: &str, request: Request) -> Result<Pending> {
459 check_job_and_poll(job, poll)?;
460 if estimate_seconds > limits::BLOB_LENGTH {
461 return env_err("$.estimate_seconds", "estimate_seconds is at most 2^53 - 1");
462 }
463 let request = Request { job: None, mesh: None, ..request };
464 Ok(Pending { job: job.into(), estimate_seconds, poll: poll.into(), request })
465 }
466
467 pub fn to_value(&self) -> Value {
469 json::parse(&self.to_json()).unwrap_or(Value::Null)
471 }
472
473 pub fn to_json(&self) -> Vec<u8> {
475 let mut out = String::new();
476 out.push_str("{\"estimate_seconds\":");
477 out.push_str(&self.estimate_seconds.to_string());
478 out.push_str(",\"job\":");
479 json::write_string(&self.job, &mut out);
480 out.push_str(",\"outcome\":\"pending\",\"poll\":");
481 json::write_string(&self.poll, &mut out);
482 out.push_str(",\"request\":{");
483 if let Some(a) = &self.request.asof {
484 out.push_str("\"asof\":");
485 json::write_string(a, &mut out);
486 out.push(',');
487 }
488 out.push_str("\"cell_id\":");
489 json::write_string(&self.request.cell_id, &mut out);
490 if let Some(p) = &self.request.pin {
491 out.push_str(",\"fn_hash\":");
492 json::write_string(p, &mut out);
493 }
494 out.push_str("}}");
495 out.into_bytes()
496 }
497}
498
499pub(crate) fn check_job_and_poll(job: &str, poll: &str) -> Result<()> {
500 if !is_job(job) {
501 return env_err("$.job", "job is 32 to 64 lowercase hex digits");
502 }
503 let url_ok =
504 poll.strip_prefix("https://").is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
505 && poll.len() <= limits::LICENCE_URL;
506 if !url_ok {
507 return env_err("$.poll", "poll is an https URL of printable ASCII, at most 512 characters");
508 }
509 let host = poll["https://".len()..].split('/').next().unwrap_or("");
511 if !poll["https://".len()..].contains('/') || lexical::check_dns_name(host, "").is_err() {
512 return env_err("$.poll", "poll's host is a DNS name");
513 }
514 if !poll.ends_with(&format!("/jobs/{job}")) {
515 return env_err("$.poll", "poll ends /jobs/<job>");
516 }
517 Ok(())
518}
519
520pub(crate) fn quote(q: &Value) -> Result<Quote> {
521 let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
522 schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
523 let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
524 let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
525 let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
526 if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
527 return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
528 }
529 let url_ok = authorise
530 .strip_prefix("https://")
531 .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
532 && authorise.len() <= limits::LICENCE_URL;
533 if !url_ok {
534 return env_err("$.quote.authorise", "authorise is an https URL");
535 }
536 if !lexical::is_currency(currency) {
537 return env_err("$.quote.currency", "currency is a currency code from units.md");
538 }
539 Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
540}