nuid/
lib.rs

1// Copyright 2018 Ivan Porto Carrero
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15
16use std::fmt::{self, Display};
17use std::ops::Deref;
18use std::str;
19use std::sync::Mutex;
20
21use rand::distr::Alphanumeric;
22use rand::Rng;
23
24const BASE: usize = 62;
25const ALPHABET: [u8; BASE] = *b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
26
27const PRE_LEN: usize = 12;
28const MAX_SEQ: u64 = 839_299_365_868_340_224; // (BASE ^ remaining bytes 22 - 12) == 62^10
29const MIN_INC: u64 = 33;
30const MAX_INC: u64 = 333;
31
32/// The number of bytes/characters of a NUID.
33pub const TOTAL_LEN: usize = 22;
34
35static GLOBAL_NUID: Mutex<NUID> = Mutex::new(NUID::new());
36
37/// Generate the next `NUID` string from the global locked `NUID` instance.
38#[allow(clippy::missing_panics_doc)]
39#[must_use]
40pub fn next() -> NUIDStr {
41    GLOBAL_NUID.lock().unwrap().next()
42}
43
44/// NUID needs to be very fast to generate and truly unique, all while being entropy pool friendly.
45/// We will use 12 bytes of crypto generated data (entropy draining), and 10 bytes of sequential data
46/// that is started at a pseudo random number and increments with a pseudo-random increment.
47/// Total is 22 bytes of base 62 ascii text :)
48pub struct NUID {
49    pre: [u8; PRE_LEN],
50    seq: u64,
51    inc: u64,
52}
53
54/// An `NUID` string.
55///
56/// Use [`NUIDStr::as_str`], [`NUIDStr::into_bytes`], the [`Deref`] implementation or
57/// [`ToString`] to access the string.
58pub struct NUIDStr(
59    // INVARIANT: this buffer must always contain a valid utf-8 string
60    [u8; TOTAL_LEN],
61);
62
63impl Default for NUID {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl NUID {
70    /// generate a new `NUID` and properly initialize the prefix, sequential start, and sequential increment.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self {
74            pre: [0; PRE_LEN],
75            // the first call to `next` will cause the prefix and sequential to be regenerated
76            seq: MAX_SEQ,
77            inc: 0,
78        }
79    }
80
81    pub fn randomize_prefix(&mut self) {
82        for (i, n) in rand::rng()
83            .sample_iter(&Alphanumeric)
84            .take(PRE_LEN)
85            .enumerate()
86        {
87            self.pre[i] = ALPHABET[n as usize % BASE];
88        }
89    }
90
91    /// Generate the next `NUID` string.
92    #[allow(clippy::should_implement_trait)]
93    #[must_use]
94    pub fn next(&mut self) -> NUIDStr {
95        let mut buffer = [0u8; TOTAL_LEN];
96
97        self.seq += self.inc;
98        if self.seq >= MAX_SEQ {
99            self.randomize_prefix();
100            self.reset_sequential();
101        }
102        #[allow(clippy::cast_possible_truncation)]
103        let seq = self.seq as usize;
104
105        for (i, n) in self.pre.iter().enumerate() {
106            buffer[i] = *n;
107        }
108
109        let mut l = seq;
110        for i in (PRE_LEN..TOTAL_LEN).rev() {
111            buffer[i] = ALPHABET[l % BASE];
112            l /= BASE;
113        }
114
115        // `buffer` has been filled with base62 data, which is always valid utf-8
116        NUIDStr(buffer)
117    }
118
119    fn reset_sequential(&mut self) {
120        let mut rng = rand::rng();
121        self.seq = rng.random_range(0..MAX_SEQ);
122        self.inc = rng.random_range(MIN_INC..MAX_INC);
123    }
124}
125
126impl NUIDStr {
127    /// Get a reference to the inner string
128    #[must_use]
129    pub fn as_str(&self) -> &str {
130        // SAFETY: the invariant guarantees the buffer to always contain utf-8 characters
131        unsafe { str::from_utf8_unchecked(&self.0) }
132    }
133
134    /// Extract the inner buffer
135    #[must_use]
136    pub fn into_bytes(self) -> [u8; TOTAL_LEN] {
137        self.0
138    }
139}
140
141impl Display for NUIDStr {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(self.as_str())
144    }
145}
146
147impl Deref for NUIDStr {
148    type Target = str;
149
150    /// Get a reference to the inner string
151    fn deref(&self) -> &Self::Target {
152        self.as_str()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::collections::HashSet;
160
161    #[test]
162    fn alphabet_size() {
163        assert_eq!(ALPHABET.len(), BASE);
164    }
165
166    #[test]
167    fn global_nuid_init() {
168        assert_eq!(GLOBAL_NUID.lock().unwrap().pre.len(), PRE_LEN);
169        assert_ne!(GLOBAL_NUID.lock().unwrap().seq, 0);
170    }
171
172    #[test]
173    fn nuid_rollover() {
174        let mut n = NUID::new();
175        n.seq = MAX_SEQ;
176        let old = n.pre.to_vec();
177        let _ = n.next();
178        assert_ne!(n.pre.to_vec(), old);
179
180        let mut n = NUID::new();
181        n.seq = 1;
182        let old = n.pre.to_vec();
183        let _ = n.next();
184        assert_eq!(n.pre.to_vec(), old);
185    }
186
187    #[test]
188    fn nuid_len() {
189        let id = next();
190        assert_eq!(id.len(), TOTAL_LEN);
191    }
192
193    #[test]
194    fn proper_prefix() {
195        let mut min: u8 = 255;
196        let mut max: u8 = 0;
197
198        for nn in &ALPHABET {
199            let n = *nn;
200            if n < min {
201                min = n;
202            }
203            if n > max {
204                max = n;
205            }
206        }
207
208        for _ in 0..100_000 {
209            let nuid = NUID::new();
210            for j in 0..PRE_LEN {
211                assert!(nuid.pre[j] >= min || nuid.pre[j] <= max);
212            }
213        }
214    }
215
216    #[test]
217    fn unique() {
218        let mut set = HashSet::new();
219        for _ in 0..10_000_000 {
220            assert!(set.insert(next().to_string()));
221        }
222    }
223}