Skip to main content

northroot_proof_engine/
ids.rs

1//! OTEL ID validation utilities
2//!
3//! Validates trace_id (32 hex chars) and span_id (16 hex chars) according to
4//! OpenTelemetry specification.
5//!
6//! # Validation Pattern
7//!
8//! **CRITICAL**: When validating "lowercase hex digits", we must allow:
9//! - Digits: 0-9 (these are NOT lowercase, so `is_ascii_lowercase()` returns false)
10//! - Lowercase hex letters: a-f
11//!
12//! **WRONG**: `c.is_ascii_lowercase() && c.is_ascii_hexdigit()` — fails for digits!
13//! **CORRECT**: `c.is_ascii_hexdigit() && !c.is_ascii_uppercase()` — allows digits + lowercase, rejects uppercase.
14//!
15//! This pattern must be used consistently across all hex validation code.
16
17use thiserror::Error;
18
19/// Error returned when an OTEL ID format is invalid
20#[derive(Debug, Error, PartialEq, Eq)]
21pub enum OtelIdError {
22    /// Trace ID must be exactly 32 lowercase hexadecimal characters
23    #[error("trace_id must be exactly 32 lowercase hexadecimal characters, got: {0}")]
24    InvalidTraceId(String),
25
26    /// Span ID must be exactly 16 lowercase hexadecimal characters
27    #[error("span_id must be exactly 16 lowercase hexadecimal characters, got: {0}")]
28    InvalidSpanId(String),
29}
30
31/// Validates a trace_id according to OTEL specification.
32///
33/// Trace IDs must be exactly 32 lowercase hexadecimal characters.
34///
35/// # Examples
36///
37/// ```
38/// use northroot_proof_engine::ids::validate_trace_id;
39///
40/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e").is_ok());
41/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9E").is_err()); // uppercase
42/// assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a").is_err()); // too short
43/// ```
44pub fn validate_trace_id(trace_id: &str) -> Result<(), OtelIdError> {
45    if trace_id.len() != 32 {
46        return Err(OtelIdError::InvalidTraceId(trace_id.to_string()));
47    }
48
49    // CRITICAL: Check hex digit first, then ensure no uppercase letters.
50    // Digits (0-9) are not lowercase, so we can't use is_ascii_lowercase() && is_ascii_hexdigit().
51    // Instead: must be hex digit AND not uppercase (allows digits + lowercase a-f).
52    if !trace_id
53        .chars()
54        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
55    {
56        return Err(OtelIdError::InvalidTraceId(trace_id.to_string()));
57    }
58
59    Ok(())
60}
61
62/// Validates a span_id according to OTEL specification.
63///
64/// Span IDs must be exactly 16 lowercase hexadecimal characters.
65///
66/// # Examples
67///
68/// ```
69/// use northroot_proof_engine::ids::validate_span_id;
70///
71/// assert!(validate_span_id("d2b8c1e4f7a12345").is_ok());
72/// assert!(validate_span_id("d2b8c1e4f7a1234").is_err()); // too short
73/// assert!(validate_span_id("d2b8c1e4f7a1234G").is_err()); // invalid hex
74/// ```
75pub fn validate_span_id(span_id: &str) -> Result<(), OtelIdError> {
76    if span_id.len() != 16 {
77        return Err(OtelIdError::InvalidSpanId(span_id.to_string()));
78    }
79
80    // CRITICAL: Check hex digit first, then ensure no uppercase letters.
81    // Digits (0-9) are not lowercase, so we can't use is_ascii_lowercase() && is_ascii_hexdigit().
82    // Instead: must be hex digit AND not uppercase (allows digits + lowercase a-f).
83    if !span_id
84        .chars()
85        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
86    {
87        return Err(OtelIdError::InvalidSpanId(span_id.to_string()));
88    }
89
90    Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_validate_trace_id_valid() {
99        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e").is_ok());
100        assert!(validate_trace_id("00000000000000000000000000000000").is_ok());
101        assert!(validate_trace_id("ffffffffffffffffffffffffffffffff").is_ok());
102    }
103
104    #[test]
105    fn test_validate_trace_id_invalid_length() {
106        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a").is_err());
107        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9e8").is_err());
108    }
109
110    #[test]
111    fn test_validate_trace_id_invalid_case() {
112        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9E").is_err());
113        assert!(validate_trace_id("4F9B7A7A9B0A4A9E8B0A4A9E8B0A4A9E").is_err());
114    }
115
116    #[test]
117    fn test_validate_trace_id_invalid_chars() {
118        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9g").is_err());
119        assert!(validate_trace_id("4f9b7a7a9b0a4a9e8b0a4a9e8b0a4a9-").is_err());
120    }
121
122    #[test]
123    fn test_validate_span_id_valid() {
124        assert!(validate_span_id("d2b8c1e4f7a12345").is_ok());
125        assert!(validate_span_id("0000000000000000").is_ok());
126        assert!(validate_span_id("ffffffffffffffff").is_ok());
127    }
128
129    #[test]
130    fn test_validate_span_id_invalid_length() {
131        assert!(validate_span_id("d2b8c1e4f7a1234").is_err());
132        assert!(validate_span_id("d2b8c1e4f7a123456").is_err());
133    }
134
135    #[test]
136    fn test_validate_span_id_invalid_case() {
137        assert!(validate_span_id("d2b8c1e4f7a1234F").is_err());
138        assert!(validate_span_id("D2B8C1E4F7A12345").is_err());
139    }
140
141    #[test]
142    fn test_validate_span_id_invalid_chars() {
143        assert!(validate_span_id("d2b8c1e4f7a1234g").is_err());
144        assert!(validate_span_id("d2b8c1e4f7a1234-").is_err());
145    }
146}