lean_ctx_protocol/
evidence.rs1use crate::common::{
2 ExtensionsV1, ValidationError, validate_bounded_string, validate_schema_version,
3};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct EvidenceRefV1 {
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub schema_version: Option<u32>,
11 pub kind: EvidenceKind,
12 pub uri: String,
13 pub digest: String,
14 pub signature_status: SignatureStatus,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub media_type: Option<String>,
17 #[serde(default, flatten)]
18 pub extensions: ExtensionsV1,
19}
20
21const EVIDENCE_RESERVED_FIELDS: &[&str] = &[
22 "schema_version",
23 "kind",
24 "uri",
25 "digest",
26 "signature_status",
27 "media_type",
28];
29
30impl EvidenceRefV1 {
31 pub fn validate(&self) -> Result<(), ValidationError> {
32 self.extensions
33 .validate_reserved(EVIDENCE_RESERVED_FIELDS)?;
34 if let Some(version) = self.schema_version {
35 validate_schema_version(version)?;
36 }
37 validate_bounded_string(&self.uri, "evidence uri")?;
38 validate_bounded_string(&self.digest, "evidence digest")?;
39 if self.schema_version.is_some() {
40 let digest = self
41 .digest
42 .strip_prefix("sha256:")
43 .or_else(|| self.digest.strip_prefix("blake3:"))
44 .unwrap_or(&self.digest);
45 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
46 return Err(ValidationError::new(
47 "evidence digest must contain a supported 64-digit hexadecimal digest",
48 ));
49 }
50 }
51 if let Some(media_type) = &self.media_type {
52 validate_bounded_string(media_type, "evidence media_type")?;
53 }
54 Ok(())
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum EvidenceKind {
60 ProviderReceipt,
61 RuntimeLog,
62 SignedBatch,
63 QualityMeasurement,
64 ExperimentOutcome,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub enum SignatureStatus {
69 Verified,
70 Unverified,
71 NotSigned,
72}
73
74#[cfg(test)]
75mod tests {
76 use crate::{EvidenceKind, EvidenceRefV1, SignatureStatus};
77
78 #[test]
79 fn serialization_round_trip() {
80 let evidence = EvidenceRefV1 {
81 schema_version: Some(1),
82 kind: EvidenceKind::ProviderReceipt,
83 uri: "urn:receipt:1".to_owned(),
84 digest: format!("sha256:{}", "a".repeat(64)),
85 signature_status: SignatureStatus::Verified,
86 media_type: Some("application/json".to_owned()),
87 extensions: Default::default(),
88 };
89 let json = serde_json::to_string(&evidence).expect("evidence should serialize");
90 let decoded = serde_json::from_str(&json).expect("evidence should deserialize");
91 assert_eq!(evidence, decoded);
92 }
93
94 #[test]
95 fn versionless_legacy_digest_is_bounded_but_versioned_digest_is_strict() {
96 let mut evidence = EvidenceRefV1 {
97 schema_version: None,
98 kind: EvidenceKind::ProviderReceipt,
99 uri: "urn:legacy:receipt".to_owned(),
100 digest: "sha256:legacy-receipt-id".to_owned(),
101 signature_status: SignatureStatus::Verified,
102 media_type: None,
103 extensions: Default::default(),
104 };
105 evidence
106 .validate()
107 .expect("versionless legacy digest remains readable");
108
109 evidence.schema_version = Some(1);
110 assert!(
111 evidence.validate().is_err(),
112 "versioned evidence requires a canonical digest"
113 );
114 }
115}