Skip to main content

nexcore_id/
lib.rs

1//! Zero-dependency UUID implementation for `nexcore` ecosystem
2//!
3//! Provides `NexId` — a 128-bit universally unique identifier compatible with
4//! UUID v4 (random) and v7 (timestamp + random) specifications.
5//!
6//! # Supply Chain Sovereignty
7//!
8//! This crate has **zero external dependencies**. It replaces the `uuid` crate
9//! for the `nexcore` ecosystem, eliminating supply chain risk for identifier generation.
10//!
11//! # Security
12//!
13//! **Platform-dependent entropy quality:**
14//!
15//! | Platform | Entropy Source | CSPRNG |
16//! |----------|----------------|--------|
17//! | Unix (Linux, macOS, BSD) | `/dev/urandom` | Yes |
18//! | Windows (Vista+) | `BCryptGenRandom` | Yes |
19//! | WASM | Timestamp + xorshift | No |
20//! | Other | Timestamp + xorshift | No |
21//!
22//! **WARNING:** On WASM and unsupported platforms, UUIDs are generated using a
23//! timestamp-seeded xorshift64 PRNG that is **NOT cryptographically secure**.
24//! Do not use for:
25//! - Cryptographic keys or secrets
26//! - Password reset tokens
27//! - Security-sensitive session identifiers
28//!
29//! See `SECURITY.md` for full threat model and usage guidelines.
30//!
31//! # Examples
32//!
33//! ```
34//! use nexcore_id::NexId;
35//!
36//! // Generate random v4 UUID
37//! let id = NexId::v4();
38//! println!("{id}"); // e.g., "550e8400-e29b-41d4-a716-446655440000"
39//!
40//! // Generate timestamp-based v7 UUID
41//! let id = NexId::v7();
42//!
43//! // Parse from string
44//! let id: NexId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
45//! ```
46
47#![cfg_attr(not(feature = "std"), no_std)]
48// NOTE: unsafe_code is denied (not forbidden) to allow the isolated Windows FFI
49// in fill_random_windows(). All other unsafe code is still rejected.
50#![deny(unsafe_code)]
51#![cfg_attr(not(test), deny(clippy::unwrap_used))]
52#![cfg_attr(not(test), deny(clippy::expect_used))]
53#![cfg_attr(not(test), deny(clippy::panic))]
54#![warn(missing_docs)]
55#[cfg(not(feature = "std"))]
56extern crate alloc;
57
58#[cfg(not(feature = "std"))]
59use alloc::string::String;
60
61use core::fmt;
62use core::str::FromStr;
63
64/// A 128-bit universally unique identifier.
65///
66/// Compatible with RFC 4122 UUID format. Supports v4 (random) and v7 (timestamp).
67///
68/// # Serialization
69///
70/// With the `serde` feature enabled, `NexId` serializes as a hyphenated string:
71/// ```json
72/// "550e8400-e29b-41d4-a716-446655440000"
73/// ```
74#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct NexId([u8; 16]);
76
77// ============================================================================
78// Serde Support (optional feature)
79// ============================================================================
80
81#[cfg(feature = "serde")]
82impl serde::Serialize for NexId {
83    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
84    where
85        S: serde::Serializer,
86    {
87        serializer.serialize_str(&self.to_string_hyphenated())
88    }
89}
90
91#[cfg(feature = "serde")]
92impl<'de> serde::Deserialize<'de> for NexId {
93    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
94    where
95        D: serde::Deserializer<'de>,
96    {
97        struct NexIdVisitor;
98
99        impl serde::de::Visitor<'_> for NexIdVisitor {
100            type Value = NexId;
101
102            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103                write!(f, "a UUID string (hyphenated or simple)")
104            }
105
106            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
107            where
108                E: serde::de::Error,
109            {
110                v.parse().map_err(serde::de::Error::custom)
111            }
112        }
113
114        deserializer.deserialize_str(NexIdVisitor)
115    }
116}
117
118/// Error returned when parsing a UUID string fails.
119#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ParseError {
122    /// Input has wrong length (expected 36 chars with hyphens or 32 without)
123    InvalidLength,
124    /// Invalid character (not hex digit or hyphen)
125    InvalidCharacter,
126    /// Hyphen in wrong position
127    InvalidFormat,
128}
129
130impl fmt::Display for ParseError {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        match self {
133            Self::InvalidLength => write!(f, "invalid UUID length"),
134            Self::InvalidCharacter => write!(f, "invalid character in UUID"),
135            Self::InvalidFormat => write!(f, "invalid UUID format"),
136        }
137    }
138}
139
140#[cfg(feature = "std")]
141impl std::error::Error for ParseError {}
142
143impl NexId {
144    /// The nil UUID (all zeros).
145    pub const NIL: Self = Self([0; 16]);
146
147    /// The max UUID (all ones).
148    pub const MAX: Self = Self([0xff; 16]);
149
150    /// Creates a new `NexId` from raw bytes.
151    #[must_use]
152    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
153        Self(bytes)
154    }
155
156    /// Returns the raw bytes of this UUID.
157    #[must_use]
158    pub const fn as_bytes(&self) -> &[u8; 16] {
159        &self.0
160    }
161
162    /// Returns the UUID version (4 for random, 7 for timestamp).
163    #[must_use]
164    pub const fn version(&self) -> u8 {
165        (self.0[6] >> 4) & 0x0f
166    }
167
168    /// Returns the UUID variant (should be 0b10xx for RFC 4122).
169    #[must_use]
170    pub const fn variant(&self) -> u8 {
171        (self.0[8] >> 6) & 0x03
172    }
173
174    /// Returns true if this is the nil UUID.
175    #[must_use]
176    pub const fn is_nil(&self) -> bool {
177        // Explicit comparison of all 16 bytes avoids indexing in const context.
178        // `const fn` cannot use iterators, so each byte is checked individually.
179        self.0[0] == 0
180            && self.0[1] == 0
181            && self.0[2] == 0
182            && self.0[3] == 0
183            && self.0[4] == 0
184            && self.0[5] == 0
185            && self.0[6] == 0
186            && self.0[7] == 0
187            && self.0[8] == 0
188            && self.0[9] == 0
189            && self.0[10] == 0
190            && self.0[11] == 0
191            && self.0[12] == 0
192            && self.0[13] == 0
193            && self.0[14] == 0
194            && self.0[15] == 0
195    }
196
197    /// Generates a new random v4 UUID.
198    ///
199    /// # Entropy Source
200    ///
201    /// - **Unix:** `/dev/urandom` (CSPRNG)
202    /// - **Windows:** Timestamp-seeded xorshift64 (**NOT CSPRNG**)
203    /// - **Other:** Timestamp-seeded xorshift64 (**NOT CSPRNG**)
204    ///
205    /// # Security Warning
206    ///
207    /// On non-Unix platforms, the output is **predictable**. An attacker who knows
208    /// the approximate generation time can enumerate possible UUIDs. Do not use
209    /// for security-sensitive purposes on Windows.
210    ///
211    /// See `SECURITY.md` for full threat model.
212    #[cfg(feature = "std")]
213    #[must_use]
214    pub fn v4() -> Self {
215        let mut bytes = [0u8; 16];
216        fill_random(&mut bytes);
217
218        // Set version to 4
219        bytes[6] = (bytes[6] & 0x0f) | 0x40;
220        // Set variant to RFC 4122
221        bytes[8] = (bytes[8] & 0x3f) | 0x80;
222
223        Self(bytes)
224    }
225
226    /// Generates a new timestamp-based v7 UUID.
227    ///
228    /// Combines Unix millisecond timestamp (48 bits) with random data (74 bits)
229    /// for time-ordered uniqueness. UUIDs generated later have larger values.
230    ///
231    /// # Entropy Source
232    ///
233    /// Same as [`v4()`](Self::v4) — see security warnings for non-Unix platforms.
234    ///
235    /// # Ordering Guarantee
236    ///
237    /// v7 UUIDs are **coarsely** time-ordered (millisecond resolution). Within the
238    /// same millisecond, ordering depends on the random component and is not
239    /// guaranteed to be monotonic.
240    ///
241    /// # Timestamp Range
242    ///
243    /// Valid until year 10889 (48-bit millisecond counter from Unix epoch).
244    #[cfg(feature = "std")]
245    #[must_use]
246    pub fn v7() -> Self {
247        let mut bytes = [0u8; 16];
248
249        // Get timestamp in milliseconds.
250        // as_millis() returns u128; we truncate to u64 which holds ~584 million years
251        // of milliseconds — far beyond the UUID v7 spec's 48-bit (year 10889) range.
252        // Saturating at u64::MAX is safe: the upper bits are masked off anyway.
253        let ts_millis: u128 = std::time::SystemTime::now()
254            .duration_since(std::time::UNIX_EPOCH)
255            .map(|d| d.as_millis())
256            .unwrap_or(0);
257        // Clamp to u64: values beyond u64::MAX (~584M years) are saturated safely.
258        #[allow(
259            clippy::as_conversions,
260            reason = "ts_millis fits in u64 for any realistic timestamp: u64::MAX ms ~= 584 million years; saturating lossy cast is intentional"
261        )]
262        let ts = ts_millis as u64;
263
264        // First 48 bits: timestamp bytes extracted by right-shift then truncation to u8.
265        // Each shift isolates exactly 8 bits; the `as u8` discards upper bits deliberately.
266        #[allow(
267            clippy::as_conversions,
268            reason = "byte extraction: right-shift isolates the target octet, as u8 discards upper bits intentionally"
269        )]
270        {
271            bytes[0] = (ts >> 40) as u8;
272            bytes[1] = (ts >> 32) as u8;
273            bytes[2] = (ts >> 24) as u8;
274            bytes[3] = (ts >> 16) as u8;
275            bytes[4] = (ts >> 8) as u8;
276            bytes[5] = ts as u8;
277        }
278
279        // Fill remaining with random
280        let mut rand_bytes = [0u8; 10];
281        fill_random(&mut rand_bytes);
282        bytes[6..16].copy_from_slice(&rand_bytes);
283
284        // Set version to 7
285        bytes[6] = (bytes[6] & 0x0f) | 0x70;
286        // Set variant to RFC 4122
287        bytes[8] = (bytes[8] & 0x3f) | 0x80;
288
289        Self(bytes)
290    }
291
292    /// Creates a `NexId` from a u128 value.
293    #[must_use]
294    pub const fn from_u128(value: u128) -> Self {
295        Self(value.to_be_bytes())
296    }
297
298    /// Converts this `NexId` to a u128 value.
299    #[must_use]
300    pub const fn to_u128(&self) -> u128 {
301        u128::from_be_bytes(self.0)
302    }
303
304    /// Returns the hyphenated string representation.
305    #[must_use]
306    pub fn to_string_hyphenated(&self) -> String {
307        let mut s = String::with_capacity(36);
308        for (i, byte) in self.0.iter().enumerate() {
309            if i == 4 || i == 6 || i == 8 || i == 10 {
310                s.push('-');
311            }
312            // nibble >> 4 and nibble & 0x0f are both in 0..=15, within HEX_CHARS bounds.
313            let hi = usize::from(byte >> 4);
314            let lo = usize::from(byte & 0x0f);
315            s.push(HEX_CHARS.get(hi).copied().unwrap_or('?'));
316            s.push(HEX_CHARS.get(lo).copied().unwrap_or('?'));
317        }
318        s
319    }
320
321    /// Returns the simple (non-hyphenated) string representation.
322    #[must_use]
323    pub fn to_string_simple(&self) -> String {
324        let mut s = String::with_capacity(32);
325        for byte in &self.0 {
326            let hi = usize::from(byte >> 4);
327            let lo = usize::from(byte & 0x0f);
328            s.push(HEX_CHARS.get(hi).copied().unwrap_or('?'));
329            s.push(HEX_CHARS.get(lo).copied().unwrap_or('?'));
330        }
331        s
332    }
333}
334
335const HEX_CHARS: [char; 16] = [
336    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
337];
338
339impl fmt::Display for NexId {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        write!(f, "{}", self.to_string_hyphenated())
342    }
343}
344
345impl fmt::Debug for NexId {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        write!(f, "NexId({})", self.to_string_hyphenated())
348    }
349}
350
351impl FromStr for NexId {
352    type Err = ParseError;
353
354    fn from_str(s: &str) -> Result<Self, Self::Err> {
355        let s = s.trim();
356
357        // Support both hyphenated (36 chars) and simple (32 chars) formats
358        match s.len() {
359            36 => parse_hyphenated(s),
360            32 => parse_simple(s),
361            _ => Err(ParseError::InvalidLength),
362        }
363    }
364}
365
366fn parse_hyphenated(s: &str) -> Result<NexId, ParseError> {
367    let bytes = s.as_bytes();
368
369    // Verify hyphen positions: 8-4-4-4-12.
370    // These indices are always valid: s.len() == 36 is checked by the caller.
371    let b8 = bytes.get(8).copied().ok_or(ParseError::InvalidFormat)?;
372    let b13 = bytes.get(13).copied().ok_or(ParseError::InvalidFormat)?;
373    let b18 = bytes.get(18).copied().ok_or(ParseError::InvalidFormat)?;
374    let b23 = bytes.get(23).copied().ok_or(ParseError::InvalidFormat)?;
375    if b8 != b'-' || b13 != b'-' || b18 != b'-' || b23 != b'-' {
376        return Err(ParseError::InvalidFormat);
377    }
378
379    let mut result = [0u8; 16];
380    let mut byte_idx: usize = 0;
381
382    for (i, chunk) in s.split('-').enumerate() {
383        let expected_len = match i {
384            0 => 8,
385            1..=3 => 4,
386            4 => 12,
387            _ => return Err(ParseError::InvalidFormat),
388        };
389
390        if chunk.len() != expected_len {
391            return Err(ParseError::InvalidFormat);
392        }
393
394        for pair in chunk.as_bytes().chunks(2) {
395            let high = hex_digit(pair.first().copied().ok_or(ParseError::InvalidFormat)?)?;
396            let low = hex_digit(pair.get(1).copied().ok_or(ParseError::InvalidFormat)?)?;
397            let slot = result.get_mut(byte_idx).ok_or(ParseError::InvalidFormat)?;
398            *slot = (high << 4) | low;
399            byte_idx = byte_idx.saturating_add(1);
400        }
401    }
402
403    Ok(NexId(result))
404}
405
406fn parse_simple(s: &str) -> Result<NexId, ParseError> {
407    let mut result = [0u8; 16];
408
409    for (i, pair) in s.as_bytes().chunks(2).enumerate() {
410        if pair.len() != 2 {
411            return Err(ParseError::InvalidLength);
412        }
413        let high = hex_digit(pair.first().copied().ok_or(ParseError::InvalidFormat)?)?;
414        let low = hex_digit(pair.get(1).copied().ok_or(ParseError::InvalidFormat)?)?;
415        let slot = result.get_mut(i).ok_or(ParseError::InvalidFormat)?;
416        *slot = (high << 4) | low;
417    }
418
419    Ok(NexId(result))
420}
421
422const fn hex_digit(c: u8) -> Result<u8, ParseError> {
423    match c {
424        // Subtractions are bounded by match arm guards: c >= b'0' and c <= b'9',
425        // so c - b'0' is in 0..=9 with no underflow possible.
426        #[allow(
427            clippy::arithmetic_side_effects,
428            reason = "match arm guarantees c >= b'0' and c <= b'9', so subtraction cannot underflow"
429        )]
430        b'0'..=b'9' => Ok(c - b'0'),
431        // c >= b'a' and c <= b'f', so c - b'a' is in 0..=5; adding 10 gives 10..=15, no overflow.
432        #[allow(
433            clippy::arithmetic_side_effects,
434            reason = "match arm guarantees c in b'a'..=b'f', so c - b'a' is 0..=5 and adding 10 gives 10..=15, no overflow"
435        )]
436        b'a'..=b'f' => Ok(c - b'a' + 10),
437        // c >= b'A' and c <= b'F', so c - b'A' is in 0..=5; adding 10 gives 10..=15, no overflow.
438        #[allow(
439            clippy::arithmetic_side_effects,
440            reason = "match arm guarantees c in b'A'..=b'F', so c - b'A' is 0..=5 and adding 10 gives 10..=15, no overflow"
441        )]
442        b'A'..=b'F' => Ok(c - b'A' + 10),
443        _ => Err(ParseError::InvalidCharacter),
444    }
445}
446
447/// Cached /dev/urandom handle for Unix systems.
448#[cfg(all(feature = "std", unix))]
449static URANDOM: std::sync::OnceLock<std::sync::Mutex<std::fs::File>> = std::sync::OnceLock::new();
450
451/// Initialize the cached urandom file handle.
452#[cfg(all(feature = "std", unix))]
453fn init_urandom() -> std::sync::Mutex<std::fs::File> {
454    use std::sync::Mutex;
455    let file = std::fs::File::open("/dev/urandom").unwrap_or_else(|_| {
456        std::fs::File::open("/dev/null").unwrap_or_else(|_| std::process::abort())
457    });
458    Mutex::new(file)
459}
460
461/// Fill buffer with random bytes using OS entropy.
462///
463/// # Performance
464///
465/// Uses a cached file handle to `/dev/urandom` on Unix, avoiding repeated
466/// file open/close overhead (~2-3x speedup vs per-call open).
467#[cfg(feature = "std")]
468fn fill_random(buf: &mut [u8]) {
469    #[cfg(unix)]
470    {
471        fill_random_unix(buf);
472    }
473
474    #[cfg(windows)]
475    {
476        fill_random_windows(buf);
477    }
478
479    #[cfg(not(any(unix, windows)))]
480    {
481        fallback_random(buf);
482    }
483}
484
485// ============================================================================
486// Windows CSPRNG via BCryptGenRandom (zero-dependency FFI)
487// ============================================================================
488
489/// Windows entropy via BCryptGenRandom (CSPRNG).
490///
491/// Uses `BCRYPT_USE_SYSTEM_PREFERRED_RNG` flag which allows passing NULL
492/// for the algorithm handle, simplifying the implementation.
493///
494/// # Security
495///
496/// `BCryptGenRandom` is the recommended Windows CSPRNG since Vista/Server 2008.
497/// It draws from the same entropy pool as `CryptGenRandom` but with a modern API.
498///
499/// # Fallback
500///
501/// If BCryptGenRandom fails (should never happen on modern Windows), falls back
502/// to the weak timestamp-seeded PRNG with a warning in debug builds.
503#[cfg(all(feature = "std", windows))]
504fn fill_random_windows(buf: &mut [u8]) {
505    // Raw FFI to bcrypt.dll - zero external dependencies
506    #[link(name = "bcrypt")]
507    extern "system" {
508        // NTSTATUS BCryptGenRandom(
509        //   BCRYPT_ALG_HANDLE hAlgorithm,  // NULL with BCRYPT_USE_SYSTEM_PREFERRED_RNG
510        //   PUCHAR pbBuffer,
511        //   ULONG cbBuffer,
512        //   ULONG dwFlags
513        // );
514        fn BCryptGenRandom(
515            h_algorithm: *mut core::ffi::c_void,
516            pb_buffer: *mut u8,
517            cb_buffer: u32,
518            dw_flags: u32,
519        ) -> i32;
520    }
521
522    // BCRYPT_USE_SYSTEM_PREFERRED_RNG = 0x00000002
523    // Allows hAlgorithm to be NULL, uses system default RNG
524    const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 0x0000_0002;
525
526    // STATUS_SUCCESS = 0x00000000
527    const STATUS_SUCCESS: i32 = 0;
528
529    // buf.len() is the byte count of a small stack buffer (16 or 10 bytes max);
530    // it always fits in u32 (max value 4_294_967_295). The cast is safe.
531    #[allow(
532        clippy::as_conversions,
533        reason = "buf is always 16 or 10 bytes (UUID-sized), well within u32::MAX; truncation is impossible"
534    )]
535    let buf_len = buf.len() as u32;
536
537    // SAFETY: BCryptGenRandom is a well-documented Windows API.
538    // We pass a valid buffer and length, NULL algorithm handle with the
539    // BCRYPT_USE_SYSTEM_PREFERRED_RNG flag as documented.
540    #[allow(unsafe_code)] // Required for FFI, isolated to this function
541    let status = unsafe {
542        BCryptGenRandom(
543            core::ptr::null_mut(),
544            buf.as_mut_ptr(),
545            buf_len,
546            BCRYPT_USE_SYSTEM_PREFERRED_RNG,
547        )
548    };
549
550    if status != STATUS_SUCCESS {
551        // BCryptGenRandom failed - this should never happen on modern Windows
552        // Fall back to weak PRNG (better than panicking)
553        #[cfg(debug_assertions)]
554        eprintln!(
555            "WARNING: BCryptGenRandom failed with status 0x{:08X}, using weak fallback",
556            status
557        );
558        fallback_random(buf);
559    }
560}
561
562/// Unix entropy via cached /dev/urandom handle.
563#[cfg(all(feature = "std", unix))]
564fn fill_random_unix(buf: &mut [u8]) {
565    use std::io::Read;
566    let mutex = URANDOM.get_or_init(init_urandom);
567    let result = mutex.lock().map(|mut g| g.read_exact(buf));
568    if result.is_err() || result.is_ok_and(|r| r.is_err()) {
569        fallback_random(buf);
570    }
571}
572
573/// Fallback random using timestamp and counter (not cryptographically secure).
574#[cfg(feature = "std")]
575#[allow(
576    clippy::cast_possible_truncation,
577    reason = "intentional: as_nanos() truncates u128 to u64 for xorshift seed (nanosecond precision, upper bits discarded); byte extraction via >> 56 then as u8 isolates the top byte deliberately"
578)]
579fn fallback_random(buf: &mut [u8]) {
580    use std::time::{SystemTime, UNIX_EPOCH};
581
582    // as_nanos() returns u128; truncating to u64 for the xorshift seed is intentional —
583    // we only need nanosecond-granularity entropy, not the full 128-bit range.
584    #[allow(
585        clippy::as_conversions,
586        reason = "truncating u128 nanoseconds to u64 for xorshift PRNG seed; upper bits are discarded intentionally as the lower 64 bits provide sufficient entropy variation"
587    )]
588    let seed = SystemTime::now()
589        .duration_since(UNIX_EPOCH)
590        .map(|d| d.as_nanos() as u64)
591        .unwrap_or(0);
592
593    // Simple xorshift64 PRNG (constants from Marsaglia)
594    let mut state = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
595
596    for byte in buf.iter_mut() {
597        state ^= state >> 12;
598        state ^= state << 25;
599        state ^= state >> 27;
600        // >> 56 on a u64 isolates the top 8 bits into positions 0..=7; as u8 is exact.
601        #[allow(
602            clippy::as_conversions,
603            reason = "state >> 56 produces a value in 0..=255 (top byte of u64), so as u8 is a lossless truncation"
604        )]
605        {
606            *byte = (state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8;
607        }
608    }
609}
610
611impl Default for NexId {
612    fn default() -> Self {
613        Self::NIL
614    }
615}
616
617impl From<[u8; 16]> for NexId {
618    fn from(bytes: [u8; 16]) -> Self {
619        Self::from_bytes(bytes)
620    }
621}
622
623impl From<NexId> for [u8; 16] {
624    fn from(id: NexId) -> Self {
625        id.0
626    }
627}
628
629impl From<u128> for NexId {
630    fn from(value: u128) -> Self {
631        Self::from_u128(value)
632    }
633}
634
635impl From<NexId> for u128 {
636    fn from(id: NexId) -> Self {
637        id.to_u128()
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn test_nil() {
647        assert!(NexId::NIL.is_nil());
648        assert!(!NexId::MAX.is_nil());
649    }
650
651    #[test]
652    fn test_v4_version() {
653        let id = NexId::v4();
654        assert_eq!(id.version(), 4);
655        assert_eq!(id.variant(), 2); // 0b10
656    }
657
658    #[test]
659    fn test_v7_version() {
660        let id = NexId::v7();
661        assert_eq!(id.version(), 7);
662        assert_eq!(id.variant(), 2); // 0b10
663    }
664
665    #[test]
666    fn test_v7_ordering() {
667        let id1 = NexId::v7();
668        std::thread::sleep(std::time::Duration::from_millis(2));
669        let id2 = NexId::v7();
670        assert!(id1 < id2, "v7 UUIDs should be time-ordered");
671    }
672
673    #[test]
674    fn test_parse_hyphenated() {
675        let s = "550e8400-e29b-41d4-a716-446655440000";
676        let id: NexId = s.parse().unwrap();
677        assert_eq!(id.to_string(), s);
678    }
679
680    #[test]
681    fn test_parse_simple() {
682        let s = "550e8400e29b41d4a716446655440000";
683        let id: NexId = s.parse().unwrap();
684        assert_eq!(id.to_string_simple(), s);
685    }
686
687    #[test]
688    fn test_roundtrip() {
689        let original = NexId::v4();
690        let s = original.to_string();
691        let parsed: NexId = s.parse().unwrap();
692        assert_eq!(original, parsed);
693    }
694
695    #[test]
696    fn test_u128_conversion() {
697        let value: u128 = 0x550e8400_e29b_41d4_a716_446655440000;
698        let id = NexId::from_u128(value);
699        assert_eq!(id.to_u128(), value);
700    }
701
702    #[test]
703    fn test_uniqueness() {
704        let ids: Vec<NexId> = (0..1000).map(|_| NexId::v4()).collect();
705        let mut sorted = ids.clone();
706        sorted.sort();
707        sorted.dedup();
708        assert_eq!(ids.len(), sorted.len(), "All v4 IDs should be unique");
709    }
710}
711
712/// Serde serialization tests (only compiled with serde feature).
713#[cfg(all(test, feature = "serde"))]
714mod serde_tests {
715    use super::*;
716
717    #[test]
718    fn test_serialize_json() {
719        let id: NexId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
720        let json = serde_json::to_string(&id).unwrap();
721        assert_eq!(json, "\"550e8400-e29b-41d4-a716-446655440000\"");
722    }
723
724    #[test]
725    fn test_deserialize_json_hyphenated() {
726        let json = "\"550e8400-e29b-41d4-a716-446655440000\"";
727        let id: NexId = serde_json::from_str(json).unwrap();
728        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
729    }
730
731    #[test]
732    fn test_deserialize_json_simple() {
733        let json = "\"550e8400e29b41d4a716446655440000\"";
734        let id: NexId = serde_json::from_str(json).unwrap();
735        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
736    }
737
738    #[test]
739    fn test_serde_roundtrip() {
740        let original = NexId::v4();
741        let json = serde_json::to_string(&original).unwrap();
742        let restored: NexId = serde_json::from_str(&json).unwrap();
743        assert_eq!(original, restored);
744    }
745
746    #[test]
747    fn test_deserialize_invalid() {
748        let result: Result<NexId, _> = serde_json::from_str("\"not-a-uuid\"");
749        assert!(result.is_err());
750    }
751}
752
753/// NIST SP 800-22 mathematical helpers.
754#[cfg(test)]
755mod nist_math {
756    /// Horner's method polynomial evaluation for erfc.
757    fn erfc_poly(t: f64) -> f64 {
758        let c = [
759            0.170_872_77,
760            -0.822_152_23,
761            1.488_515_87,
762            -1.135_203_98,
763            0.278_868_07,
764            -0.186_288_06,
765            0.096_784_18,
766            0.374_091_96,
767            1.000_023_68,
768            -1.265_512_23,
769        ];
770        let mut result = c[0];
771        for &coef in &c[1..] {
772            result = result * t + coef;
773        }
774        result
775    }
776
777    /// Complementary error function for p-value calculation.
778    pub fn erfc(x: f64) -> f64 {
779        let t = 1.0 / (1.0 + 0.5 * x.abs());
780        let tau = t * (-x * x + erfc_poly(t)).exp();
781        if x >= 0.0 { tau } else { 2.0 - tau }
782    }
783
784    /// Log-gamma via Lanczos approximation.
785    pub fn ln_gamma(x: f64) -> f64 {
786        let c = [
787            76.180_091_729_471_46,
788            -86.505_320_329_416_77,
789            24.014_098_240_830_91,
790            -1.231_739_572_450_155,
791            0.001_208_650_973_866_179,
792            -0.000_005_395_239_384_953,
793        ];
794        let y = x - 1.0;
795        let mut sum = 1.000_000_000_190_015;
796        for (i, &coef) in c.iter().enumerate() {
797            // i is 0..=5 (array length 6), converting to f64 is exact and lossless.
798            #[allow(
799                clippy::as_conversions,
800                reason = "i is 0..=5 from a fixed-length array iteration; f64 represents all integers up to 2^53 exactly"
801            )]
802            let i_f64 = i as f64;
803            sum += coef / (y + i_f64 + 1.0);
804        }
805        let t = y + 5.5;
806        0.5 * (2.0 * core::f64::consts::PI).ln() + (y + 0.5) * t.ln() - t + sum.ln()
807    }
808
809    /// Incomplete gamma Q(a,x) for chi-squared p-values.
810    pub fn igamc(a: f64, x: f64) -> f64 {
811        if x < 0.0 || a <= 0.0 {
812            return 1.0;
813        }
814        if x < a + 1.0 {
815            1.0 - igam_series(a, x)
816        } else {
817            igam_cf(a, x)
818        }
819    }
820
821    fn igam_series(a: f64, x: f64) -> f64 {
822        if x == 0.0 {
823            return 0.0;
824        }
825        let mut sum = 1.0 / a;
826        let mut term = sum;
827        for n in 1..200 {
828            // n is 1..200 (i32 range); converting to f64 is exact for all values <= 2^53.
829            #[allow(
830                clippy::as_conversions,
831                reason = "n is 1..200, well within f64's exact integer range of 2^53"
832            )]
833            let n_f64 = n as f64;
834            term *= x / (a + n_f64);
835            sum += term;
836            if term.abs() < sum.abs() * 1e-14 {
837                break;
838            }
839        }
840        sum * (-x + a * x.ln() - ln_gamma(a)).exp()
841    }
842
843    fn igam_cf(a: f64, x: f64) -> f64 {
844        let mut f = 1e-30_f64;
845        let mut c = 1e-30_f64;
846        for n in 1..200 {
847            let an = compute_an(n, a);
848            // n is 1..200; converting to f64 is exact for all values <= 2^53.
849            #[allow(
850                clippy::as_conversions,
851                reason = "n is 1..200, well within f64's exact integer range of 2^53"
852            )]
853            let n_f64 = n as f64;
854            let bn = x + n_f64 - a;
855            let d = clamp_small(1.0 / clamp_small(bn + an / f));
856            c = clamp_small(bn + an / c);
857            let delta = c * d;
858            f *= delta;
859            if (delta - 1.0).abs() < 1e-14 {
860                break;
861            }
862        }
863        (-x + a * x.ln() - ln_gamma(a)).exp() / f
864    }
865
866    fn compute_an(n: i32, a: f64) -> f64 {
867        // n is 1..200 (i32); converting to f64 is exact.
868        #[allow(
869            clippy::as_conversions,
870            reason = "n is 1..200 (i32), converting to f64 is exact; all values <= 2^53"
871        )]
872        if n % 2 == 1 {
873            (n as f64 + 1.0) / 2.0
874        } else {
875            -(n as f64 / 2.0 - a)
876        }
877    }
878
879    fn clamp_small(x: f64) -> f64 {
880        if x.abs() < 1e-30 { 1e-30 } else { x }
881    }
882}
883
884/// NIST SP 800-22 randomness tests.
885#[cfg(test)]
886mod nist_sp800_22 {
887    use super::*;
888    use nist_math::{erfc, igamc};
889
890    const SAMPLE_BITS: usize = 100_000;
891    const ALPHA: f64 = 0.01;
892
893    fn collect_random_bits(n_bits: usize) -> Vec<u8> {
894        let mut bits = Vec::with_capacity(n_bits);
895        while bits.len() < n_bits {
896            let id = NexId::v4();
897            append_uuid_bits(id.as_bytes(), &mut bits, n_bits);
898        }
899        bits
900    }
901
902    fn append_uuid_bits(bytes: &[u8; 16], bits: &mut Vec<u8>, limit: usize) {
903        for (i, &byte) in bytes.iter().enumerate() {
904            append_byte_bits(byte, i, bits, limit);
905        }
906    }
907
908    fn append_byte_bits(byte: u8, byte_idx: usize, bits: &mut Vec<u8>, limit: usize) {
909        for bit_idx in 0..8 {
910            if bits.len() >= limit {
911                return;
912            }
913            let is_version = byte_idx == 6 && bit_idx >= 4;
914            let is_variant = byte_idx == 8 && bit_idx >= 6;
915            if !is_version && !is_variant {
916                bits.push((byte >> bit_idx) & 1);
917            }
918        }
919    }
920
921    #[test]
922    fn test_frequency_monobit() {
923        let bits = collect_random_bits(SAMPLE_BITS);
924        let p = frequency_monobit_pvalue(&bits);
925        assert!(p >= ALPHA, "Frequency test FAILED: p={p:.6}");
926    }
927
928    fn frequency_monobit_pvalue(bits: &[u8]) -> f64 {
929        // bits.len() is at most SAMPLE_BITS = 100_000, losslessly representable in f64.
930        #[allow(
931            clippy::as_conversions,
932            reason = "bits.len() <= 100_000 which is exactly representable in f64 (< 2^53)"
933        )]
934        let n = bits.len() as f64;
935        let s_n: i64 = bits
936            .iter()
937            .map(|&b| if b == 1 { 1i64 } else { -1i64 })
938            .sum();
939        // s_n is in -100_000..=100_000; converting to f64 is exact.
940        #[allow(
941            clippy::as_conversions,
942            reason = "s_n is bounded by bits.len() <= 100_000, well within f64's exact integer range"
943        )]
944        let s_obs = (s_n as f64).abs() / n.sqrt();
945        erfc(s_obs / core::f64::consts::SQRT_2)
946    }
947
948    #[test]
949    fn test_frequency_block() {
950        let bits = collect_random_bits(SAMPLE_BITS);
951        let p = block_frequency_pvalue(&bits, 100);
952        assert!(p >= ALPHA, "Block frequency FAILED: p={p:.6}");
953    }
954
955    fn block_frequency_pvalue(bits: &[u8], block_size: usize) -> f64 {
956        let n_blocks = bits.len() / block_size;
957        let chi_sq = block_chi_squared(bits, block_size, n_blocks);
958        // n_blocks <= 1_000 (100_000 / 100), exactly representable in f64.
959        #[allow(
960            clippy::as_conversions,
961            reason = "n_blocks <= 1_000, well within f64's exact integer range of 2^53"
962        )]
963        let n_blocks_f64 = n_blocks as f64;
964        igamc(n_blocks_f64 / 2.0, chi_sq / 2.0)
965    }
966
967    fn block_chi_squared(bits: &[u8], m: usize, n: usize) -> f64 {
968        let mut chi_sq = 0.0;
969        for i in 0..n {
970            let ones: usize = bits
971                .get(i.saturating_mul(m)..i.saturating_mul(m).saturating_add(m))
972                .map(|sl| sl.iter().map(|&b| usize::from(b)).sum())
973                .unwrap_or(0);
974            // ones <= m <= SAMPLE_BITS = 100_000, exactly representable in f64.
975            // m <= SAMPLE_BITS = 100_000, exactly representable in f64.
976            #[allow(
977                clippy::as_conversions,
978                reason = "ones and m are both bounded by SAMPLE_BITS = 100_000, well within f64's exact integer range"
979            )]
980            let pi = ones as f64 / m as f64;
981            chi_sq += (pi - 0.5).powi(2);
982        }
983        // m <= 100_000, exactly representable in f64.
984        #[allow(
985            clippy::as_conversions,
986            reason = "m <= SAMPLE_BITS = 100_000, well within f64's exact integer range of 2^53"
987        )]
988        {
989            chi_sq * 4.0 * m as f64
990        }
991    }
992
993    #[test]
994    fn test_runs() {
995        let bits = collect_random_bits(SAMPLE_BITS);
996        let p = runs_pvalue(&bits);
997        if let Some(pval) = p {
998            assert!(pval >= ALPHA, "Runs test FAILED: p={pval:.6}");
999        }
1000    }
1001
1002    fn runs_pvalue(bits: &[u8]) -> Option<f64> {
1003        // bits.len() <= 100_000, exactly representable in f64.
1004        #[allow(
1005            clippy::as_conversions,
1006            reason = "bits.len() <= 100_000, well within f64's exact integer range of 2^53"
1007        )]
1008        let n = bits.len() as f64;
1009        let ones: usize = bits.iter().map(|&b| usize::from(b)).sum();
1010        // ones <= 100_000, exactly representable in f64.
1011        #[allow(
1012            clippy::as_conversions,
1013            reason = "ones <= bits.len() <= 100_000, well within f64's exact integer range"
1014        )]
1015        let pi = ones as f64 / n;
1016        let tau = 2.0 / n.sqrt();
1017        if (pi - 0.5).abs() >= tau {
1018            return None;
1019        }
1020        let v_obs = count_transitions(bits).saturating_add(1);
1021        let expected = 2.0 * n * pi * (1.0 - pi) + 1.0;
1022        let variance = 2.0 * n * pi * (1.0 - pi);
1023        // v_obs <= SAMPLE_BITS + 1 = 100_001, exactly representable in f64.
1024        #[allow(
1025            clippy::as_conversions,
1026            reason = "v_obs <= SAMPLE_BITS + 1 = 100_001, well within f64's exact integer range of 2^53"
1027        )]
1028        let z = (v_obs as f64 - expected).abs() / (2.0 * variance).sqrt();
1029        Some(erfc(z / core::f64::consts::SQRT_2))
1030    }
1031
1032    fn count_transitions(bits: &[u8]) -> u64 {
1033        // count() returns usize; on any realistic platform this fits in u64.
1034        #[allow(
1035            clippy::as_conversions,
1036            reason = "transition count is bounded by bits.len() <= 100_000, well within u64::MAX"
1037        )]
1038        {
1039            bits.windows(2).filter(|w| w[0] != w[1]).count() as u64
1040        }
1041    }
1042
1043    #[test]
1044    fn test_bit_independence() {
1045        let bits = collect_random_bits(SAMPLE_BITS);
1046        let p = independence_pvalue(&bits);
1047        assert!(p >= ALPHA, "Independence FAILED: p={p:.6}");
1048    }
1049
1050    fn independence_pvalue(bits: &[u8]) -> f64 {
1051        let counts = count_bit_pairs(bits);
1052        // bits.len() - 1 is at most 99_999, exactly representable in f64.
1053        #[allow(
1054            clippy::as_conversions,
1055            reason = "bits.len() <= 100_000, so bits.len() - 1 <= 99_999, well within f64's exact integer range"
1056        )]
1057        let total = (bits.len() - 1) as f64;
1058        let expected = total / 4.0;
1059        let chi_sq = chi_squared_from_counts(&counts, expected);
1060        igamc(1.5, chi_sq / 2.0)
1061    }
1062
1063    fn count_bit_pairs(bits: &[u8]) -> [u64; 4] {
1064        let mut c = [0u64; 4];
1065        for w in bits.windows(2) {
1066            // w[0] and w[1] are bits (0 or 1); index is 0*2+0=0, 0*2+1=1, 1*2+0=2, 1*2+1=3.
1067            // These are known safe: w is always length 2 from windows(2).
1068            let idx = usize::from(w[0]) * 2 + usize::from(w[1]);
1069            if let Some(slot) = c.get_mut(idx) {
1070                *slot = slot.saturating_add(1);
1071            }
1072        }
1073        c
1074    }
1075
1076    fn chi_squared_from_counts(counts: &[u64; 4], expected: f64) -> f64 {
1077        counts
1078            .iter()
1079            // c is a u64 count bounded by SAMPLE_BITS; converting to f64 is exact.
1080            .map(|&c| {
1081                #[allow(clippy::as_conversions, reason = "c is a window count bounded by SAMPLE_BITS = 100_000, well within f64's exact integer range of 2^53")]
1082                let c_f64 = c as f64;
1083                (c_f64 - expected).powi(2) / expected
1084            })
1085            .sum()
1086    }
1087}