northroot_proof_engine/
validation.rs1use thiserror::Error;
7
8use crate::types::{MethodRef, SpanCommitment};
9
10#[derive(Debug, Error, PartialEq, Eq)]
12pub enum ValidationError {
13 #[error("invalid trace_id: {0}")]
15 InvalidTraceId(String),
16
17 #[error("invalid span_id: {0}")]
19 InvalidSpanId(String),
20
21 #[error("invalid sha256 format: {0}")]
23 InvalidSha256(String),
24
25 #[error("invalid iso_time: {0}")]
27 InvalidIsoTime(String),
28
29 #[error("invalid method_ref: {0}")]
31 InvalidMethodRef(String),
32
33 #[error("invalid span_commitment: {0}")]
35 InvalidSpanCommitment(String),
36}
37
38pub fn validate_trace_id(id: &str) -> Result<(), ValidationError> {
43 if id.len() < 8 {
44 return Err(ValidationError::InvalidTraceId(
45 "trace_id must be at least 8 characters".to_string(),
46 ));
47 }
48
49 if !id
51 .chars()
52 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
53 {
54 return Err(ValidationError::InvalidTraceId(
55 "trace_id contains invalid characters".to_string(),
56 ));
57 }
58
59 Ok(())
60}
61
62pub fn validate_span_id(id: &str) -> Result<(), ValidationError> {
66 if id.is_empty() {
67 return Err(ValidationError::InvalidSpanId(
68 "span_id cannot be empty".to_string(),
69 ));
70 }
71
72 if !id
74 .chars()
75 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
76 {
77 return Err(ValidationError::InvalidSpanId(
78 "span_id contains invalid characters".to_string(),
79 ));
80 }
81
82 Ok(())
83}
84
85pub fn validate_sha256_format(hash: &str) -> Result<(), ValidationError> {
89 if !hash.starts_with("sha256:") {
90 return Err(ValidationError::InvalidSha256(
91 "hash must start with 'sha256:'".to_string(),
92 ));
93 }
94
95 let hash_part = &hash[7..]; if hash_part.len() != 64 {
97 return Err(ValidationError::InvalidSha256(format!(
98 "hash part must be 64 characters, got {}",
99 hash_part.len()
100 )));
101 }
102
103 if !hash_part
105 .chars()
106 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
107 {
108 return Err(ValidationError::InvalidSha256(
109 "hash must be lowercase hexadecimal".to_string(),
110 ));
111 }
112
113 Ok(())
114}
115
116pub fn validate_iso_time(ts: &str) -> Result<(), ValidationError> {
121 use chrono::{DateTime, Utc};
122
123 match DateTime::<Utc>::from_str(ts) {
125 Ok(_) => {
126 if ts.ends_with('Z') && ts.contains('T') {
128 Ok(())
129 } else {
130 Err(ValidationError::InvalidIsoTime(
131 "timestamp must be RFC3339 format with Z suffix".to_string(),
132 ))
133 }
134 }
135 Err(e) => Err(ValidationError::InvalidIsoTime(format!(
136 "failed to parse timestamp: {}",
137 e
138 ))),
139 }
140}
141
142pub fn validate_method_ref(method_ref: &MethodRef) -> Result<(), ValidationError> {
144 if method_ref.method_id.is_empty() {
145 return Err(ValidationError::InvalidMethodRef(
146 "method_id cannot be empty".to_string(),
147 ));
148 }
149
150 if method_ref.version.is_empty() {
151 return Err(ValidationError::InvalidMethodRef(
152 "version cannot be empty".to_string(),
153 ));
154 }
155
156 validate_sha256_format(&method_ref.method_shape_root)
157 .map_err(|e| ValidationError::InvalidMethodRef(format!("method_shape_root: {}", e)))?;
158
159 validate_sha256_format(&method_ref.image_digest)
160 .map_err(|e| ValidationError::InvalidMethodRef(format!("image_digest: {}", e)))?;
161
162 if method_ref.method_fingerprint.is_empty() {
163 return Err(ValidationError::InvalidMethodRef(
164 "method_fingerprint cannot be empty".to_string(),
165 ));
166 }
167
168 Ok(())
169}
170
171pub fn validate_span_commitment(commitment: &SpanCommitment) -> Result<(), ValidationError> {
173 validate_trace_id(&commitment.trace_id)
174 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("trace_id: {}", e)))?;
175
176 validate_span_id(&commitment.span_id)
177 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("span_id: {}", e)))?;
178
179 validate_method_ref(&commitment.method_ref)
180 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("method_ref: {}", e)))?;
181
182 validate_sha256_format(&commitment.data_shape_hash)
183 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("data_shape_hash: {}", e)))?;
184
185 validate_sha256_format(&commitment.span_shape_hash)
186 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("span_shape_hash: {}", e)))?;
187
188 if commitment.inputs.is_empty() {
189 return Err(ValidationError::InvalidSpanCommitment(
190 "inputs cannot be empty".to_string(),
191 ));
192 }
193
194 for (idx, input) in commitment.inputs.iter().enumerate() {
195 if input.name.is_empty() {
196 return Err(ValidationError::InvalidSpanCommitment(format!(
197 "inputs[{}].name cannot be empty",
198 idx
199 )));
200 }
201 validate_sha256_format(&input.content_hash).map_err(|e| {
202 ValidationError::InvalidSpanCommitment(format!("inputs[{}].content_hash: {}", idx, e))
203 })?;
204 }
205
206 if commitment.outputs.is_empty() {
207 return Err(ValidationError::InvalidSpanCommitment(
208 "outputs cannot be empty".to_string(),
209 ));
210 }
211
212 for (idx, output) in commitment.outputs.iter().enumerate() {
213 if output.name.is_empty() {
214 return Err(ValidationError::InvalidSpanCommitment(format!(
215 "outputs[{}].name cannot be empty",
216 idx
217 )));
218 }
219 validate_sha256_format(&output.content_hash).map_err(|e| {
220 ValidationError::InvalidSpanCommitment(format!("outputs[{}].content_hash: {}", idx, e))
221 })?;
222 }
223
224 validate_iso_time(&commitment.start_time)
225 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("start_time: {}", e)))?;
226
227 validate_iso_time(&commitment.end_time)
228 .map_err(|e| ValidationError::InvalidSpanCommitment(format!("end_time: {}", e)))?;
229
230 use chrono::{DateTime, Utc};
232 let start: DateTime<Utc> = commitment.start_time.parse().map_err(|e| {
233 ValidationError::InvalidSpanCommitment(format!("start_time parse error: {}", e))
234 })?;
235 let end: DateTime<Utc> = commitment.end_time.parse().map_err(|e| {
236 ValidationError::InvalidSpanCommitment(format!("end_time parse error: {}", e))
237 })?;
238
239 if end < start {
240 return Err(ValidationError::InvalidSpanCommitment(
241 "end_time must be >= start_time".to_string(),
242 ));
243 }
244
245 Ok(())
246}
247
248use std::str::FromStr;
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_validate_trace_id_valid() {
256 assert!(validate_trace_id("tr_01H1234567890").is_ok());
257 assert!(validate_trace_id("trace-abc-123").is_ok());
258 }
259
260 #[test]
261 fn test_validate_trace_id_too_short() {
262 assert!(validate_trace_id("short").is_err());
263 }
264
265 #[test]
266 fn test_validate_span_id_valid() {
267 assert!(validate_span_id("sp-0001").is_ok());
268 assert!(validate_span_id("span_abc").is_ok());
269 }
270
271 #[test]
272 fn test_validate_span_id_empty() {
273 assert!(validate_span_id("").is_err());
274 }
275
276 #[test]
277 fn test_validate_sha256_format_valid() {
278 let hash = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
279 assert!(validate_sha256_format(hash).is_ok());
280 }
281
282 #[test]
283 fn test_validate_sha256_format_invalid_prefix() {
284 assert!(validate_sha256_format("invalid:abc").is_err());
285 }
286
287 #[test]
288 fn test_validate_sha256_format_wrong_length() {
289 assert!(validate_sha256_format("sha256:short").is_err());
290 }
291
292 #[test]
293 fn test_validate_sha256_format_uppercase() {
294 let hash = "sha256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
295 assert!(validate_sha256_format(hash).is_err());
296 }
297
298 #[test]
299 fn test_validate_iso_time_valid() {
300 assert!(validate_iso_time("2025-11-06T12:00:00.000Z").is_ok());
301 }
302
303 #[test]
304 fn test_validate_iso_time_invalid() {
305 assert!(validate_iso_time("invalid").is_err());
306 }
307}