Skip to main content

northroot_proof_engine/
validation.rs

1//! Validation functions for v0.2 proof types
2//!
3//! This module consolidates all validation logic for IDs, hashes, timestamps,
4//! and proof structures.
5
6use thiserror::Error;
7
8use crate::types::{MethodRef, SpanCommitment};
9
10/// Error returned when validation fails
11#[derive(Debug, Error, PartialEq, Eq)]
12pub enum ValidationError {
13    /// Invalid trace ID format
14    #[error("invalid trace_id: {0}")]
15    InvalidTraceId(String),
16
17    /// Invalid span ID format
18    #[error("invalid span_id: {0}")]
19    InvalidSpanId(String),
20
21    /// Invalid SHA-256 hash format
22    #[error("invalid sha256 format: {0}")]
23    InvalidSha256(String),
24
25    /// Invalid ISO timestamp format
26    #[error("invalid iso_time: {0}")]
27    InvalidIsoTime(String),
28
29    /// Invalid method reference
30    #[error("invalid method_ref: {0}")]
31    InvalidMethodRef(String),
32
33    /// Invalid span commitment
34    #[error("invalid span_commitment: {0}")]
35    InvalidSpanCommitment(String),
36}
37
38/// Validates a trace ID.
39///
40/// Trace IDs must be at least 8 characters and contain only valid characters.
41/// This follows OpenTelemetry trace ID conventions but is more lenient.
42pub 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    // Allow alphanumeric, hyphens, underscores
50    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
62/// Validates a span ID.
63///
64/// Span IDs must be at least 1 character and contain only valid characters.
65pub 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    // Allow alphanumeric, hyphens, underscores
73    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
85/// Validates SHA-256 hash format.
86///
87/// Must be in format: `sha256:<64 lowercase hex chars>`
88pub 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..]; // Skip "sha256:"
96    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    // Must be lowercase hexadecimal
104    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
116/// Validates ISO timestamp format.
117///
118/// Must be RFC3339 format with millisecond precision (UTC).
119/// Format: `YYYY-MM-DDTHH:MM:SS.sssZ`
120pub fn validate_iso_time(ts: &str) -> Result<(), ValidationError> {
121    use chrono::{DateTime, Utc};
122
123    // Try parsing as RFC3339
124    match DateTime::<Utc>::from_str(ts) {
125        Ok(_) => {
126            // Check format roughly matches expected pattern
127            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
142/// Validates a method reference.
143pub 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
171/// Validates a span commitment.
172pub 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    // Validate end_time >= start_time
231    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}