Skip to main content

things3_cloud/ids/
things_id.rs

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