northroot-proof-engine 0.2.0

Core proof computation and validation library for PoSH (Proof of Shape) and PoX (Proof of Execution)
Documentation
//! Validation functions for v0.2 proof types
//!
//! This module consolidates all validation logic for IDs, hashes, timestamps,
//! and proof structures.

use thiserror::Error;

use crate::types::{MethodRef, SpanCommitment};

/// Error returned when validation fails
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ValidationError {
    /// Invalid trace ID format
    #[error("invalid trace_id: {0}")]
    InvalidTraceId(String),

    /// Invalid span ID format
    #[error("invalid span_id: {0}")]
    InvalidSpanId(String),

    /// Invalid SHA-256 hash format
    #[error("invalid sha256 format: {0}")]
    InvalidSha256(String),

    /// Invalid ISO timestamp format
    #[error("invalid iso_time: {0}")]
    InvalidIsoTime(String),

    /// Invalid method reference
    #[error("invalid method_ref: {0}")]
    InvalidMethodRef(String),

    /// Invalid span commitment
    #[error("invalid span_commitment: {0}")]
    InvalidSpanCommitment(String),
}

/// Validates a trace ID.
///
/// Trace IDs must be at least 8 characters and contain only valid characters.
/// This follows OpenTelemetry trace ID conventions but is more lenient.
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(),
        ));
    }

    // Allow alphanumeric, hyphens, underscores
    if !id
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(ValidationError::InvalidTraceId(
            "trace_id contains invalid characters".to_string(),
        ));
    }

    Ok(())
}

/// Validates a span ID.
///
/// Span IDs must be at least 1 character and contain only valid characters.
pub fn validate_span_id(id: &str) -> Result<(), ValidationError> {
    if id.is_empty() {
        return Err(ValidationError::InvalidSpanId(
            "span_id cannot be empty".to_string(),
        ));
    }

    // Allow alphanumeric, hyphens, underscores
    if !id
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(ValidationError::InvalidSpanId(
            "span_id contains invalid characters".to_string(),
        ));
    }

    Ok(())
}

/// Validates SHA-256 hash format.
///
/// Must be in format: `sha256:<64 lowercase hex chars>`
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..]; // Skip "sha256:"
    if hash_part.len() != 64 {
        return Err(ValidationError::InvalidSha256(format!(
            "hash part must be 64 characters, got {}",
            hash_part.len()
        )));
    }

    // Must be lowercase hexadecimal
    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(())
}

/// Validates ISO timestamp format.
///
/// Must be RFC3339 format with millisecond precision (UTC).
/// Format: `YYYY-MM-DDTHH:MM:SS.sssZ`
pub fn validate_iso_time(ts: &str) -> Result<(), ValidationError> {
    use chrono::{DateTime, Utc};

    // Try parsing as RFC3339
    match DateTime::<Utc>::from_str(ts) {
        Ok(_) => {
            // Check format roughly matches expected pattern
            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
        ))),
    }
}

/// Validates a method reference.
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(())
}

/// Validates a span commitment.
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)))?;

    // Validate end_time >= start_time
    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());
    }
}