signedshot_validator/
sidecar.rs1use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6use crate::error::{Result, ValidationError};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CaptureTrust {
10 pub jwt: String,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct MediaIntegrity {
16 pub content_hash: String,
18 pub signature: String,
20 pub public_key: String,
22 pub capture_id: String,
24 pub captured_at: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Sidecar {
30 pub version: String,
31 pub capture_trust: CaptureTrust,
32 pub media_integrity: MediaIntegrity,
34}
35
36impl Sidecar {
37 pub fn from_json(json: &str) -> Result<Self> {
38 let sidecar: Sidecar = serde_json::from_str(json)?;
39 sidecar.validate()?;
40 Ok(sidecar)
41 }
42
43 pub fn from_file(path: &Path) -> Result<Self> {
44 let contents = std::fs::read_to_string(path)?;
45 Self::from_json(&contents)
46 }
47
48 fn validate(&self) -> Result<()> {
49 if self.version != "1.0" {
50 return Err(ValidationError::UnsupportedVersion(self.version.clone()));
51 }
52
53 if self.capture_trust.jwt.is_empty() {
54 return Err(ValidationError::InvalidSidecar(
55 "capture_trust.jwt is empty".to_string(),
56 ));
57 }
58
59 let parts: Vec<&str> = self.capture_trust.jwt.split('.').collect();
60 if parts.len() != 3 {
61 return Err(ValidationError::InvalidSidecar(
62 "capture_trust.jwt is not a valid JWT format".to_string(),
63 ));
64 }
65
66 Ok(())
67 }
68
69 pub fn jwt(&self) -> &str {
70 &self.capture_trust.jwt
71 }
72
73 pub fn media_integrity(&self) -> &MediaIntegrity {
74 &self.media_integrity
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 const VALID_JWT: &str =
83 "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature";
84
85 const VALID_SIDECAR_JSON: &str = r#"{
86 "version": "1.0",
87 "capture_trust": {
88 "jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"
89 },
90 "media_integrity": {
91 "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
92 "signature": "MEUCIQC...",
93 "public_key": "BF8lB7BJ5vOldMb...",
94 "capture_id": "550e8400-e29b-41d4-a716-446655440000",
95 "captured_at": "2026-01-26T15:30:00Z"
96 }
97 }"#;
98
99 #[test]
100 fn parse_valid_sidecar() {
101 let sidecar = Sidecar::from_json(VALID_SIDECAR_JSON).unwrap();
102 assert_eq!(sidecar.version, "1.0");
103 assert!(sidecar.jwt().starts_with("eyJ"));
104
105 let integrity = sidecar.media_integrity();
106 assert_eq!(integrity.content_hash.len(), 64);
107 assert_eq!(integrity.capture_id, "550e8400-e29b-41d4-a716-446655440000");
108 assert_eq!(integrity.captured_at, "2026-01-26T15:30:00Z");
109 }
110
111 #[test]
112 fn reject_unsupported_version() {
113 let json = format!(
114 r#"{{
115 "version": "2.0",
116 "capture_trust": {{"jwt": "{}"}},
117 "media_integrity": {{
118 "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
119 "signature": "sig",
120 "public_key": "key",
121 "capture_id": "uuid",
122 "captured_at": "2026-01-26T15:30:00Z"
123 }}
124 }}"#,
125 VALID_JWT
126 );
127
128 let result = Sidecar::from_json(&json);
129 assert!(matches!(
130 result,
131 Err(ValidationError::UnsupportedVersion(_))
132 ));
133 }
134
135 #[test]
136 fn reject_empty_jwt() {
137 let json = r#"{
138 "version": "1.0",
139 "capture_trust": {"jwt": ""},
140 "media_integrity": {
141 "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
142 "signature": "sig",
143 "public_key": "key",
144 "capture_id": "uuid",
145 "captured_at": "2026-01-26T15:30:00Z"
146 }
147 }"#;
148
149 let result = Sidecar::from_json(json);
150 assert!(matches!(result, Err(ValidationError::InvalidSidecar(_))));
151 }
152
153 #[test]
154 fn reject_invalid_jwt_format() {
155 let json = r#"{
156 "version": "1.0",
157 "capture_trust": {"jwt": "not-a-jwt"},
158 "media_integrity": {
159 "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
160 "signature": "sig",
161 "public_key": "key",
162 "capture_id": "uuid",
163 "captured_at": "2026-01-26T15:30:00Z"
164 }
165 }"#;
166
167 let result = Sidecar::from_json(json);
168 assert!(matches!(result, Err(ValidationError::InvalidSidecar(_))));
169 }
170
171 #[test]
172 fn reject_missing_capture_trust() {
173 let json = r#"{"version": "1.0", "media_integrity": {}}"#;
174
175 let result = Sidecar::from_json(json);
176 assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
177 }
178
179 #[test]
180 fn reject_missing_media_integrity() {
181 let json = r#"{
182 "version": "1.0",
183 "capture_trust": {
184 "jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"
185 }
186 }"#;
187
188 let result = Sidecar::from_json(json);
189 assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
190 }
191
192 #[test]
193 fn reject_invalid_json() {
194 let json = "not valid json";
195
196 let result = Sidecar::from_json(json);
197 assert!(matches!(result, Err(ValidationError::SidecarParseError(_))));
198 }
199
200 #[test]
201 fn from_file_nonexistent() {
202 let result = Sidecar::from_file(Path::new("/nonexistent/file.json"));
203 assert!(matches!(result, Err(ValidationError::SidecarReadError(_))));
204 }
205}