things3_cloud/ids/
things_id.rs1use 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#[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#[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
136pub fn base58_encode_fixed(raw: &[u8; 16]) -> ([u8; 22], usize) {
140 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 let leading_ones = raw.iter().take_while(|&&b| b == 0).count();
160 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 for b in out[..leading_ones].iter_mut() {
171 *b = BASE58_ALPHABET[0]; }
173 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)) }
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 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 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}