things3_cloud/ids/
things_id.rs1use 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#[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#[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
144pub(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}