Skip to main content

ifc_model/
guid.rs

1//! IFC GlobalId: the 22-character compressed GUID.
2//!
3//! # Why this is not standard base-64
4//!
5//! IFC packs a 128-bit UUID into 22 characters using its own alphabet ordered
6//! `0-9 A-Z a-z _ $`, processed as four 6-digit base-64 groups. It is *not*
7//! RFC 4648, and feeding it to a general base-64 decoder produces silent
8//! garbage rather than an error — which is exactly the kind of bug that
9//! surfaces as "some elements mysteriously fail to match" months later.
10//!
11//! Every IFC root object carries one, and it is the only stable cross-file
12//! identity an element has, so correctness here underpins diffing, clash
13//! tracking, and BCF issue references.
14
15/// The IFC base-64 alphabet, in IFC's own order.
16const ALPHABET: &[u8; 64] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
17
18/// Reverse lookup, built at compile time.
19const fn reverse_table() -> [i8; 256] {
20    let mut table = [-1i8; 256];
21    let mut i = 0;
22    while i < 64 {
23        table[ALPHABET[i] as usize] = i as i8;
24        i += 1;
25    }
26    table
27}
28
29const REVERSE: [i8; 256] = reverse_table();
30
31/// A 22-character IFC GlobalId.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct Guid([u8; 22]);
34
35impl Guid {
36    /// Wrap 22 ASCII characters, validating the alphabet and the range.
37    ///
38    /// 22 base-64 digits carry 132 bits and a UUID has 128, so the leading
39    /// digit holds only the top 2 bits and must be `0`–`3`. A higher leading
40    /// digit names no UUID: accepting it would let two distinct texts expand
41    /// to the same UUID in [`to_uuid`](Self::to_uuid). Every accepted `Guid`
42    /// therefore round-trips through `to_uuid`/`from_uuid` unchanged.
43    pub fn parse(text: &str) -> Option<Self> {
44        let bytes = text.as_bytes();
45        if bytes.len() != 22 {
46            return None;
47        }
48        if bytes.iter().any(|&b| REVERSE[b as usize] < 0) {
49            return None;
50        }
51        if REVERSE[bytes[0] as usize] > 3 {
52            return None;
53        }
54        let mut buf = [0u8; 22];
55        buf.copy_from_slice(bytes);
56        Some(Self(buf))
57    }
58
59    /// The GlobalId as text.
60    pub fn as_str(&self) -> &str {
61        // SAFETY-free: every byte was validated against an ASCII alphabet in
62        // `parse`, and `from_uuid` only emits alphabet bytes.
63        std::str::from_utf8(&self.0).expect("alphabet is ASCII by construction")
64    }
65
66    /// Compress a raw 128-bit UUID into the IFC form.
67    pub fn from_uuid(uuid: [u8; 16]) -> Self {
68        let mut num = 0u128;
69        for b in uuid {
70            num = (num << 8) | b as u128;
71        }
72        // 22 base-64 digits cover 132 bits; the leading digit carries the
73        // remaining 2 bits of the 128-bit value.
74        let mut out = [b'0'; 22];
75        let mut n = num;
76        for slot in out.iter_mut().rev() {
77            *slot = ALPHABET[(n & 0x3f) as usize];
78            n >>= 6;
79        }
80        Self(out)
81    }
82
83    /// Expand back to the raw 128-bit UUID.
84    pub fn to_uuid(self) -> [u8; 16] {
85        let mut num = 0u128;
86        for &b in &self.0 {
87            num = (num << 6) | (REVERSE[b as usize] as u128);
88        }
89        let mut out = [0u8; 16];
90        for (i, slot) in out.iter_mut().enumerate() {
91            *slot = ((num >> (8 * (15 - i))) & 0xff) as u8;
92        }
93        out
94    }
95}
96
97impl std::fmt::Display for Guid {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.write_str(self.as_str())
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn rejects_wrong_length_and_foreign_characters() {
109        assert!(Guid::parse("tooshort").is_none());
110        // '+' and '/' are standard base-64 but NOT in IFC's alphabet.
111        assert!(Guid::parse("0123456789ABCDEFGHIJ+/").is_none());
112    }
113
114    #[test]
115    fn accepts_a_real_globalid_from_the_fixture_corpus() {
116        assert!(Guid::parse("2O2Fr$t4X7Zf8NOew3FLOH").is_some());
117    }
118
119    /// The property that matters: compress then expand must be identity, or
120    /// element identity silently changes on export.
121    #[test]
122    fn uuid_roundtrip_is_lossless() {
123        let uuid: [u8; 16] = [
124            0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
125            0x32, 0x10,
126        ];
127        assert_eq!(Guid::from_uuid(uuid).to_uuid(), uuid);
128    }
129
130    /// #62: a leading digit above `3` would set bits beyond the 128 a UUID
131    /// holds, so two texts could expand to one UUID.
132    #[test]
133    fn rejects_a_leading_digit_beyond_the_uuid_range() {
134        for leading in ALPHABET.iter().skip(4) {
135            let mut text = [b'0'; 22];
136            text[0] = *leading;
137            let text = std::str::from_utf8(&text).unwrap();
138            assert!(Guid::parse(text).is_none(), "{text} was accepted");
139        }
140        for leading in b"0123" {
141            let mut text = [b'$'; 22];
142            text[0] = *leading;
143            assert!(Guid::parse(std::str::from_utf8(&text).unwrap()).is_some());
144        }
145        assert!(Guid::parse("4000000000000000000000").is_none());
146        assert!(Guid::parse("$$$$$$$$$$$$$$$$$$$$$$").is_none());
147    }
148
149    /// The largest and smallest UUIDs map to the extremes of the range.
150    #[test]
151    fn the_uuid_range_ends_at_the_leading_digit_three() {
152        assert_eq!(Guid::from_uuid([0; 16]).as_str(), "0000000000000000000000");
153        assert_eq!(
154            Guid::from_uuid([0xff; 16]).as_str(),
155            "3$$$$$$$$$$$$$$$$$$$$$"
156        );
157    }
158
159    /// Every accepted text round-trips: for each position and each digit the
160    /// alphabet allows there, `from_uuid(to_uuid(g)) == g`.
161    #[test]
162    fn every_accepted_text_roundtrips_through_the_uuid() {
163        let base = *b"2O2Fr$t4X7Zf8NOew3FLOH";
164        let mut checked = 0;
165        for position in 0..22 {
166            for &digit in ALPHABET {
167                let mut text = base;
168                text[position] = digit;
169                let text = std::str::from_utf8(&text).unwrap();
170                if let Some(guid) = Guid::parse(text) {
171                    assert_eq!(Guid::from_uuid(guid.to_uuid()), guid, "{text}");
172                    checked += 1;
173                }
174            }
175        }
176        // 21 positions accept all 64 digits, the leading one only 4.
177        assert_eq!(checked, 21 * 64 + 4);
178    }
179
180    #[test]
181    fn text_roundtrip_is_lossless() {
182        let g = Guid::parse("2O2Fr$t4X7Zf8NOew3FLOH").unwrap();
183        assert_eq!(Guid::from_uuid(g.to_uuid()), g);
184    }
185}