1use std::collections::{BTreeMap, BTreeSet};
12
13use serde_json::{Map, Value};
14
15use crate::canon::{canonical_bytes, commit_field, sha256_prefixed, CanonError};
16use crate::didkey;
17
18pub const SUPPORTED_SPECS: [&str; 4] = [
19 "continuity-receipt/0.1",
20 "continuity-receipt/0.2",
21 "continuity-receipt/0.3",
22 "continuity-receipt/0.4",
23];
24pub const RECORD_TYPES: [&str; 9] = [
25 "session.pass.created",
26 "task.decision",
27 "task.execution",
28 "delivery.attestation",
29 "task.termination",
30 "settlement",
31 "authority.succession",
32 "agreement.offer",
33 "agreement.accept",
34];
35
36const ANCHOR_TYPES: [&str; 3] = ["opentimestamps", "public-chain", "custom"];
37const PROVENANCE_PREFIXES: [&str; 2] = ["sha256:", "merkle-sha256:"];
38const BOUND_TYPES: [&str; 4] = [
40 "task.decision",
41 "task.execution",
42 "delivery.attestation",
43 "settlement",
44];
45
46const PASS_FIELDS: [&str; 7] = [
47 "gate_id",
48 "mandala_class",
49 "quotas",
50 "expires_at",
51 "policy_version",
52 "mandate_ref",
53 "agent_id",
54];
55const DECISION_FIELDS: [&str; 6] = [
56 "action",
57 "action_args_hash",
58 "model",
59 "input_provenance",
60 "decision",
61 "policy_version",
62];
63const EXECUTION_FIELDS: [&str; 4] = ["tool_calls", "egress", "resources", "sandbox_class"];
64const DELIVERY_FIELDS: [&str; 3] = ["request_hash", "response_hash", "counterparty"];
65const TERMINATION_FIELDS: [&str; 3] = ["reason", "limits_at_stop", "remaining"];
66const SETTLEMENT_FIELDS: [&str; 5] = [
67 "rail",
68 "rail_ref",
69 "amount",
70 "gated_on_delivery",
71 "settled_at",
72];
73const SUCCESSION_FIELDS: [&str; 4] = ["from_authority", "to_authority", "effective_at", "reason"];
74const OFFER_FIELDS: [&str; 5] = ["offer_id", "offeree", "terms_hash", "valid_until", "nonce"];
75const ACCEPT_FIELDS: [&str; 3] = ["offer_ref", "offer_id", "terms_hash"];
76const ACCEPT_FIELDS_04: [&str; 4] = ["offer_ref", "offer_id", "terms_hash", "offeree"];
78
79fn required_fields(record_type: &str, spec: Option<&str>) -> Option<&'static [&'static str]> {
80 if spec == Some("continuity-receipt/0.4") && record_type == "agreement.accept" {
81 return Some(&ACCEPT_FIELDS_04);
82 }
83 match record_type {
84 "session.pass.created" => Some(&PASS_FIELDS),
85 "task.decision" => Some(&DECISION_FIELDS),
86 "task.execution" => Some(&EXECUTION_FIELDS),
87 "delivery.attestation" => Some(&DELIVERY_FIELDS),
88 "task.termination" => Some(&TERMINATION_FIELDS),
89 "settlement" => Some(&SETTLEMENT_FIELDS),
90 "authority.succession" => Some(&SUCCESSION_FIELDS),
91 "agreement.offer" => Some(&OFFER_FIELDS),
92 "agreement.accept" => Some(&ACCEPT_FIELDS),
93 _ => None,
94 }
95}
96
97#[derive(Debug, Clone)]
99pub struct ErrorEntry {
100 pub code: String,
101 pub detail: String,
102 pub receipt_id: Value,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct VerifyResult {
108 pub errors: Vec<ErrorEntry>,
109 pub provisional_reasons: Vec<String>,
110 pub insufficient_reasons: Vec<String>,
111 pub summary: Map<String, Value>,
112}
113
114impl VerifyResult {
115 pub fn verdict(&self) -> &'static str {
117 if !self.errors.is_empty() {
118 "UNTRUSTED"
119 } else if !self.insufficient_reasons.is_empty() {
120 "INSUFFICIENT_EVIDENCE"
121 } else if !self.provisional_reasons.is_empty() {
122 "PROVISIONAL"
123 } else {
124 "TRUSTED"
125 }
126 }
127
128 pub fn codes(&self) -> Vec<&str> {
129 self.errors
130 .iter()
131 .map(|entry| entry.code.as_str())
132 .collect()
133 }
134
135 pub fn as_dict(&self) -> Value {
137 let mut map = Map::new();
138 map.insert(
139 "verdict".to_string(),
140 Value::String(self.verdict().to_string()),
141 );
142 map.insert("errors".to_string(), self.errors_value());
143 map.insert(
144 "provisional_reasons".to_string(),
145 Value::Array(
146 self.provisional_reasons
147 .iter()
148 .map(|reason| Value::String(reason.clone()))
149 .collect(),
150 ),
151 );
152 map.insert(
153 "insufficient_reasons".to_string(),
154 Value::Array(
155 self.insufficient_reasons
156 .iter()
157 .map(|reason| Value::String(reason.clone()))
158 .collect(),
159 ),
160 );
161 map.insert("summary".to_string(), Value::Object(self.summary.clone()));
162 Value::Object(map)
163 }
164
165 fn errors_value(&self) -> Value {
166 Value::Array(
167 self.errors
168 .iter()
169 .map(|entry| {
170 let mut item = Map::new();
171 item.insert("code".to_string(), Value::String(entry.code.clone()));
172 item.insert("detail".to_string(), Value::String(entry.detail.clone()));
173 item.insert("receipt_id".to_string(), entry.receipt_id.clone());
174 Value::Object(item)
175 })
176 .collect(),
177 )
178 }
179
180 pub fn malformed(detail: impl Into<String>) -> Self {
182 Self::coded("malformed", detail)
183 }
184
185 pub fn coded(code: &str, detail: impl Into<String>) -> Self {
187 let mut result = Self::default();
188 fatal(&mut result, code, detail.into(), None);
189 result
190 }
191}
192
193fn fatal(
194 result: &mut VerifyResult,
195 code: &str,
196 detail: impl Into<String>,
197 receipt_id: Option<&Value>,
198) {
199 result.errors.push(ErrorEntry {
200 code: code.to_string(),
201 detail: detail.into(),
202 receipt_id: receipt_id.cloned().unwrap_or(Value::Null),
203 });
204}
205
206fn spec_supported(value: Option<&Value>) -> bool {
207 value
208 .and_then(Value::as_str)
209 .map(|spec| SUPPORTED_SPECS.contains(&spec))
210 .unwrap_or(false)
211}
212
213fn depth_exceeded(value: &Value, limit: usize) -> bool {
216 let mut stack = vec![(value, 1usize)];
217 while let Some((current, depth)) = stack.pop() {
218 if depth > limit {
219 return true;
220 }
221 match current {
222 Value::Object(map) => stack.extend(map.values().map(|item| (item, depth + 1))),
223 Value::Array(items) => stack.extend(items.iter().map(|item| (item, depth + 1))),
224 _ => {}
225 }
226 }
227 false
228}
229
230fn check_shape(result: &mut VerifyResult, bundle_object: &Map<String, Value>, receipts: &[Value]) {
234 for (index, receipt) in receipts.iter().enumerate() {
235 let Some(record) = receipt.as_object() else {
236 fatal(
237 result,
238 "malformed",
239 format!("receipt {index} is not an object"),
240 None,
241 );
242 continue;
243 };
244 let receipt_id = record.get("receipt_id").filter(|value| value.is_string());
245 if !record.get("body").map(Value::is_object).unwrap_or(false) {
246 fatal(result, "malformed", "body is not an object", receipt_id);
247 }
248 if !record.get("issuer").map(Value::is_object).unwrap_or(false) {
249 fatal(result, "malformed", "issuer is not an object", receipt_id);
250 }
251 }
252 if let Some(anchors) = bundle_object.get("anchors") {
253 if !anchors.is_null() && !anchors.is_array() {
254 fatal(result, "malformed", "anchors is not a list", None);
255 } else if let Some(items) = anchors.as_array() {
256 for anchor in items {
257 if !anchor.is_object() {
258 fatal(result, "anchor_invalid", "anchor entry is not an object", None);
259 }
260 }
261 }
262 }
263 if let Some(revocations) = bundle_object.get("revocations") {
264 if !revocations.is_null() && !revocations.is_array() {
265 fatal(result, "bad_revocation", "revocations must be a list", None);
266 } else if let Some(items) = revocations.as_array() {
267 for statement in items {
268 if !statement.is_object() {
269 fatal(
270 result,
271 "bad_revocation",
272 "revocation statements must be objects",
273 None,
274 );
275 }
276 }
277 }
278 }
279 if let Some(disclosure) = bundle_object.get("disclosure_map") {
280 if !disclosure.is_null() && !disclosure.is_object() {
281 fatal(result, "malformed", "disclosure_map is not an object", None);
282 }
283 }
284}
285
286pub fn verify_bundle(bundle: &Value, require_anchor: bool) -> VerifyResult {
288 let mut result = VerifyResult::default();
289
290 let Some(bundle_object) = bundle.as_object() else {
291 fatal(&mut result, "malformed", "bundle is not an object", None);
292 return result;
293 };
294
295 const MAX_NESTING_DEPTH: usize = 64;
296 if depth_exceeded(bundle, MAX_NESTING_DEPTH) {
297 fatal(
298 &mut result,
299 "nesting_too_deep",
300 format!("bundle nesting exceeds depth {MAX_NESTING_DEPTH}"),
301 None,
302 );
303 return result;
304 }
305
306 if !spec_supported(bundle_object.get("spec")) {
307 fatal(
308 &mut result,
309 "version_unsupported",
310 format!("spec={}", py_repr(bundle_object.get("spec"))),
311 None,
312 );
313 return result;
314 }
315
316 let Some(receipts) = bundle_object
317 .get("receipts")
318 .and_then(Value::as_array)
319 .filter(|items| !items.is_empty())
320 else {
321 fatal(&mut result, "malformed", "bundle has no receipts", None);
322 return result;
323 };
324
325 const MAX_RECEIPTS: usize = 10_000;
326 if receipts.len() > MAX_RECEIPTS {
327 fatal(
328 &mut result,
329 "too_many_receipts",
330 format!("{} receipts exceeds limit {MAX_RECEIPTS}", receipts.len()),
331 None,
332 );
333 return result;
334 }
335
336 let task_id = bundle_object
338 .get("task_id")
339 .filter(|value| !value.is_null());
340 let mut expected_prev: Option<String> = None;
341 let mut type_by_seq: BTreeMap<usize, String> = BTreeMap::new();
342
343 check_shape(&mut result, bundle_object, receipts);
344
345 for (index, receipt) in receipts.iter().enumerate() {
346 let Some(record) = receipt.as_object() else {
347 continue;
348 };
349 let receipt_id = record.get("receipt_id");
350
351 if !spec_supported(record.get("spec")) {
352 fatal(
353 &mut result,
354 "version_unsupported",
355 format!("receipt spec={}", py_repr(record.get("spec"))),
356 receipt_id,
357 );
358 }
359 if record.get("task_id").filter(|value| !value.is_null()) != task_id {
360 fatal(
361 &mut result,
362 "task_mismatch",
363 "receipt task_id != bundle task_id",
364 receipt_id,
365 );
366 }
367
368 let record_type = record.get("type").and_then(Value::as_str);
369 let Some(record_type) = record_type.filter(|name| RECORD_TYPES.contains(name)) else {
370 fatal(
371 &mut result,
372 "unknown_type",
373 format!("type={}", py_repr(record.get("type"))),
374 receipt_id,
375 );
376 continue;
377 };
378 type_by_seq.insert(index, record_type.to_string());
379
380 let Some(body) = record.get("body").and_then(Value::as_object) else {
381 continue;
382 };
383 let missing: Vec<&str> =
384 required_fields(record_type, record.get("spec").and_then(Value::as_str))
385 .unwrap_or(&[])
386 .iter()
387 .copied()
388 .filter(|name| !body.contains_key(*name))
389 .collect();
390 if !missing.is_empty() {
391 let listed = missing
392 .iter()
393 .map(|name| quote_py_string(name))
394 .collect::<Vec<_>>()
395 .join(", ");
396 fatal(
397 &mut result,
398 "malformed",
399 format!("missing body fields [{listed}]"),
400 receipt_id,
401 );
402 }
403
404 if !validate_timestamp(record.get("issued_at")) {
405 fatal(
406 &mut result,
407 "malformed",
408 format!(
409 "issued_at not RFC 3339 UTC: {}",
410 py_repr(record.get("issued_at"))
411 ),
412 receipt_id,
413 );
414 }
415
416 if !seq_matches(record.get("seq"), index) {
417 fatal(
418 &mut result,
419 "chain_break",
420 format!("seq {} != position {index}", py_str(record.get("seq"))),
421 receipt_id,
422 );
423 }
424 let prev = record.get("prev").filter(|value| !value.is_null());
425 let prev_matches = match (prev, expected_prev.as_deref()) {
426 (None, None) => true,
427 (Some(Value::String(text)), Some(expected)) => text == expected,
428 _ => false,
429 };
430 if !prev_matches {
431 fatal(
432 &mut result,
433 "chain_break",
434 "prev digest mismatch",
435 receipt_id,
436 );
437 }
438
439 match receipt_digest(record) {
440 Ok(digest) => expected_prev = Some(digest),
441 Err(error) => {
442 fatal(
443 &mut result,
444 "malformed",
445 format!("canonicalization failed: {error}"),
446 receipt_id,
447 );
448 expected_prev = None;
449 continue;
450 }
451 }
452
453 let sig = record.get("sig").and_then(Value::as_object);
454 let sig_shape_ok = sig
455 .and_then(|value| value.get("alg"))
456 .and_then(Value::as_str)
457 == Some("ed25519")
458 && py_truthy(sig.and_then(|value| value.get("value")));
459 if !sig_shape_ok {
460 fatal(
461 &mut result,
462 "bad_signature",
463 "missing or unsupported sig",
464 receipt_id,
465 );
466 continue;
467 }
468 let issuer = record
469 .get("issuer")
470 .and_then(Value::as_object)
471 .and_then(|issuer| issuer.get("id"))
472 .and_then(Value::as_str)
473 .unwrap_or("");
474 let signature = sig
475 .and_then(|value| value.get("value"))
476 .and_then(Value::as_str);
477 match canonical_bytes(&Value::Object(unsigned_view(record))) {
478 Ok(message) => {
479 let verified = signature
480 .map(|value| didkey::verify(issuer, &message, value))
481 .unwrap_or(false);
482 if !verified {
483 fatal(
484 &mut result,
485 "bad_signature",
486 "signature does not verify",
487 receipt_id,
488 );
489 }
490 }
491 Err(error) => fatal(
492 &mut result,
493 "malformed",
494 format!("canonicalization failed: {error}"),
495 receipt_id,
496 ),
497 }
498 }
499
500 check_cross_record(&mut result, receipts, &type_by_seq);
501 check_agreements(&mut result, receipts);
502 check_redactions(
503 &mut result,
504 receipts,
505 bundle_object
506 .get("disclosure_map")
507 .and_then(Value::as_object),
508 );
509 check_attestations(&mut result, receipts);
510 check_provenance(&mut result, receipts);
511 check_revocations(&mut result, bundle_object, receipts);
512 check_anchors(&mut result, bundle_object, receipts, require_anchor);
513
514 let summary = build_summary(receipts, &type_by_seq);
515 let extras = std::mem::take(&mut result.summary);
516 result.summary = summary;
517 for (key, value) in extras {
518 result.summary.insert(key, value);
519 }
520 result
521}
522
523fn check_cross_record(
524 result: &mut VerifyResult,
525 receipts: &[Value],
526 type_by_seq: &BTreeMap<usize, String>,
527) {
528 let pass_receipts: Vec<&Map<String, Value>> = receipts
529 .iter()
530 .filter_map(Value::as_object)
531 .filter(|record| record.get("type").and_then(Value::as_str) == Some("session.pass.created"))
532 .collect();
533 if pass_receipts.is_empty() {
534 fatal(
535 result,
536 "malformed",
537 "chain has no session.pass.created receipt",
538 None,
539 );
540 return;
541 }
542 let Some(pass_body) = pass_receipts[0].get("body").and_then(Value::as_object) else {
543 return;
544 };
545 let policy_version = pass_body.get("policy_version");
546
547 for receipt in receipts.iter().filter_map(Value::as_object) {
548 if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
549 continue;
550 }
551 let Some(decision_body) = receipt.get("body").and_then(Value::as_object) else {
552 continue;
553 };
554 let decision_policy = decision_body.get("policy_version");
555 if decision_policy != policy_version {
556 fatal(
557 result,
558 "policy_mismatch",
559 format!(
560 "decision policy {} != pass policy {}",
561 py_repr(decision_policy),
562 py_repr(policy_version)
563 ),
564 receipt.get("receipt_id"),
565 );
566 }
567 }
568
569 let mut spend_cap = pass_body.get("spend_cap").filter(|value| !value.is_null());
570 if spend_cap.is_some() && !spend_cap.map(Value::is_object).unwrap_or(false) {
571 fatal(
572 result,
573 "malformed",
574 "pass spend_cap is not an object",
575 pass_receipts[0].get("receipt_id"),
576 );
577 spend_cap = None;
578 }
579 let settlement_indexes: Vec<usize> = type_by_seq
580 .iter()
581 .filter(|(_, record_type)| record_type.as_str() == "settlement")
582 .map(|(index, _)| *index)
583 .collect();
584 let delivery_indexes: Vec<usize> = type_by_seq
585 .iter()
586 .filter(|(_, record_type)| record_type.as_str() == "delivery.attestation")
587 .map(|(index, _)| *index)
588 .collect();
589
590 for index in settlement_indexes {
591 let Some(settlement) = receipts.get(index).and_then(Value::as_object) else {
592 continue;
593 };
594 let Some(body) = settlement.get("body").and_then(Value::as_object) else {
595 continue;
596 };
597 let amount = body.get("amount");
598 if amount.is_some() && !amount.map(Value::is_object).unwrap_or(false) {
599 fatal(
600 result,
601 "malformed",
602 "settlement amount is not an object",
603 settlement.get("receipt_id"),
604 );
605 continue;
606 }
607 if let Some(cap) = spend_cap {
608 let cap_object = cap.as_object();
609 let amount_object = amount.and_then(Value::as_object);
610 let currency_mismatch = amount_object.and_then(|item| item.get("currency"))
611 != cap_object.and_then(|item| item.get("currency"));
612 let exceeded = if currency_mismatch {
613 true
614 } else {
615 match (minor_units(amount_object), minor_units(cap_object)) {
616 (Some(settled), Some(limit)) => settled > limit,
617 _ => {
618 fatal(
619 result,
620 "malformed",
621 "settlement amount is not numeric",
622 settlement.get("receipt_id"),
623 );
624 false
625 }
626 }
627 };
628 if exceeded {
629 let amount_repr = amount
630 .map(py_repr_value)
631 .unwrap_or_else(|| "{}".to_string());
632 fatal(
633 result,
634 "cap_exceeded",
635 format!(
636 "settlement {amount_repr} exceeds cap {}",
637 py_repr(Some(cap))
638 ),
639 settlement.get("receipt_id"),
640 );
641 }
642 }
643 if py_truthy(body.get("gated_on_delivery")) {
644 let gated_late =
645 delivery_indexes.is_empty() || delivery_indexes.iter().min().copied() > Some(index);
646 if gated_late {
647 fatal(
648 result,
649 "delivery_before_settlement",
650 "gated settlement recorded before any delivery attestation",
651 settlement.get("receipt_id"),
652 );
653 }
654 }
655 }
656
657 if !type_by_seq
658 .values()
659 .any(|record_type| record_type == "task.termination")
660 {
661 fatal(
662 result,
663 "missing_termination",
664 "task has no termination receipt",
665 None,
666 );
667 }
668}
669
670fn check_agreements(result: &mut VerifyResult, receipts: &[Value]) {
678 let mut offers: BTreeMap<String, &Map<String, Value>> = BTreeMap::new();
679 let mut accepts: BTreeMap<String, &Map<String, Value>> = BTreeMap::new();
680 for receipt in receipts.iter().filter_map(Value::as_object) {
681 match receipt.get("type").and_then(Value::as_str) {
682 Some("agreement.offer") => {
683 if let Ok(digest) = receipt_digest(receipt) {
684 offers.insert(digest, receipt);
685 }
686 }
687 Some("agreement.accept") => {
688 if let Ok(digest) = receipt_digest(receipt) {
689 accepts.insert(digest, receipt);
690 }
691 }
692 _ => {}
693 }
694 }
695
696 for accept in accepts.values() {
697 check_accept(result, accept, &offers);
698 }
699
700 let mut referenced: BTreeSet<String> = BTreeSet::new();
701 for receipt in receipts.iter().filter_map(Value::as_object) {
702 let Some(record_type) = receipt.get("type").and_then(Value::as_str) else {
703 continue;
704 };
705 if !BOUND_TYPES.contains(&record_type) {
706 continue;
707 }
708 if receipt.get("spec").and_then(Value::as_str) != Some("continuity-receipt/0.4") {
709 continue;
710 }
711 let Some(body) = receipt.get("body").and_then(Value::as_object) else {
712 continue;
713 };
714 let Some(reference) = body.get("agreement_ref").filter(|value| !value.is_null()) else {
715 continue;
716 };
717 let accept = reference
718 .as_str()
719 .and_then(|reference| accepts.get(reference));
720 let Some(accept) = accept else {
721 result.insufficient_reasons.push(format!(
722 "missing_agreement:{}",
723 receipt
724 .get("receipt_id")
725 .and_then(Value::as_str)
726 .unwrap_or("")
727 ));
728 continue;
729 };
730 referenced.insert(reference.as_str().unwrap_or("").to_string());
731 let Some(accept_body) = accept.get("body").and_then(Value::as_object) else {
732 continue;
733 };
734 let accept_issued = accept
735 .get("issued_at")
736 .and_then(Value::as_str)
737 .and_then(parse_timestamp);
738 let bound_issued = receipt
739 .get("issued_at")
740 .and_then(Value::as_str)
741 .and_then(parse_timestamp);
742 if let (Some(accept_issued), Some(bound_issued)) = (accept_issued, bound_issued) {
743 if bound_issued < accept_issued {
744 fatal(
745 result,
746 "agreement_before_accept",
747 format!(
748 "bound receipt issued before its accept {}",
749 py_repr(accept.get("receipt_id"))
750 ),
751 receipt.get("receipt_id"),
752 );
753 }
754 }
755 let issuer_id = receipt
756 .get("issuer")
757 .and_then(Value::as_object)
758 .and_then(|issuer| issuer.get("id"))
759 .and_then(Value::as_str);
760 if let Some(offeree) = accept_body.get("offeree").and_then(Value::as_str) {
761 if issuer_id != Some(offeree) {
762 fatal(
763 result,
764 "agreement_issuer_mismatch",
765 format!("bound receipt issuer {issuer_id:?} is not the offeree"),
766 receipt.get("receipt_id"),
767 );
768 }
769 }
770 }
771
772 check_agreement_completeness(result, receipts, &accepts, &referenced);
773}
774
775fn check_accept(
777 result: &mut VerifyResult,
778 receipt: &Map<String, Value>,
779 offers: &BTreeMap<String, &Map<String, Value>>,
780) {
781 let Some(body) = receipt.get("body").and_then(Value::as_object) else {
782 return;
783 };
784 let offer = body
785 .get("offer_ref")
786 .and_then(Value::as_str)
787 .and_then(|reference| offers.get(reference));
788 let Some(offer) = offer else {
789 result.insufficient_reasons.push(format!(
790 "missing_offer:{}",
791 receipt
792 .get("receipt_id")
793 .and_then(Value::as_str)
794 .unwrap_or("")
795 ));
796 return;
797 };
798 let Some(offer_body) = offer.get("body").and_then(Value::as_object) else {
799 return;
800 };
801 let valid_until = offer_body.get("valid_until");
802 if !validate_timestamp(valid_until) {
803 fatal(
804 result,
805 "malformed",
806 format!(
807 "offer valid_until not RFC 3339 UTC: {}",
808 py_repr(valid_until)
809 ),
810 offer.get("receipt_id"),
811 );
812 return;
813 }
814 let same_offer_id = body.get("offer_id") == offer_body.get("offer_id");
815 let same_terms = body.get("terms_hash") == offer_body.get("terms_hash");
816 if !same_offer_id || !same_terms {
817 fatal(
818 result,
819 "offer_mismatch",
820 "accept does not match the referenced offer",
821 receipt.get("receipt_id"),
822 );
823 return;
824 }
825 let accept_issued = receipt
826 .get("issued_at")
827 .and_then(Value::as_str)
828 .and_then(parse_timestamp);
829 let valid_until_at = valid_until.and_then(Value::as_str).and_then(parse_timestamp);
830 if let (Some(accept_issued), Some(valid_until_at)) = (accept_issued, valid_until_at) {
831 if accept_issued > valid_until_at {
832 fatal(
833 result,
834 "offer_expired",
835 format!(
836 "accept issued after offer valid_until {}",
837 py_repr(offer_body.get("valid_until"))
838 ),
839 receipt.get("receipt_id"),
840 );
841 }
842 }
843 if receipt.get("spec").and_then(Value::as_str) != Some("continuity-receipt/0.4") {
844 return;
845 }
846 let offeree = body.get("offeree").and_then(Value::as_str);
847 let issuer_id = receipt
848 .get("issuer")
849 .and_then(Value::as_object)
850 .and_then(|issuer| issuer.get("id"))
851 .and_then(Value::as_str);
852 match offeree {
853 None | Some("") => fatal(
854 result,
855 "malformed",
856 format!("accept offeree is not a string: {}", py_repr(body.get("offeree"))),
857 receipt.get("receipt_id"),
858 ),
859 Some(offeree) => {
860 if issuer_id != Some(offeree)
861 || offer_body.get("offeree").and_then(Value::as_str) != Some(offeree)
862 {
863 fatal(
864 result,
865 "offeree_mismatch",
866 format!(
867 "accept offeree {offeree:?} does not match the signer {issuer_id:?} / offer"
868 ),
869 receipt.get("receipt_id"),
870 );
871 }
872 }
873 }
874 let offer_issued = offer
875 .get("issued_at")
876 .and_then(Value::as_str)
877 .and_then(parse_timestamp);
878 if let (Some(accept_issued), Some(offer_issued)) = (accept_issued, offer_issued) {
879 if accept_issued < offer_issued {
880 fatal(
881 result,
882 "accept_before_offer",
883 format!("accept issued before offer {}", py_repr(offer.get("receipt_id"))),
884 receipt.get("receipt_id"),
885 );
886 }
887 }
888}
889
890fn check_agreement_completeness(
892 result: &mut VerifyResult,
893 receipts: &[Value],
894 accepts: &BTreeMap<String, &Map<String, Value>>,
895 referenced: &BTreeSet<String>,
896) {
897 for (digest, accept) in accepts {
898 if accept.get("spec").and_then(Value::as_str) == Some("continuity-receipt/0.4")
899 && !referenced.contains(digest)
900 {
901 result.provisional_reasons.push(format!(
902 "agreement_unreferenced:{}",
903 accept
904 .get("receipt_id")
905 .and_then(Value::as_str)
906 .unwrap_or("")
907 ));
908 }
909 }
910 for receipt in receipts.iter().filter_map(Value::as_object) {
911 if receipt.get("spec").and_then(Value::as_str) != Some("continuity-receipt/0.4") {
912 continue;
913 }
914 let Some(record_type) = receipt.get("type").and_then(Value::as_str) else {
915 continue;
916 };
917 if !BOUND_TYPES.contains(&record_type) {
918 continue;
919 }
920 let Some(body) = receipt.get("body").and_then(Value::as_object) else {
921 continue;
922 };
923 let has_ref = body
924 .get("agreement_ref")
925 .map(|value| !value.is_null())
926 .unwrap_or(false);
927 if has_ref {
928 continue;
929 }
930 let issuer_id = receipt
931 .get("issuer")
932 .and_then(Value::as_object)
933 .and_then(|issuer| issuer.get("id"))
934 .and_then(Value::as_str);
935 let bound_issued = receipt
936 .get("issued_at")
937 .and_then(Value::as_str)
938 .and_then(parse_timestamp);
939 let (Some(issuer_id), Some(bound_at)) = (issuer_id, bound_issued) else {
940 continue;
941 };
942 for accept in accepts.values() {
943 if accept.get("spec").and_then(Value::as_str) != Some("continuity-receipt/0.4") {
944 continue;
945 }
946 let Some(accept_body) = accept.get("body").and_then(Value::as_object) else {
947 continue;
948 };
949 if accept_body.get("offeree").and_then(Value::as_str) != Some(issuer_id) {
950 continue;
951 }
952 let accept_issued = accept
953 .get("issued_at")
954 .and_then(Value::as_str)
955 .and_then(parse_timestamp);
956 let Some(accept_at) = accept_issued else {
957 continue;
958 };
959 if accept_at <= bound_at {
960 result.provisional_reasons.push(format!(
961 "missing_agreement_ref:{}",
962 receipt
963 .get("receipt_id")
964 .and_then(Value::as_str)
965 .unwrap_or("")
966 ));
967 break;
968 }
969 }
970 }
971}
972
973fn check_redactions(
974 result: &mut VerifyResult,
975 receipts: &[Value],
976 disclosure_map: Option<&Map<String, Value>>,
977) {
978 let root = Value::Array(receipts.to_vec());
979 let mut redactions: Vec<(String, &Map<String, Value>)> = Vec::new();
980 iter_redactions(&root, "receipts", &mut redactions);
981
982 for (path, field) in redactions {
983 if required_field_for_path(&path, receipts).is_some() {
984 fatal(
985 result,
986 "redacted_required",
987 format!("required field redacted at {path}"),
988 None,
989 );
990 continue;
991 }
992 if let Some(entry) = disclosure_map
993 .and_then(|map| map.get(path.as_str()))
994 .and_then(Value::as_object)
995 {
996 if !entry.is_empty() && entry.contains_key("salt") && entry.contains_key("value") {
997 let computed = match (
998 entry.get("salt").and_then(Value::as_str),
999 entry.get("value"),
1000 ) {
1001 (Some(salt), Some(value)) => commit_field(salt, value).ok(),
1002 _ => None,
1003 };
1004 if computed.as_deref() != field.get("commit").and_then(Value::as_str) {
1005 fatal(
1006 result,
1007 "commit_mismatch",
1008 format!("commit mismatch at {path}"),
1009 None,
1010 );
1011 }
1012 continue;
1013 }
1014 }
1015 if py_truthy(field.get("erased")) {
1016 result
1017 .insufficient_reasons
1018 .push(format!("erased_content:{path}"));
1019 } else {
1020 result
1021 .provisional_reasons
1022 .push(format!("redacted_without_disclosure:{path}"));
1023 }
1024 }
1025}
1026
1027fn iter_redactions<'a>(
1030 node: &'a Value,
1031 path: &str,
1032 out: &mut Vec<(String, &'a Map<String, Value>)>,
1033) {
1034 match node {
1035 Value::Object(map) => {
1036 if map.get("redacted") == Some(&Value::Bool(true)) {
1037 out.push((path.to_string(), map));
1038 return;
1039 }
1040 for (key, value) in map {
1041 let child = if path.is_empty() {
1042 key.clone()
1043 } else {
1044 format!("{path}.{key}")
1045 };
1046 iter_redactions(value, &child, out);
1047 }
1048 }
1049 Value::Array(items) => {
1050 for (index, value) in items.iter().enumerate() {
1051 iter_redactions(value, &format!("{path}[{index}]"), out);
1052 }
1053 }
1054 _ => {}
1055 }
1056}
1057
1058pub(crate) fn required_field_for_path(path: &str, receipts: &[Value]) -> Option<String> {
1060 let parts: Vec<&str> = path.split('.').collect();
1061 if parts.len() < 3 || !parts[0].starts_with("receipts[") || parts[1] != "body" {
1062 return None;
1063 }
1064 let index_text = parts[0].strip_prefix("receipts[")?.trim_end_matches(']');
1065 let index: usize = index_text.parse().ok()?;
1066 let receipt = receipts.get(index).and_then(Value::as_object)?;
1067 let record_type = receipt.get("type").and_then(Value::as_str)?;
1068 required_fields(record_type, receipt.get("spec").and_then(Value::as_str))
1069 .filter(|fields| fields.contains(&parts[2]))
1070 .map(|_| parts[2].to_string())
1071}
1072
1073fn check_attestations(result: &mut VerifyResult, receipts: &[Value]) {
1076 let mut seen: Vec<Value> = Vec::new();
1077 for receipt in receipts.iter().filter_map(Value::as_object) {
1078 if receipt.get("type").and_then(Value::as_str) != Some("delivery.attestation") {
1079 continue;
1080 }
1081 let body = receipt.get("body").and_then(Value::as_object);
1082 let counterparty = body
1083 .and_then(|body| body.get("counterparty"))
1084 .and_then(Value::as_object);
1085 let counterparty_id = counterparty.and_then(|item| item.get("id"));
1086 let attestation = counterparty.and_then(|item| item.get("attestation"));
1087
1088 if !py_truthy(attestation) {
1089 let mut entry = Map::new();
1090 entry.insert(
1091 "receipt_id".to_string(),
1092 receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
1093 );
1094 entry.insert(
1095 "counterparty".to_string(),
1096 counterparty_id.cloned().unwrap_or(Value::Null),
1097 );
1098 entry.insert(
1099 "attestation".to_string(),
1100 Value::String("absent".to_string()),
1101 );
1102 seen.push(Value::Object(entry));
1103 continue;
1104 }
1105
1106 let attestation_object = attestation.and_then(Value::as_object);
1107 let algorithm_ok = attestation_object
1108 .and_then(|item| item.get("alg"))
1109 .and_then(Value::as_str)
1110 == Some("ed25519");
1111 let key = attestation_object
1112 .and_then(|item| item.get("key"))
1113 .and_then(Value::as_str);
1114 let value = attestation_object
1115 .and_then(|item| item.get("value"))
1116 .and_then(Value::as_str);
1117 let valid = match (body, key, value) {
1118 (Some(body), Some(key), Some(value)) if algorithm_ok => {
1119 let message = Value::Object(attestation_view(body));
1120 canonical_bytes(&message)
1121 .map(|bytes| didkey::verify(key, &bytes, value))
1122 .unwrap_or(false)
1123 }
1124 _ => false,
1125 };
1126
1127 let mut entry = Map::new();
1128 entry.insert(
1129 "receipt_id".to_string(),
1130 receipt.get("receipt_id").cloned().unwrap_or(Value::Null),
1131 );
1132 entry.insert(
1133 "counterparty".to_string(),
1134 counterparty_id.cloned().unwrap_or(Value::Null),
1135 );
1136 entry.insert(
1137 "attestation".to_string(),
1138 Value::String(if valid { "valid" } else { "invalid" }.to_string()),
1139 );
1140 entry.insert(
1141 "key".to_string(),
1142 attestation_object
1143 .and_then(|item| item.get("key"))
1144 .cloned()
1145 .unwrap_or(Value::Null),
1146 );
1147 seen.push(Value::Object(entry));
1148
1149 if !valid {
1150 fatal(
1151 result,
1152 "bad_attestation",
1153 "counterparty attestation does not verify",
1154 receipt.get("receipt_id"),
1155 );
1156 }
1157 }
1158 if !seen.is_empty() {
1159 result
1160 .summary
1161 .insert("attestations".to_string(), Value::Array(seen));
1162 }
1163}
1164
1165fn attestation_view(body: &Map<String, Value>) -> Map<String, Value> {
1168 let mut view = body.clone();
1169 if let Some(counterparty) = view.get("counterparty").and_then(Value::as_object).cloned() {
1170 let mut counterparty = counterparty;
1171 counterparty.remove("attestation");
1172 view.insert("counterparty".to_string(), Value::Object(counterparty));
1173 }
1174 view
1175}
1176
1177fn check_provenance(result: &mut VerifyResult, receipts: &[Value]) {
1179 for receipt in receipts.iter().filter_map(Value::as_object) {
1180 if receipt.get("type").and_then(Value::as_str) != Some("task.decision") {
1181 continue;
1182 }
1183 let Some(provenance) = receipt
1184 .get("body")
1185 .and_then(Value::as_object)
1186 .and_then(|body| body.get("input_provenance"))
1187 .and_then(Value::as_object)
1188 else {
1189 continue;
1190 };
1191 let Some(observed) = provenance
1192 .get("observed_sources_hash")
1193 .filter(|value| !value.is_null())
1194 else {
1195 continue;
1196 };
1197 let supported = observed
1198 .as_str()
1199 .map(|text| {
1200 PROVENANCE_PREFIXES
1201 .iter()
1202 .any(|prefix| text.starts_with(*prefix))
1203 })
1204 .unwrap_or(false);
1205 if !supported {
1206 fatal(
1207 result,
1208 "provenance_invalid",
1209 format!(
1210 "observed_sources_hash has unsupported form: {}",
1211 py_repr(Some(observed))
1212 ),
1213 receipt.get("receipt_id"),
1214 );
1215 }
1216 }
1217}
1218
1219fn check_revocations(result: &mut VerifyResult, bundle: &Map<String, Value>, receipts: &[Value]) {
1225 let raw = bundle.get("revocations");
1226 if !py_truthy(raw) {
1227 return;
1228 }
1229 let Some(statements) = raw.and_then(Value::as_array) else {
1230 return;
1231 };
1232 if statements.iter().any(|statement| !statement.is_object()) {
1233 return;
1234 }
1235
1236 let (revoked, statement_errors) = verify_revocation_statements(statements);
1237 result.errors.extend(statement_errors);
1238 let checked = revoked.len();
1239
1240 for receipt in receipts.iter().filter_map(Value::as_object) {
1241 let key_id = receipt
1242 .get("issuer")
1243 .and_then(Value::as_object)
1244 .and_then(|issuer| issuer.get("id"))
1245 .and_then(Value::as_str);
1246 let Some(key_id) = key_id else {
1247 continue;
1248 };
1249 let issued_at = receipt.get("issued_at");
1250 if !validate_timestamp(issued_at) {
1251 continue;
1252 }
1253 let Some(issued_timestamp) = issued_at.and_then(Value::as_str).and_then(parse_timestamp)
1254 else {
1255 continue;
1256 };
1257 for (revoked_key, revoked_at) in &revoked {
1258 if revoked_key == key_id && issued_timestamp >= *revoked_at {
1259 fatal(
1260 result,
1261 "key_revoked",
1262 format!(
1263 "issuer key {key_id} was revoked at {}",
1264 isoformat(revoked_at)
1265 ),
1266 receipt.get("receipt_id"),
1267 );
1268 }
1269 }
1270 }
1271 result
1272 .summary
1273 .insert("revocations_checked".to_string(), Value::from(checked));
1274}
1275
1276pub(crate) fn verify_revocation_statements(
1283 statements: &[Value],
1284) -> (Vec<(String, Timestamp)>, Vec<ErrorEntry>) {
1285 let mut revoked: Vec<(String, Timestamp)> = Vec::new();
1286 let mut errors: Vec<ErrorEntry> = Vec::new();
1287 for statement in statements {
1288 let Some(statement) = statement.as_object() else {
1289 errors.push(ErrorEntry {
1290 code: "bad_revocation".to_string(),
1291 detail: "revocation statement is not an object".to_string(),
1292 receipt_id: Value::Null,
1293 });
1294 continue;
1295 };
1296 let key_id = statement.get("key").and_then(Value::as_str);
1297 let revoked_at = statement.get("revoked_at");
1298 if key_id.is_none() || !validate_timestamp(revoked_at) {
1299 errors.push(ErrorEntry {
1300 code: "bad_revocation".to_string(),
1301 detail: format!(
1302 "malformed revocation statement for {}",
1303 py_repr(statement.get("key"))
1304 ),
1305 receipt_id: Value::Null,
1306 });
1307 continue;
1308 }
1309 let key_id = key_id.unwrap_or_default();
1310 let revoked_at_text = revoked_at.and_then(Value::as_str).unwrap_or_default();
1311
1312 let sig = statement.get("sig").and_then(Value::as_object);
1313 let signature_ok = sig.and_then(|item| item.get("alg")).and_then(Value::as_str)
1314 == Some("ed25519")
1315 && py_truthy(sig.and_then(|item| item.get("value")));
1316 if !signature_ok {
1317 errors.push(ErrorEntry {
1318 code: "bad_revocation".to_string(),
1319 detail: format!(
1320 "revocation statement unsigned for {}",
1321 py_repr(statement.get("key"))
1322 ),
1323 receipt_id: Value::Null,
1324 });
1325 continue;
1326 }
1327
1328 let mut unsigned = statement.clone();
1329 unsigned.remove("sig");
1330 let verified = match canonical_bytes(&Value::Object(unsigned)) {
1331 Ok(message) => sig
1332 .and_then(|item| item.get("value"))
1333 .and_then(Value::as_str)
1334 .map(|value| didkey::verify(key_id, &message, value))
1335 .unwrap_or(false),
1336 Err(_) => false,
1337 };
1338 if !verified {
1339 errors.push(ErrorEntry {
1340 code: "bad_revocation".to_string(),
1341 detail: format!(
1342 "revocation signature invalid for {}",
1343 py_repr(statement.get("key"))
1344 ),
1345 receipt_id: Value::Null,
1346 });
1347 continue;
1348 }
1349 let Some(revoked_timestamp) = parse_timestamp(revoked_at_text) else {
1350 continue; };
1352 revoked.push((key_id.to_string(), revoked_timestamp));
1353 }
1354 (revoked, errors)
1355}
1356
1357fn check_anchors(
1359 result: &mut VerifyResult,
1360 bundle: &Map<String, Value>,
1361 receipts: &[Value],
1362 require_anchor: bool,
1363) {
1364 let anchors = bundle.get("anchors");
1365 if !py_truthy(anchors) {
1366 if require_anchor {
1367 result
1368 .provisional_reasons
1369 .push("anchor_missing".to_string());
1370 }
1371 return;
1372 }
1373 let Some(anchor_list) = anchors.and_then(Value::as_array) else {
1374 return;
1375 };
1376
1377 let mut kinds: Vec<Value> = Vec::new();
1378 for anchor in anchor_list {
1379 let Some(anchor_object) = anchor.as_object() else {
1380 continue;
1381 };
1382 let target = anchor_object.get("target");
1383 let target_receipt = find_receipt_by_id(receipts, target);
1384 let bound = match (
1385 target_receipt,
1386 anchor_object
1387 .get("hash")
1388 .and_then(Value::as_str),
1389 ) {
1390 (Some(receipt), Some(hash)) => receipt_digest(receipt)
1391 .map(|digest| digest == hash)
1392 .unwrap_or(false),
1393 _ => false,
1394 };
1395 if !bound {
1396 fatal(
1397 result,
1398 "anchor_invalid",
1399 format!("anchor invalid for {}", py_str(target)),
1400 None,
1401 );
1402 continue;
1403 }
1404 let meta = anchor_object
1405 .get("anchor")
1406 .filter(|value| !value.is_null());
1407 if let Some(meta) = meta {
1408 let anchor_type = meta.as_object().and_then(|item| item.get("type"));
1409 match anchor_type
1410 .and_then(Value::as_str)
1411 .filter(|name| ANCHOR_TYPES.contains(name))
1412 {
1413 Some(name) => kinds.push(Value::String(name.to_string())),
1414 None => {
1415 let rendered = if meta.is_object() {
1416 py_repr(anchor_type)
1417 } else {
1418 py_repr(Some(meta))
1419 };
1420 fatal(
1421 result,
1422 "anchor_invalid",
1423 format!("unknown anchor type: {rendered}"),
1424 None,
1425 );
1426 }
1427 }
1428 }
1429 }
1430 let summary_kinds = if kinds.is_empty() {
1431 vec![Value::String("hash-only".to_string())]
1432 } else {
1433 kinds
1434 };
1435 result
1436 .summary
1437 .insert("anchors".to_string(), Value::Array(summary_kinds));
1438}
1439
1440fn find_receipt_by_id<'a>(
1442 receipts: &'a [Value],
1443 target: Option<&Value>,
1444) -> Option<&'a Map<String, Value>> {
1445 let target = target.filter(|value| !value.is_null());
1446 let mut found = None;
1447 for receipt in receipts.iter().filter_map(Value::as_object) {
1448 let receipt_id = receipt.get("receipt_id").filter(|value| !value.is_null());
1449 if receipt_id == target {
1450 found = Some(receipt);
1451 }
1452 }
1453 found
1454}
1455
1456fn build_summary(receipts: &[Value], type_by_seq: &BTreeMap<usize, String>) -> Map<String, Value> {
1457 let mut summary = Map::new();
1458 summary.insert("receipts".to_string(), Value::from(receipts.len()));
1459 summary.insert(
1460 "types".to_string(),
1461 Value::Array(
1462 receipts
1463 .iter()
1464 .filter_map(Value::as_object)
1465 .map(|record| record.get("type").cloned().unwrap_or(Value::Null))
1466 .collect(),
1467 ),
1468 );
1469 let mut issuers: BTreeSet<String> = BTreeSet::new();
1470 for record in receipts.iter().filter_map(Value::as_object) {
1471 let id = record
1472 .get("issuer")
1473 .and_then(Value::as_object)
1474 .and_then(|issuer| issuer.get("id"))
1475 .and_then(Value::as_str)
1476 .unwrap_or("");
1477 issuers.insert(id.to_string());
1478 }
1479 summary.insert(
1480 "issuers".to_string(),
1481 Value::Array(issuers.into_iter().map(Value::String).collect()),
1482 );
1483 summary.insert(
1484 "terminated".to_string(),
1485 Value::Bool(
1486 type_by_seq
1487 .values()
1488 .any(|record_type| record_type == "task.termination"),
1489 ),
1490 );
1491 summary.insert(
1492 "settled".to_string(),
1493 Value::Bool(
1494 type_by_seq
1495 .values()
1496 .any(|record_type| record_type == "settlement"),
1497 ),
1498 );
1499 summary
1500}
1501
1502pub(crate) fn unsigned_view(receipt: &Map<String, Value>) -> Map<String, Value> {
1503 let mut view = receipt.clone();
1504 view.remove("sig");
1505 view
1506}
1507
1508pub(crate) fn receipt_digest(receipt: &Map<String, Value>) -> Result<String, CanonError> {
1509 canonical_bytes(&Value::Object(unsigned_view(receipt))).map(|bytes| sha256_prefixed(&bytes))
1510}
1511
1512fn seq_matches(value: Option<&Value>, index: usize) -> bool {
1513 match value {
1514 Some(Value::Number(number)) => {
1515 number.as_u64() == Some(index as u64) || number.as_i64() == Some(index as i64)
1516 }
1517 Some(Value::Bool(flag)) => usize::from(*flag) == index,
1519 _ => false,
1520 }
1521}
1522
1523fn minor_units(amount: Option<&Map<String, Value>>) -> Option<i128> {
1524 match amount.and_then(|item| item.get("minor")) {
1525 None => Some(0),
1526 Some(value) => py_int(value),
1527 }
1528}
1529
1530fn py_int(value: &Value) -> Option<i128> {
1532 match value {
1533 Value::Bool(flag) => Some(i128::from(*flag)),
1534 Value::Number(number) => number
1535 .as_i64()
1536 .map(i128::from)
1537 .or_else(|| number.as_u64().map(i128::from))
1538 .or_else(|| number.to_string().parse::<i128>().ok()),
1539 Value::String(text) => text.trim().parse::<i128>().ok(),
1540 _ => None,
1541 }
1542}
1543
1544fn py_truthy(value: Option<&Value>) -> bool {
1546 match value {
1547 None | Some(Value::Null) => false,
1548 Some(Value::Bool(flag)) => *flag,
1549 Some(Value::Number(number)) => number.as_f64() != Some(0.0),
1550 Some(Value::String(text)) => !text.is_empty(),
1551 Some(Value::Array(items)) => !items.is_empty(),
1552 Some(Value::Object(map)) => !map.is_empty(),
1553 }
1554}
1555
1556fn py_repr(value: Option<&Value>) -> String {
1558 match value {
1559 None | Some(Value::Null) => "None".to_string(),
1560 Some(Value::Bool(true)) => "True".to_string(),
1561 Some(Value::Bool(false)) => "False".to_string(),
1562 Some(Value::Number(number)) => number.to_string(),
1563 Some(Value::String(text)) => quote_py_string(text),
1564 Some(Value::Array(items)) => {
1565 let parts: Vec<String> = items.iter().map(|item| py_repr(Some(item))).collect();
1566 format!("[{}]", parts.join(", "))
1567 }
1568 Some(Value::Object(map)) => {
1569 let parts: Vec<String> = map
1570 .iter()
1571 .map(|(key, item)| format!("{}: {}", quote_py_string(key), py_repr(Some(item))))
1572 .collect();
1573 format!("{{{}}}", parts.join(", "))
1574 }
1575 }
1576}
1577
1578fn py_repr_value(value: &Value) -> String {
1579 py_repr(Some(value))
1580}
1581
1582fn py_str(value: Option<&Value>) -> String {
1584 match value {
1585 None | Some(Value::Null) => "None".to_string(),
1586 Some(Value::Bool(true)) => "True".to_string(),
1587 Some(Value::Bool(false)) => "False".to_string(),
1588 Some(Value::Number(number)) => number.to_string(),
1589 Some(Value::String(text)) => text.clone(),
1590 Some(other) => py_repr(Some(other)),
1591 }
1592}
1593
1594fn quote_py_string(text: &str) -> String {
1595 let quote = if text.contains('\'') && !text.contains('"') {
1596 '"'
1597 } else {
1598 '\''
1599 };
1600 let mut out = String::with_capacity(text.len() + 2);
1601 out.push(quote);
1602 for character in text.chars() {
1603 match character {
1604 '\\' => out.push_str("\\\\"),
1605 '\n' => out.push_str("\\n"),
1606 '\r' => out.push_str("\\r"),
1607 '\t' => out.push_str("\\t"),
1608 c if c == quote => {
1609 out.push('\\');
1610 out.push(c);
1611 }
1612 c if (c as u32) < 0x20 || c as u32 == 0x7f => {
1613 out.push_str(&format!("\\x{:02x}", c as u32));
1614 }
1615 c => out.push(c),
1616 }
1617 }
1618 out.push(quote);
1619 out
1620}
1621
1622pub(crate) fn validate_timestamp(value: Option<&Value>) -> bool {
1624 value
1625 .and_then(Value::as_str)
1626 .map(is_rfc3339_utc)
1627 .unwrap_or(false)
1628}
1629
1630fn is_rfc3339_utc(text: &str) -> bool {
1631 let bytes = text.as_bytes();
1632 let is_digit = |index: usize| {
1633 bytes
1634 .get(index)
1635 .map(|byte| byte.is_ascii_digit())
1636 .unwrap_or(false)
1637 };
1638 if bytes.len() < 20 {
1639 return false;
1640 }
1641 if !(is_digit(0) && is_digit(1) && is_digit(2) && is_digit(3)) {
1642 return false;
1643 }
1644 if bytes[4] != b'-' || !(is_digit(5) && is_digit(6)) {
1645 return false;
1646 }
1647 if bytes[7] != b'-' || !(is_digit(8) && is_digit(9)) {
1648 return false;
1649 }
1650 if bytes[10] != b'T' || !(is_digit(11) && is_digit(12)) {
1651 return false;
1652 }
1653 if bytes[13] != b':' || !(is_digit(14) && is_digit(15)) {
1654 return false;
1655 }
1656 if bytes[16] != b':' || !(is_digit(17) && is_digit(18)) {
1657 return false;
1658 }
1659 if bytes.len() == 20 {
1660 return bytes[19] == b'Z';
1661 }
1662 if bytes[19] != b'.' || bytes[bytes.len() - 1] != b'Z' {
1663 return false;
1664 }
1665 let fraction = &bytes[20..bytes.len() - 1];
1666 !fraction.is_empty() && fraction.len() <= 3 && fraction.iter().all(|byte| byte.is_ascii_digit())
1667}
1668
1669#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1671pub(crate) struct Timestamp {
1672 year: u32,
1673 month: u32,
1674 day: u32,
1675 hour: u32,
1676 minute: u32,
1677 second: u32,
1678 millis: u32,
1679}
1680
1681pub(crate) fn parse_timestamp(text: &str) -> Option<Timestamp> {
1682 if !is_rfc3339_utc(text) {
1683 return None;
1684 }
1685 let part = |start: usize, end: usize| -> Option<u32> { text.get(start..end)?.parse().ok() };
1686 let millis = if text.as_bytes().get(19) == Some(&b'.') {
1687 let fraction = text.get(20..text.len().checked_sub(1)?)?;
1688 let mut value = fraction.parse::<u32>().ok()?;
1689 for _ in fraction.len()..3 {
1690 value = value.checked_mul(10)?;
1691 }
1692 value
1693 } else {
1694 0
1695 };
1696 Some(Timestamp {
1697 year: part(0, 4)?,
1698 month: part(5, 7)?,
1699 day: part(8, 10)?,
1700 hour: part(11, 13)?,
1701 minute: part(14, 16)?,
1702 second: part(17, 19)?,
1703 millis,
1704 })
1705}
1706
1707pub(crate) fn isoformat(timestamp: &Timestamp) -> String {
1709 let base = format!(
1710 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
1711 timestamp.year,
1712 timestamp.month,
1713 timestamp.day,
1714 timestamp.hour,
1715 timestamp.minute,
1716 timestamp.second
1717 );
1718 if timestamp.millis == 0 {
1719 format!("{base}+00:00")
1720 } else {
1721 format!("{base}.{:06}+00:00", timestamp.millis * 1000)
1722 }
1723}