use thiserror::Error;
use crate::types::{MethodRef, SpanCommitment};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ValidationError {
#[error("invalid trace_id: {0}")]
InvalidTraceId(String),
#[error("invalid span_id: {0}")]
InvalidSpanId(String),
#[error("invalid sha256 format: {0}")]
InvalidSha256(String),
#[error("invalid iso_time: {0}")]
InvalidIsoTime(String),
#[error("invalid method_ref: {0}")]
InvalidMethodRef(String),
#[error("invalid span_commitment: {0}")]
InvalidSpanCommitment(String),
}
pub fn validate_trace_id(id: &str) -> Result<(), ValidationError> {
if id.len() < 8 {
return Err(ValidationError::InvalidTraceId(
"trace_id must be at least 8 characters".to_string(),
));
}
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ValidationError::InvalidTraceId(
"trace_id contains invalid characters".to_string(),
));
}
Ok(())
}
pub fn validate_span_id(id: &str) -> Result<(), ValidationError> {
if id.is_empty() {
return Err(ValidationError::InvalidSpanId(
"span_id cannot be empty".to_string(),
));
}
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ValidationError::InvalidSpanId(
"span_id contains invalid characters".to_string(),
));
}
Ok(())
}
pub fn validate_sha256_format(hash: &str) -> Result<(), ValidationError> {
if !hash.starts_with("sha256:") {
return Err(ValidationError::InvalidSha256(
"hash must start with 'sha256:'".to_string(),
));
}
let hash_part = &hash[7..]; if hash_part.len() != 64 {
return Err(ValidationError::InvalidSha256(format!(
"hash part must be 64 characters, got {}",
hash_part.len()
)));
}
if !hash_part
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return Err(ValidationError::InvalidSha256(
"hash must be lowercase hexadecimal".to_string(),
));
}
Ok(())
}
pub fn validate_iso_time(ts: &str) -> Result<(), ValidationError> {
use chrono::{DateTime, Utc};
match DateTime::<Utc>::from_str(ts) {
Ok(_) => {
if ts.ends_with('Z') && ts.contains('T') {
Ok(())
} else {
Err(ValidationError::InvalidIsoTime(
"timestamp must be RFC3339 format with Z suffix".to_string(),
))
}
}
Err(e) => Err(ValidationError::InvalidIsoTime(format!(
"failed to parse timestamp: {}",
e
))),
}
}
pub fn validate_method_ref(method_ref: &MethodRef) -> Result<(), ValidationError> {
if method_ref.method_id.is_empty() {
return Err(ValidationError::InvalidMethodRef(
"method_id cannot be empty".to_string(),
));
}
if method_ref.version.is_empty() {
return Err(ValidationError::InvalidMethodRef(
"version cannot be empty".to_string(),
));
}
validate_sha256_format(&method_ref.method_shape_root)
.map_err(|e| ValidationError::InvalidMethodRef(format!("method_shape_root: {}", e)))?;
validate_sha256_format(&method_ref.image_digest)
.map_err(|e| ValidationError::InvalidMethodRef(format!("image_digest: {}", e)))?;
if method_ref.method_fingerprint.is_empty() {
return Err(ValidationError::InvalidMethodRef(
"method_fingerprint cannot be empty".to_string(),
));
}
Ok(())
}
pub fn validate_span_commitment(commitment: &SpanCommitment) -> Result<(), ValidationError> {
validate_trace_id(&commitment.trace_id)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("trace_id: {}", e)))?;
validate_span_id(&commitment.span_id)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("span_id: {}", e)))?;
validate_method_ref(&commitment.method_ref)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("method_ref: {}", e)))?;
validate_sha256_format(&commitment.data_shape_hash)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("data_shape_hash: {}", e)))?;
validate_sha256_format(&commitment.span_shape_hash)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("span_shape_hash: {}", e)))?;
if commitment.inputs.is_empty() {
return Err(ValidationError::InvalidSpanCommitment(
"inputs cannot be empty".to_string(),
));
}
for (idx, input) in commitment.inputs.iter().enumerate() {
if input.name.is_empty() {
return Err(ValidationError::InvalidSpanCommitment(format!(
"inputs[{}].name cannot be empty",
idx
)));
}
validate_sha256_format(&input.content_hash).map_err(|e| {
ValidationError::InvalidSpanCommitment(format!("inputs[{}].content_hash: {}", idx, e))
})?;
}
if commitment.outputs.is_empty() {
return Err(ValidationError::InvalidSpanCommitment(
"outputs cannot be empty".to_string(),
));
}
for (idx, output) in commitment.outputs.iter().enumerate() {
if output.name.is_empty() {
return Err(ValidationError::InvalidSpanCommitment(format!(
"outputs[{}].name cannot be empty",
idx
)));
}
validate_sha256_format(&output.content_hash).map_err(|e| {
ValidationError::InvalidSpanCommitment(format!("outputs[{}].content_hash: {}", idx, e))
})?;
}
validate_iso_time(&commitment.start_time)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("start_time: {}", e)))?;
validate_iso_time(&commitment.end_time)
.map_err(|e| ValidationError::InvalidSpanCommitment(format!("end_time: {}", e)))?;
use chrono::{DateTime, Utc};
let start: DateTime<Utc> = commitment.start_time.parse().map_err(|e| {
ValidationError::InvalidSpanCommitment(format!("start_time parse error: {}", e))
})?;
let end: DateTime<Utc> = commitment.end_time.parse().map_err(|e| {
ValidationError::InvalidSpanCommitment(format!("end_time parse error: {}", e))
})?;
if end < start {
return Err(ValidationError::InvalidSpanCommitment(
"end_time must be >= start_time".to_string(),
));
}
Ok(())
}
use std::str::FromStr;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_trace_id_valid() {
assert!(validate_trace_id("tr_01H1234567890").is_ok());
assert!(validate_trace_id("trace-abc-123").is_ok());
}
#[test]
fn test_validate_trace_id_too_short() {
assert!(validate_trace_id("short").is_err());
}
#[test]
fn test_validate_span_id_valid() {
assert!(validate_span_id("sp-0001").is_ok());
assert!(validate_span_id("span_abc").is_ok());
}
#[test]
fn test_validate_span_id_empty() {
assert!(validate_span_id("").is_err());
}
#[test]
fn test_validate_sha256_format_valid() {
let hash = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(validate_sha256_format(hash).is_ok());
}
#[test]
fn test_validate_sha256_format_invalid_prefix() {
assert!(validate_sha256_format("invalid:abc").is_err());
}
#[test]
fn test_validate_sha256_format_wrong_length() {
assert!(validate_sha256_format("sha256:short").is_err());
}
#[test]
fn test_validate_sha256_format_uppercase() {
let hash = "sha256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
assert!(validate_sha256_format(hash).is_err());
}
#[test]
fn test_validate_iso_time_valid() {
assert!(validate_iso_time("2025-11-06T12:00:00.000Z").is_ok());
}
#[test]
fn test_validate_iso_time_invalid() {
assert!(validate_iso_time("invalid").is_err());
}
}