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, Debug, Default, PartialEq, Eq, Hash)]
127#[non_exhaustive]
128pub struct EnvelopeExtras {
129 pub fault: Option<Fault>,
131 pub fn_hash: Option<String>,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq, Hash)]
138pub struct Quote {
139 pub amount: String,
141 pub currency: String,
143 pub authorise: String,
145}
146
147#[derive(Clone, Debug, PartialEq, Eq, Hash)]
149pub struct Request {
150 pub(crate) cell_id: String,
151 pub(crate) asof: Option<String>,
152 pub(crate) job: Option<String>,
153 pub(crate) pin: Option<String>,
154 pub(crate) mesh: Option<String>,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq, Hash)]
159pub struct InvalidRequest(String);
160
161impl core::fmt::Display for InvalidRequest {
162 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
163 write!(f, "invalid request: {}", self.0)
164 }
165}
166
167impl std::error::Error for InvalidRequest {}
168
169impl Request {
170 pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
174 if lexical::check_hex(cell_id, 64, "").is_err() {
175 return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
176 }
177 if let Some(a) = asof {
178 if !lexical::is_instant(a) {
179 return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
180 }
181 }
182 Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from), job: None, pin: None, mesh: None })
183 }
184
185 pub fn with_job(cell_id: &str, asof: Option<&str>, job: &str) -> core::result::Result<Request, InvalidRequest> {
191 if !is_job(job) {
192 return Err(InvalidRequest(format!("job must be 32 to 64 lowercase hex digits: {job:?}")));
193 }
194 let mut r = Request::new(cell_id, asof)?;
195 r.job = Some(job.into());
196 Ok(r)
197 }
198
199 pub fn with_pin(self, fn_hash: &str) -> core::result::Result<Request, InvalidRequest> {
203 if lexical::check_hex(fn_hash, 64, "").is_err() {
204 return Err(InvalidRequest(format!("fn_hash must be 64 lowercase hex digits: {fn_hash:?}")));
205 }
206 Ok(Request { pin: Some(fn_hash.into()), ..self })
207 }
208
209 pub fn with_mesh(self, host: &str) -> core::result::Result<Request, InvalidRequest> {
213 if lexical::check_dns_name(host, "").is_err() {
214 return Err(InvalidRequest(format!("mesh must be a DNS name: {host:?}")));
215 }
216 Ok(Request { mesh: Some(host.into()), ..self })
217 }
218
219 pub fn fn_hash(&self) -> Option<&str> {
221 self.pin.as_deref()
222 }
223
224 pub fn mesh(&self) -> Option<&str> {
226 self.mesh.as_deref()
227 }
228
229 pub fn job(&self) -> Option<&str> {
231 self.job.as_deref()
232 }
233
234 pub fn cell_id(&self) -> &str {
236 &self.cell_id
237 }
238
239 pub fn asof(&self) -> Option<&str> {
241 self.asof.as_deref()
242 }
243}
244
245#[derive(Clone, Debug, PartialEq, Eq)]
247pub struct Envelope {
248 pub outcome: Outcome,
250 pub receipt: Option<Receipt>,
252 pub trust: Option<Trust>,
254 pub age_seconds: Option<u64>,
256 pub superseded_by: Option<String>,
258 pub reason: Option<String>,
260 pub quote: Option<Quote>,
262 pub retry_after_seconds: Option<u64>,
264 pub error: Option<ErrorCode>,
266}
267
268pub(crate) const BAD_REQUEST_CODES: [ErrorCode; 15] = [
269 ErrorCode::ParseUri,
270 ErrorCode::CanonName,
271 ErrorCode::CanonArgName,
272 ErrorCode::CanonArgDuplicate,
273 ErrorCode::CanonArgOrder,
274 ErrorCode::CanonPercent,
275 ErrorCode::CanonType,
276 ErrorCode::CanonNumber,
277 ErrorCode::CanonDate,
278 ErrorCode::CanonInstant,
279 ErrorCode::CanonString,
280 ErrorCode::CanonBlob,
281 ErrorCode::CanonLimit,
282 ErrorCode::ArgUnknown,
283 ErrorCode::ArgMissing,
284];
285
286pub(crate) fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
287 err(ErrorCode::Envelope, path, detail)
288}
289
290pub(crate) fn integer_member(v: &Value, path: &str) -> Result<u64> {
292 let Some(t) = v.as_number_text() else {
293 return env_err(path, "must be a non-negative integer");
294 };
295 let ok =
296 !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
297 match (ok, t.parse::<u64>()) {
298 (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
299 _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
300 }
301}
302
303pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
308 check_envelope_extras(status, body, keys, request).map(|(env, _)| env)
309}
310
311pub fn check_envelope_extras(
321 status: u16,
322 body: &[u8],
323 keys: &Keys,
324 request: &Request,
325) -> Result<(Envelope, EnvelopeExtras)> {
326 crate::answer::check_answer(status, body, keys, request)?.into_envelope()
327}
328
329pub fn check_envelope_any(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<AnyEnvelope> {
336 let a = crate::answer::check_answer(status, body, keys, request)?;
337 if let Some(p) = a.pending {
338 return Ok(AnyEnvelope::Pending(p));
339 }
340 a.into_envelope().map(|(e, x)| AnyEnvelope::Envelope(e, x))
341}
342
343pub(crate) fn is_job(s: &str) -> bool {
345 (32..=64).contains(&s.len()) && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
346}
347
348#[allow(clippy::large_enum_variant)]
352#[derive(Clone, Debug, PartialEq, Eq)]
353#[non_exhaustive]
354pub enum AnyEnvelope {
355 Envelope(Envelope, EnvelopeExtras),
358 Pending(Pending),
360}
361
362impl AnyEnvelope {
363 pub fn outcome_str(&self) -> &'static str {
365 match self {
366 AnyEnvelope::Envelope(e, _) => e.outcome.as_str(),
367 AnyEnvelope::Pending(_) => "pending",
368 }
369 }
370}
371
372#[derive(Clone, Debug, PartialEq, Eq, Hash)]
376#[non_exhaustive]
377pub struct Pending {
378 pub job: String,
380 pub estimate_seconds: u64,
382 pub poll: String,
385 pub request: Request,
387}
388
389impl Pending {
390 pub const STATUS: u16 = 202;
392
393 pub fn new(job: &str, estimate_seconds: u64, poll: &str, request: Request) -> Result<Pending> {
400 check_job_and_poll(job, poll)?;
401 if estimate_seconds > limits::BLOB_LENGTH {
402 return env_err("$.estimate_seconds", "estimate_seconds is at most 2^53 - 1");
403 }
404 let request = Request { job: None, mesh: None, ..request };
405 Ok(Pending { job: job.into(), estimate_seconds, poll: poll.into(), request })
406 }
407
408 pub fn to_value(&self) -> Value {
410 json::parse(&self.to_json()).unwrap_or(Value::Null)
412 }
413
414 pub fn to_json(&self) -> Vec<u8> {
416 let mut out = String::new();
417 out.push_str("{\"estimate_seconds\":");
418 out.push_str(&self.estimate_seconds.to_string());
419 out.push_str(",\"job\":");
420 json::write_string(&self.job, &mut out);
421 out.push_str(",\"outcome\":\"pending\",\"poll\":");
422 json::write_string(&self.poll, &mut out);
423 out.push_str(",\"request\":{");
424 if let Some(a) = &self.request.asof {
425 out.push_str("\"asof\":");
426 json::write_string(a, &mut out);
427 out.push(',');
428 }
429 out.push_str("\"cell_id\":");
430 json::write_string(&self.request.cell_id, &mut out);
431 if let Some(p) = &self.request.pin {
432 out.push_str(",\"fn_hash\":");
433 json::write_string(p, &mut out);
434 }
435 out.push_str("}}");
436 out.into_bytes()
437 }
438}
439
440pub(crate) fn check_job_and_poll(job: &str, poll: &str) -> Result<()> {
441 if !is_job(job) {
442 return env_err("$.job", "job is 32 to 64 lowercase hex digits");
443 }
444 let url_ok =
445 poll.strip_prefix("https://").is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
446 && poll.len() <= limits::LICENCE_URL;
447 if !url_ok {
448 return env_err("$.poll", "poll is an https URL of printable ASCII, at most 512 characters");
449 }
450 let host = poll["https://".len()..].split('/').next().unwrap_or("");
452 if !poll["https://".len()..].contains('/') || lexical::check_dns_name(host, "").is_err() {
453 return env_err("$.poll", "poll's host is a DNS name");
454 }
455 if !poll.ends_with(&format!("/jobs/{job}")) {
456 return env_err("$.poll", "poll ends /jobs/<job>");
457 }
458 Ok(())
459}
460
461pub(crate) fn quote(q: &Value) -> Result<Quote> {
462 let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
463 schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
464 let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
465 let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
466 let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
467 if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
468 return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
469 }
470 let url_ok = authorise
471 .strip_prefix("https://")
472 .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
473 && authorise.len() <= limits::LICENCE_URL;
474 if !url_ok {
475 return env_err("$.quote.authorise", "authorise is an https URL");
476 }
477 if !lexical::is_currency(currency) {
478 return env_err("$.quote.currency", "currency is a currency code from units.md");
479 }
480 Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
481}