northroot-proof-engine 0.3.2

Core proof computation and validation library for PoSH (Proof of Shape) and PoX (Proof of Execution)
Documentation
//! OTEL ID validation utilities
//!
//! Validates trace_id (32 hex chars) and span_id (16 hex chars) according to
//! OpenTelemetry specification.
//!
//! # Validation Pattern
//!
//! **CRITICAL**: When validating "lowercase hex digits", we must allow:
//! - Digits: 0-9 (these are NOT lowercase, so `is_ascii_lowercase()` returns false)
//! - Lowercase hex letters: a-f
//!
//! **WRONG**: `c.is_ascii_lowercase() && c.is_ascii_hexdigit()` — fails for digits!
//! **CORRECT**: `c.is_ascii_hexdigit() && !c.is_ascii_uppercase()` — allows digits + lowercase, rejects uppercase.
//!
//! This pattern must be used consistently across all hex validation code.

use thiserror::Error;

/// Error returned when an OTEL ID format is invalid
#[derive(Debug, Error, PartialEq, Eq)]
pub enum OtelIdError {
    /// Trace ID must be exactly 32 lowercase hexadecimal characters
    #[error("trace_id must be exactly 32 lowercase hexadecimal characters, got: {0}")]
    InvalidTraceId(String),

    /// Span ID must be exactly 16 lowercase hexadecimal characters
    #[error("span_id must be exactly 16 lowercase hexadecimal characters, got: {0}")]
    InvalidSpanId(String),
}

/// Validates a trace_id according to OTEL specification.
///
/// Trace IDs must be exactly 32 lowercase hexadecimal characters.
///
/// # Examples
///
/// ```
/// use northroot_proof_engine::ids::validate_trace_id;
///
/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e").is_ok());
/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9E").is_err()); // uppercase
/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a").is_err()); // too short
/// ```
pub fn validate_trace_id(trace_id: &str) -> Result<(), OtelIdError> {
    if trace_id.len() != 32 {
        return Err(OtelIdError::InvalidTraceId(trace_id.to_string()));
    }

    // CRITICAL: Check hex digit first, then ensure no uppercase letters.
    // Digits (0-9) are not lowercase, so we can't use is_ascii_lowercase() && is_ascii_hexdigit().
    // Instead: must be hex digit AND not uppercase (allows digits + lowercase a-f).
    if !trace_id
        .chars()
        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
    {
        return Err(OtelIdError::InvalidTraceId(trace_id.to_string()));
    }

    Ok(())
}

/// Validates a span_id according to OTEL specification.
///
/// Span IDs must be exactly 16 lowercase hexadecimal characters.
///
/// # Examples
///
/// ```
/// use northroot_proof_engine::ids::validate_span_id;
///
/// assert!(validate_span_id("d2b8c1e4f7a12345").is_ok());
/// assert!(validate_span_id("d2b8c1e4f7a1234").is_err()); // too short
/// assert!(validate_span_id("d2b8c1e4f7a1234G").is_err()); // invalid hex
/// ```
pub fn validate_span_id(span_id: &str) -> Result<(), OtelIdError> {
    if span_id.len() != 16 {
        return Err(OtelIdError::InvalidSpanId(span_id.to_string()));
    }

    // CRITICAL: Check hex digit first, then ensure no uppercase letters.
    // Digits (0-9) are not lowercase, so we can't use is_ascii_lowercase() && is_ascii_hexdigit().
    // Instead: must be hex digit AND not uppercase (allows digits + lowercase a-f).
    if !span_id
        .chars()
        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
    {
        return Err(OtelIdError::InvalidSpanId(span_id.to_string()));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_validate_trace_id_valid() {
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e").is_ok());
        assert!(validate_trace_id("00000000000000000000000000000000").is_ok());
        assert!(validate_trace_id("ffffffffffffffffffffffffffffffff").is_ok());
    }

    #[test]
    fn test_validate_trace_id_invalid_length() {
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a").is_err());
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e8").is_err());
    }

    #[test]
    fn test_validate_trace_id_invalid_case() {
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9E").is_err());
        assert!(validate_trace_id("4F9B7A7A9B0A4A9E8B0A4A9E8B0A4A9E").is_err());
    }

    #[test]
    fn test_validate_trace_id_invalid_chars() {
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9g").is_err());
        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9-").is_err());
    }

    #[test]
    fn test_validate_span_id_valid() {
        assert!(validate_span_id("d2b8c1e4f7a12345").is_ok());
        assert!(validate_span_id("0000000000000000").is_ok());
        assert!(validate_span_id("ffffffffffffffff").is_ok());
    }

    #[test]
    fn test_validate_span_id_invalid_length() {
        assert!(validate_span_id("d2b8c1e4f7a1234").is_err());
        assert!(validate_span_id("d2b8c1e4f7a123456").is_err());
    }

    #[test]
    fn test_validate_span_id_invalid_case() {
        assert!(validate_span_id("d2b8c1e4f7a1234F").is_err());
        assert!(validate_span_id("D2B8C1E4F7A12345").is_err());
    }

    #[test]
    fn test_validate_span_id_invalid_chars() {
        assert!(validate_span_id("d2b8c1e4f7a1234g").is_err());
        assert!(validate_span_id("d2b8c1e4f7a1234-").is_err());
    }
}