Skip to main content

things3_cloud/ids/
things_id.rs

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