Skip to main content

things3_cloud/ids/
things_id.rs

1use std::{fmt, str::FromStr};
2
3use rand::random;
4use serde::{
5    Deserialize,
6    Deserializer,
7    Serialize,
8    Serializer,
9    de::{self, Visitor},
10};
11use sha1::{Digest, Sha1};
12use uuid::Uuid;
13
14/// A Things 3 entity identifier.
15///
16/// Internally stored as canonical 16 bytes (SHA1-truncated UUID digest).
17/// Hyphenated UUIDs and compact base58 IDs are accepted at parse-time.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct ThingsId([u8; 16]);
20
21impl ThingsId {
22    pub fn random() -> Self {
23        let uuid = Uuid::from_bytes(random());
24        ThingsId(uuid_to_bytes(&uuid))
25    }
26
27    pub fn as_bytes(&self) -> &[u8; 16] {
28        &self.0
29    }
30
31    pub fn starts_with(&self, prefix: &str) -> bool {
32        self.to_string().starts_with(prefix)
33    }
34}
35
36impl fmt::Display for ThingsId {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(&base58_encode(&self.0))
39    }
40}
41
42impl AsRef<[u8; 16]> for ThingsId {
43    fn as_ref(&self) -> &[u8; 16] {
44        &self.0
45    }
46}
47
48impl From<ThingsId> for String {
49    fn from(id: ThingsId) -> Self {
50        id.to_string()
51    }
52}
53
54impl From<String> for ThingsId {
55    fn from(value: String) -> Self {
56        value
57            .parse::<ThingsId>()
58            .unwrap_or_else(|_| panic!("invalid Things ID: {value:?}"))
59    }
60}
61
62impl From<&str> for ThingsId {
63    fn from(value: &str) -> Self {
64        value
65            .parse::<ThingsId>()
66            .unwrap_or_else(|_| panic!("invalid Things ID: {value:?}"))
67    }
68}
69
70impl Default for ThingsId {
71    fn default() -> Self {
72        Self([0u8; 16])
73    }
74}
75
76impl FromStr for ThingsId {
77    type Err = ParseThingsIdError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        if s.is_empty() {
81            return Err(ParseThingsIdError(s.to_owned()));
82        }
83        if let Ok(uuid) = Uuid::parse_str(s) {
84            return Ok(ThingsId(uuid_to_bytes(&uuid)));
85        }
86        let decoded = base58_decode(s).ok_or_else(|| ParseThingsIdError(s.to_owned()))?;
87        if decoded.len() != 16 {
88            return Err(ParseThingsIdError(s.to_owned()));
89        }
90        let mut bytes = [0u8; 16];
91        bytes.copy_from_slice(&decoded);
92        Ok(ThingsId(bytes))
93    }
94}
95
96impl Serialize for ThingsId {
97    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
98        serializer.serialize_str(&self.to_string())
99    }
100}
101
102impl<'de> Deserialize<'de> for ThingsId {
103    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
104        struct ThingsIdVisitor;
105
106        impl Visitor<'_> for ThingsIdVisitor {
107            type Value = ThingsId;
108
109            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110                write!(f, "a Things ID string (compact base58 or hyphenated UUID)")
111            }
112
113            fn visit_str<E: de::Error>(self, v: &str) -> Result<ThingsId, E> {
114                v.parse().map_err(de::Error::custom)
115            }
116        }
117
118        deserializer.deserialize_str(ThingsIdVisitor)
119    }
120}
121
122/// Error returned when a string cannot be parsed as a [`ThingsId`].
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ParseThingsIdError(String);
125
126impl fmt::Display for ParseThingsIdError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(f, "invalid Things ID: {:?}", self.0)
129    }
130}
131
132impl std::error::Error for ParseThingsIdError {}
133
134const BASE58_ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
135
136/// Encode a 16-byte Things ID into base58 ASCII, writing into a stack-allocated
137/// `[u8; 22]` buffer.  Returns the buffer and the number of valid bytes (always
138/// in the range 1..=22).  No heap allocation is performed.
139pub fn base58_encode_fixed(raw: &[u8; 16]) -> ([u8; 22], usize) {
140    // Work in base-58 using a fixed-size scratch buffer (little-endian: index 0 = least significant).
141    let mut digits = [0u8; 22];
142    let mut len = 0usize;
143
144    for &byte in raw.iter() {
145        let mut carry = byte as u32;
146        for digit in digits[..len].iter_mut() {
147            let value = (*digit as u32) * 256 + carry;
148            *digit = (value % 58) as u8;
149            carry = value / 58;
150        }
151        while carry > 0 {
152            digits[len] = (carry % 58) as u8;
153            len += 1;
154            carry /= 58;
155        }
156    }
157
158    // Account for leading zero bytes → leading '1' characters.
159    let leading_ones = raw.iter().take_while(|&&b| b == 0).count();
160    // `len` already encodes the non-leading-zero part; prepend leading ones.
161    // We build the final output directly in reverse into `out`.
162    let total = leading_ones + len;
163    debug_assert!(
164        total <= 22,
165        "base58_encode_fixed: output length {total} > 22"
166    );
167
168    let mut out = [0u8; 22];
169    // Fill leading '1's.
170    for b in out[..leading_ones].iter_mut() {
171        *b = BASE58_ALPHABET[0]; // '1'
172    }
173    // Fill remaining digits in reverse (digits is little-endian).
174    for (i, &d) in digits[..len].iter().rev().enumerate() {
175        out[leading_ones + i] = BASE58_ALPHABET[d as usize];
176    }
177    (out, total.max(1)) // at least 1 character ('1') for all-zero input
178}
179
180fn base58_encode(raw: &[u8]) -> String {
181    if raw.is_empty() {
182        return String::new();
183    }
184
185    let mut leading_zeros = 0usize;
186    for b in raw {
187        if *b == 0 {
188            leading_zeros += 1;
189        } else {
190            break;
191        }
192    }
193
194    let mut digits: Vec<u8> = Vec::new();
195    for &byte in raw.iter().skip(leading_zeros) {
196        let mut carry = byte as u32;
197        for digit in &mut digits {
198            let value = (*digit as u32 * 256) + carry;
199            *digit = (value % 58) as u8;
200            carry = value / 58;
201        }
202        while carry > 0 {
203            digits.push((carry % 58) as u8);
204            carry /= 58;
205        }
206    }
207
208    let mut out = String::with_capacity(leading_zeros + digits.len());
209    for _ in 0..leading_zeros {
210        out.push('1');
211    }
212    if digits.is_empty() {
213        return out;
214    }
215    for digit in digits.iter().rev() {
216        out.push(BASE58_ALPHABET[*digit as usize] as char);
217    }
218    out
219}
220
221fn base58_digit(byte: u8) -> Option<u8> {
222    BASE58_ALPHABET
223        .iter()
224        .position(|&c| c == byte)
225        .map(|i| i as u8)
226}
227
228fn base58_decode(input: &str) -> Option<Vec<u8>> {
229    if input.is_empty() {
230        return Some(Vec::new());
231    }
232
233    let bytes = input.as_bytes();
234    let mut leading_ones = 0usize;
235    for b in bytes {
236        if *b == b'1' {
237            leading_ones += 1;
238        } else {
239            break;
240        }
241    }
242
243    let mut decoded: Vec<u8> = Vec::new();
244    for &ch in bytes.iter().skip(leading_ones) {
245        let mut carry = base58_digit(ch)? as u32;
246        for byte in &mut decoded {
247            let value = (*byte as u32 * 58) + carry;
248            *byte = (value & 0xff) as u8;
249            carry = value >> 8;
250        }
251        while carry > 0 {
252            decoded.push((carry & 0xff) as u8);
253            carry >>= 8;
254        }
255    }
256
257    let mut out = Vec::with_capacity(leading_ones + decoded.len());
258    out.extend(std::iter::repeat_n(0u8, leading_ones));
259    for byte in decoded.iter().rev() {
260        out.push(*byte);
261    }
262    Some(out)
263}
264
265fn uuid_to_bytes(uuid: &Uuid) -> [u8; 16] {
266    let canonical = uuid.to_string().to_uppercase();
267    let digest = Sha1::digest(canonical.as_bytes());
268    let mut bytes = [0u8; 16];
269    bytes.copy_from_slice(&digest[..16]);
270    bytes
271}
272
273#[cfg(test)]
274mod tests {
275    use std::collections::HashSet;
276
277    use super::*;
278
279    const LEGACY_UUID: &str = "3C6BBD49-8D11-4FFF-8B0E-B8F33FA9C00A";
280    const LEGACY_UUID_LOWER: &str = "3c6bbd49-8d11-4fff-8b0e-b8f33fa9c00a";
281    fn compact_for_legacy() -> String {
282        ThingsId::from_str(LEGACY_UUID).unwrap().to_string()
283    }
284
285    #[test]
286    fn parse_legacy_uuid_uppercase() {
287        let id: ThingsId = LEGACY_UUID.parse().unwrap();
288        assert_eq!(id.to_string(), compact_for_legacy());
289        assert_eq!(id.to_string().len(), 22);
290    }
291
292    #[test]
293    fn parse_legacy_uuid_lowercase() {
294        let upper: ThingsId = LEGACY_UUID.parse().unwrap();
295        let lower: ThingsId = LEGACY_UUID_LOWER.parse().unwrap();
296        assert_eq!(upper, lower, "UUID parsing must be case-insensitive");
297    }
298
299    #[test]
300    fn parse_compact_preserved() {
301        let compact = compact_for_legacy();
302        let id: ThingsId = compact.parse().unwrap();
303        assert_eq!(id.to_string(), compact);
304    }
305
306    #[test]
307    fn empty_string_is_error() {
308        let err = "".parse::<ThingsId>();
309        assert!(err.is_err());
310    }
311
312    #[test]
313    fn display_roundtrip() {
314        let id: ThingsId = LEGACY_UUID.parse().unwrap();
315        let displayed = id.to_string();
316        let reparsed: ThingsId = displayed.parse().unwrap();
317        assert_eq!(id, reparsed);
318    }
319
320    #[test]
321    fn random_is_unique() {
322        let ids: HashSet<String> = (0..20).map(|_| ThingsId::random().to_string()).collect();
323        assert_eq!(ids.len(), 20, "random IDs should be unique");
324    }
325
326    #[test]
327    fn random_is_compact_length() {
328        let id = ThingsId::random();
329        assert_eq!(id.to_string().len(), 22);
330    }
331
332    #[test]
333    fn serde_roundtrip_compact() {
334        let id: ThingsId = LEGACY_UUID.parse().unwrap();
335        let json = serde_json::to_string(&id).unwrap();
336        let back: ThingsId = serde_json::from_str(&json).unwrap();
337        assert_eq!(id, back);
338    }
339
340    #[test]
341    fn serde_deserialize_from_legacy_uuid() {
342        let json = format!("\"{}\"", LEGACY_UUID);
343        let id: ThingsId = serde_json::from_str(&json).unwrap();
344        assert_eq!(id.to_string().len(), 22);
345        assert_eq!(id.to_string(), compact_for_legacy());
346    }
347
348    #[test]
349    fn into_string() {
350        let id: ThingsId = LEGACY_UUID.parse().unwrap();
351        let s: String = id.clone().into();
352        assert_eq!(s, id.to_string());
353    }
354
355    #[test]
356    fn as_ref_bytes() {
357        let id: ThingsId = LEGACY_UUID.parse().unwrap();
358        let r: &[u8; 16] = id.as_ref();
359        assert_eq!(r, id.as_bytes());
360    }
361
362    #[test]
363    fn rejects_invalid_compact_id() {
364        assert!("not-a-things-id".parse::<ThingsId>().is_err());
365        assert!("0OIl".parse::<ThingsId>().is_err());
366    }
367
368    #[test]
369    fn base58_roundtrip_for_internal_bytes() {
370        let samples = [
371            [0u8; 16],
372            [255u8; 16],
373            uuid_to_bytes(&Uuid::parse_str(LEGACY_UUID).unwrap()),
374        ];
375        for sample in samples {
376            let encoded = base58_encode(&sample);
377            let decoded = base58_decode(&encoded).unwrap();
378            assert_eq!(decoded, sample);
379        }
380    }
381
382    #[test]
383    fn base58_encode_fixed_matches_to_string() {
384        // All-zeros, all-ones, the legacy UUID, and 20 random IDs.
385        let mut samples: Vec<ThingsId> = vec![
386            ThingsId([0u8; 16]),
387            ThingsId([255u8; 16]),
388            LEGACY_UUID.parse().unwrap(),
389        ];
390        for _ in 0..20 {
391            samples.push(ThingsId::random());
392        }
393        for id in &samples {
394            let (buf, len) = base58_encode_fixed(id.as_bytes());
395            let fixed = std::str::from_utf8(&buf[..len]).unwrap();
396            let expected = id.to_string();
397            assert_eq!(fixed, expected, "mismatch for {:?}", id.as_bytes());
398        }
399    }
400
401    #[test]
402    fn base58_encode_fixed_preserves_sort_order() {
403        // Sorting by encoded string should match sorting by to_string().
404        let ids: Vec<ThingsId> = (0..50).map(|_| ThingsId::random()).collect();
405        let mut by_fixed: Vec<String> = ids
406            .iter()
407            .map(|id| {
408                let (buf, len) = base58_encode_fixed(id.as_bytes());
409                std::str::from_utf8(&buf[..len]).unwrap().to_owned()
410            })
411            .collect();
412        by_fixed.sort();
413        let mut by_string: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
414        by_string.sort();
415        assert_eq!(
416            by_fixed, by_string,
417            "fixed-encode sort order != to_string sort order"
418        );
419    }
420}