Skip to main content

lean_ctx_protocol/
evidence.rs

1use crate::common::{
2    ExtensionsV1, ValidationError, validate_bounded_string, validate_schema_version,
3};
4use serde::{Deserialize, Serialize};
5
6/// Typed evidence reference (not just a string).
7#[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}