Skip to main content

cpop_protocol/
compact_ref.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Compact evidence references (~200B CBOR / ~300 chars base64).
4//!
5//! Cryptographic link to a full Evidence packet for embedding in
6//! document metadata (PDF, EXIF, Office), QR codes, git commit messages,
7//! or protocol headers with size constraints.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// Summary statistics for compact representation.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompactSummary {
16    pub checkpoint_count: u32,
17    pub total_chars: u64,
18    pub total_vdf_time_seconds: f64,
19    /// 1=Basic, 2=Standard, 3=Enhanced, 4=Maximum
20    pub evidence_tier: u8,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub verdict: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub confidence_score: Option<f32>,
25}
26
27/// Optional metadata for compact references.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CompactMetadata {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub author_name: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub created: Option<DateTime<Utc>>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub verifier_name: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub verified_at: Option<DateTime<Utc>>,
38}
39
40/// Cryptographically-bound reference to a full Evidence packet,
41/// embeddable in space-constrained contexts.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct CompactEvidenceRef {
44    pub packet_id: Uuid,
45    /// Final checkpoint hash
46    pub chain_hash: String,
47    pub document_hash: String,
48    pub summary: CompactSummary,
49    /// Where the full Evidence can be retrieved
50    pub evidence_uri: String,
51    /// Ed25519 over the reference fields
52    pub signature: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub metadata: Option<CompactMetadata>,
55}
56
57impl CompactEvidenceRef {
58    /// Create a compact reference with required fields.
59    pub fn new(
60        packet_id: Uuid,
61        chain_hash: String,
62        document_hash: String,
63        summary: CompactSummary,
64        evidence_uri: String,
65        signature: String,
66    ) -> Self {
67        Self {
68            packet_id,
69            chain_hash,
70            document_hash,
71            summary,
72            evidence_uri,
73            signature,
74            metadata: None,
75        }
76    }
77
78    /// Attach optional metadata (author, verifier, timestamps).
79    pub fn with_metadata(mut self, metadata: CompactMetadata) -> Self {
80        self.metadata = Some(metadata);
81        self
82    }
83
84    /// Canonical payload to sign for the `signature` field.
85    pub fn signable_payload(&self) -> Vec<u8> {
86        let payload = serde_json::json!({
87            "packet_id": self.packet_id.to_string(),
88            "chain_hash": self.chain_hash,
89            "document_hash": self.document_hash,
90            "summary": {
91                "checkpoint_count": self.summary.checkpoint_count,
92                "total_chars": self.summary.total_chars,
93                "total_vdf_time_seconds": self.summary.total_vdf_time_seconds,
94                "evidence_tier": self.summary.evidence_tier,
95            },
96            "evidence_uri": self.evidence_uri,
97        });
98
99        payload.to_string().into_bytes()
100    }
101
102    /// Encode as `pop-ref:<base64url>` URI.
103    pub fn to_base64_uri(&self) -> Result<String, serde_json::Error> {
104        let json = serde_json::to_vec(self)?;
105        let encoded =
106            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &json);
107        Ok(format!("pop-ref:{}", encoded))
108    }
109
110    /// Decode from `pop-ref:<base64url>` URI.
111    pub fn from_base64_uri(uri: &str) -> Result<Self, CompactRefError> {
112        let encoded = uri
113            .strip_prefix("pop-ref:")
114            .ok_or(CompactRefError::InvalidPrefix)?;
115
116        let json =
117            base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, encoded)
118                .map_err(|_| CompactRefError::InvalidBase64)?;
119
120        serde_json::from_slice(&json).map_err(|_| CompactRefError::InvalidJson)
121    }
122
123    /// `pop://verify?...` URI for the verification service.
124    pub fn verification_uri(&self) -> String {
125        let encoded_evidence = urlencoding::encode(&self.evidence_uri);
126        format!(
127            "pop://verify?packet={}&uri={}",
128            self.packet_id, encoded_evidence
129        )
130    }
131
132    /// Rough estimate of encoded size in bytes.
133    pub fn estimated_size(&self) -> usize {
134        // Fixed overhead: UUID(16) + hashes(128) + summary(50) + sig(88) + JSON(~200)
135        let base = 16 + 64 + 64 + 50 + 100 + 88 + 100;
136        let uri_len = self.evidence_uri.len();
137        let metadata_len = self
138            .metadata
139            .as_ref()
140            .map(|m| {
141                m.author_name.as_ref().map(|s| s.len()).unwrap_or(0)
142                    + m.verifier_name.as_ref().map(|s| s.len()).unwrap_or(0)
143                    + 40 // timestamps
144            })
145            .unwrap_or(0);
146
147        base + uri_len + metadata_len
148    }
149}
150
151/// Compact reference decoding/verification errors.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CompactRefError {
154    /// URI does not start with `pop-ref:`.
155    InvalidPrefix,
156    /// Base64 decoding failed.
157    InvalidBase64,
158    /// JSON structure is malformed.
159    InvalidJson,
160    /// Ed25519 signature verification failed.
161    InvalidSignature,
162    /// Document hash does not match the referenced evidence.
163    HashMismatch,
164}
165
166impl std::fmt::Display for CompactRefError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            Self::InvalidPrefix => write!(f, "URI must start with 'pop-ref:'"),
170            Self::InvalidBase64 => write!(f, "Invalid base64 encoding"),
171            Self::InvalidJson => write!(f, "Invalid JSON structure"),
172            Self::InvalidSignature => write!(f, "Signature verification failed"),
173            Self::HashMismatch => write!(f, "Hash does not match Evidence"),
174        }
175    }
176}
177
178impl std::error::Error for CompactRefError {}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn sample_ref() -> CompactEvidenceRef {
185        CompactEvidenceRef::new(
186            Uuid::nil(),
187            "abcd1234".to_string(),
188            "efgh5678".to_string(),
189            CompactSummary {
190                checkpoint_count: 47,
191                total_chars: 12500,
192                total_vdf_time_seconds: 5400.0,
193                evidence_tier: 2,
194                verdict: Some("likely-human".to_string()),
195                confidence_score: Some(0.87),
196            },
197            "https://evidence.example.com/packets/abc.pop".to_string(),
198            "test_signature".to_string(),
199        )
200    }
201
202    #[test]
203    fn test_create_compact_ref() {
204        let compact = sample_ref();
205        assert_eq!(compact.summary.checkpoint_count, 47);
206        assert_eq!(compact.summary.evidence_tier, 2);
207    }
208
209    #[test]
210    fn test_base64_roundtrip() {
211        let original = sample_ref();
212        let encoded = original.to_base64_uri().unwrap();
213        assert!(encoded.starts_with("pop-ref:"));
214
215        let decoded = CompactEvidenceRef::from_base64_uri(&encoded).unwrap();
216        assert_eq!(decoded.packet_id, original.packet_id);
217        assert_eq!(decoded.chain_hash, original.chain_hash);
218    }
219
220    #[test]
221    fn test_invalid_prefix() {
222        let result = CompactEvidenceRef::from_base64_uri("invalid:data");
223        assert_eq!(result.unwrap_err(), CompactRefError::InvalidPrefix);
224    }
225
226    #[test]
227    fn test_new_constructor() {
228        let compact = CompactEvidenceRef::new(
229            Uuid::new_v4(),
230            "hash1".to_string(),
231            "hash2".to_string(),
232            CompactSummary {
233                checkpoint_count: 10,
234                total_chars: 1000,
235                total_vdf_time_seconds: 600.0,
236                evidence_tier: 1,
237                verdict: None,
238                confidence_score: None,
239            },
240            "https://example.com/evidence.pop".to_string(),
241            "signature".to_string(),
242        );
243
244        assert_eq!(compact.summary.checkpoint_count, 10);
245    }
246
247    #[test]
248    fn test_verification_uri() {
249        let compact = sample_ref();
250        let uri = compact.verification_uri();
251        assert!(uri.starts_with("pop://verify?"));
252        assert!(uri.contains("packet="));
253    }
254
255    #[test]
256    fn test_estimated_size() {
257        let compact = sample_ref();
258        let size = compact.estimated_size();
259        assert!(size < 1000);
260    }
261
262    #[test]
263    fn test_serialization() {
264        let original = sample_ref();
265        let json = serde_json::to_string(&original).unwrap();
266        let parsed: CompactEvidenceRef = serde_json::from_str(&json).unwrap();
267        assert_eq!(parsed.packet_id, original.packet_id);
268    }
269}