Skip to main content

limnifs_format/
lib.rs

1//! Semantic types for the `LimniFS` wire format.
2//!
3//! Source of truth: `limnifs/spec` §2.2 (Terminology / semantic types).
4//! Each type is emitted as a distinct newtype (not a bare alias) so the
5//! per-field semantic constraints from the spec (§1.1 multihash display,
6//! §1.4 determinism, exact widths) are enforced at module boundaries.
7//!
8//! All widths are exact. Storage MUST NOT widen; width changes are spec
9//! amendments, not implementation choices.
10
11#![forbid(unsafe_code)]
12#![warn(clippy::pedantic)]
13
14use core::fmt;
15
16const BLAKE3_LEN: usize = 32;
17const ORDINAL_LEN: usize = 8;
18const SLAB_ID_LEN: usize = ORDINAL_LEN + BLAKE3_LEN;
19
20pub const MANIFEST_MAGIC: [u8; 4] = *b"LMFS";
21pub const SLAB_MAGIC: [u8; 4] = *b"LIM1";
22
23pub const MANIFEST_HEADER_LEN: usize = 16;
24
25/// RFC 4648 base32 lowercase alphabet, no padding.
26const BASE32_LOWER: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
27
28fn encode_base32_lower_no_pad(input: &[u8]) -> String {
29    let capacity = (input.len() * 8).div_ceil(5);
30    let mut out = String::with_capacity(capacity);
31    let mut buffer: u64 = 0;
32    let mut bits: u32 = 0;
33    for &byte in input {
34        buffer = (buffer << 8) | u64::from(byte);
35        bits += 8;
36        while bits >= 5 {
37            bits -= 5;
38            let shift = buffer >> bits;
39            let idx = (shift & 0x1F) as usize;
40            out.push(BASE32_LOWER[idx] as char);
41        }
42    }
43    if bits > 0 {
44        let shift = 5 - bits;
45        let idx = ((buffer << shift) & 0x1F) as usize;
46        out.push(BASE32_LOWER[idx] as char);
47    }
48    out
49}
50
51fn decode_base32_lower_no_pad(input: &str) -> Option<Vec<u8>> {
52    let mut out = Vec::with_capacity(input.len() * 5 / 8 + 1);
53    let mut buffer: u64 = 0;
54    let mut bits: u32 = 0;
55    for ch in input.chars() {
56        let val = match ch {
57            'a'..='z' => u32::from(ch) - u32::from('a'),
58            '2'..='7' => u32::from(ch) - u32::from('2') + 26,
59            _ => return None,
60        };
61        buffer = (buffer << 5) | u64::from(val);
62        bits += 5;
63        if bits >= 8 {
64            bits -= 8;
65            let shift = buffer >> bits;
66            out.push((shift & 0xFF) as u8);
67        }
68    }
69    if bits >= 5 || (buffer & ((1 << bits) - 1) != 0) {
70        return None;
71    }
72    Some(out)
73}
74
75/// 32-byte BLAKE3-derived drop identity.
76///
77/// Per §1.1: `DropId = BLAKE3(plaintext)`. The display form is
78/// `b3:<base32-lower-no-pad>` (multihash-compatible, multibase-decodable).
79/// Identity is independent of every representation choice (§1.3).
80#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
81pub struct DropId([u8; BLAKE3_LEN]);
82
83impl DropId {
84    #[must_use]
85    pub const fn from_bytes(bytes: [u8; BLAKE3_LEN]) -> Self {
86        Self(bytes)
87    }
88
89    #[must_use]
90    pub fn as_bytes(&self) -> &[u8; BLAKE3_LEN] {
91        &self.0
92    }
93
94    /// Parse the multihash text form `b3:<base32-lower-no-pad>`.
95    ///
96    /// Returns `None` if the prefix, alphabet, or output length is wrong.
97    #[must_use]
98    pub fn parse_text(s: &str) -> Option<Self> {
99        let rest = s.strip_prefix("b3:")?;
100        let bytes = decode_base32_lower_no_pad(rest)?;
101        if bytes.len() != BLAKE3_LEN {
102            return None;
103        }
104        let mut arr = [0u8; BLAKE3_LEN];
105        arr.copy_from_slice(&bytes);
106        Some(Self(arr))
107    }
108
109    #[must_use]
110    pub fn to_text(self) -> String {
111        let mut s = String::with_capacity(3 + (BLAKE3_LEN * 8).div_ceil(5));
112        s.push_str("b3:");
113        s.push_str(&encode_base32_lower_no_pad(&self.0));
114        s
115    }
116}
117
118impl fmt::Display for DropId {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(&self.to_text())
121    }
122}
123
124impl fmt::Debug for DropId {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        write!(f, "DropId({self})")
127    }
128}
129
130/// Per-image slab identifier: 8-byte ordinal + 32-byte content hash.
131///
132/// Per §2.2: the ordinal ensures distinct slabs that hash to the same
133/// value within one image remain distinguishable. Total width is 40 bytes.
134#[derive(Copy, Clone, Eq, PartialEq, Hash)]
135pub struct SlabId {
136    pub ordinal: u64,
137    pub hash: [u8; BLAKE3_LEN],
138}
139
140impl SlabId {
141    #[must_use]
142    pub const fn new(ordinal: u64, hash: [u8; BLAKE3_LEN]) -> Self {
143        Self { ordinal, hash }
144    }
145
146    #[must_use]
147    pub fn from_bytes(bytes: &[u8; SLAB_ID_LEN]) -> Self {
148        let mut ordinal_bytes = [0u8; ORDINAL_LEN];
149        ordinal_bytes.copy_from_slice(&bytes[..ORDINAL_LEN]);
150        let mut hash = [0u8; BLAKE3_LEN];
151        hash.copy_from_slice(&bytes[ORDINAL_LEN..]);
152        Self {
153            ordinal: u64::from_le_bytes(ordinal_bytes),
154            hash,
155        }
156    }
157
158    #[must_use]
159    pub fn to_bytes(self) -> [u8; SLAB_ID_LEN] {
160        let mut out = [0u8; SLAB_ID_LEN];
161        out[..ORDINAL_LEN].copy_from_slice(&self.ordinal.to_le_bytes());
162        out[ORDINAL_LEN..].copy_from_slice(&self.hash);
163        out
164    }
165}
166
167impl fmt::Debug for SlabId {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("SlabId")
170            .field("ordinal", &self.ordinal)
171            .field("hash", &encode_base32_lower_no_pad(&self.hash))
172            .finish()
173    }
174}
175
176/// 32-byte image identity: BLAKE3 of the manifest's Merkle hash list.
177///
178/// Per §1.2: `ManifestRoot` is the only handle by which an image is
179/// identified at rest and in transit. Display form matches `DropId`
180/// (§1.1), but the type is distinct so module boundaries cannot confuse
181/// image identity with drop identity.
182#[derive(Copy, Clone, Eq, PartialEq, Hash)]
183pub struct ManifestRoot([u8; BLAKE3_LEN]);
184
185impl ManifestRoot {
186    #[must_use]
187    pub const fn from_bytes(bytes: [u8; BLAKE3_LEN]) -> Self {
188        Self(bytes)
189    }
190
191    #[must_use]
192    pub fn as_bytes(&self) -> &[u8; BLAKE3_LEN] {
193        &self.0
194    }
195
196    #[must_use]
197    pub fn to_text(self) -> String {
198        let mut s = String::with_capacity(3 + (BLAKE3_LEN * 8).div_ceil(5));
199        s.push_str("b3:");
200        s.push_str(&encode_base32_lower_no_pad(&self.0));
201        s
202    }
203}
204
205impl fmt::Display for ManifestRoot {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        f.write_str(&self.to_text())
208    }
209}
210
211impl fmt::Debug for ManifestRoot {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        write!(f, "ManifestRoot({self})")
214    }
215}
216
217/// Per-slab tier, limnologic vocabulary (§2.1).
218///
219/// Wire encoding is a single byte per §2.2. Unknown bytes MUST be
220/// rejected by readers with `Unsupported`.
221#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
222#[repr(u8)]
223pub enum Tier {
224    Epilimnion = 0x00,
225    Metalimnion = 0x01,
226    Hypolimnion = 0x02,
227}
228
229impl Tier {
230    #[must_use]
231    pub const fn from_byte(byte: u8) -> Option<Self> {
232        match byte {
233            0x00 => Some(Self::Epilimnion),
234            0x01 => Some(Self::Metalimnion),
235            0x02 => Some(Self::Hypolimnion),
236            _ => None,
237        }
238    }
239
240    #[must_use]
241    pub const fn to_byte(self) -> u8 {
242        self as u8
243    }
244}
245
246/// Codec, AEAD, and EC identifiers selecting how a representation was
247/// encoded (§1.3). Per §2.2: codec id (1), aead id (1, `0x00` = none),
248/// ec id (1, `0x00` = none). Total width is 3 bytes.
249///
250/// Registry IDs are normative; see `limnifs/spec` §10 (AEAD), §11 (codec),
251/// §14 (feature-flag/EC variants).
252#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
253pub struct Representation {
254    pub codec: u8,
255    pub aead: u8,
256    pub ec: u8,
257}
258
259impl Representation {
260    pub const STORE_PLAINTEXT: Self = Self {
261        codec: 0x00,
262        aead: 0x00,
263        ec: 0x00,
264    };
265
266    #[must_use]
267    pub const fn new(codec: u8, aead: u8, ec: u8) -> Self {
268        Self { codec, aead, ec }
269    }
270
271    #[must_use]
272    pub fn is_plaintext(self) -> bool {
273        self.aead == 0x00
274    }
275
276    #[must_use]
277    pub fn has_no_ec(self) -> bool {
278        self.ec == 0x00
279    }
280
281    #[must_use]
282    pub fn to_bytes(self) -> [u8; 3] {
283        [self.codec, self.aead, self.ec]
284    }
285
286    #[must_use]
287    pub const fn from_bytes(bytes: [u8; 3]) -> Self {
288        Self {
289            codec: bytes[0],
290            aead: bytes[1],
291            ec: bytes[2],
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn base32_roundtrip_zero_bytes() {
302        let input = [0u8; 32];
303        let encoded = encode_base32_lower_no_pad(&input);
304        assert_eq!(encoded.len(), (32u32 * 8).div_ceil(5) as usize);
305        let decoded = decode_base32_lower_no_pad(&encoded).expect("roundtrip");
306        assert_eq!(decoded, input.to_vec());
307    }
308
309    #[test]
310    fn base32_roundtrip_mixed_bytes() {
311        let input: Vec<u8> = (0..32u8).collect();
312        let encoded = encode_base32_lower_no_pad(&input);
313        let decoded = decode_base32_lower_no_pad(&encoded).expect("roundtrip");
314        assert_eq!(decoded, input);
315    }
316
317    #[test]
318    fn base32_rejects_invalid_chars() {
319        assert!(decode_base32_lower_no_pad("0").is_none());
320        assert!(decode_base32_lower_no_pad("1").is_none());
321        assert!(decode_base32_lower_no_pad("8").is_none());
322        assert!(decode_base32_lower_no_pad("!").is_none());
323    }
324
325    #[test]
326    fn base32_known_vector() {
327        // RFC 4648 §10 test vectors (lowercase, no padding):
328        assert_eq!(encode_base32_lower_no_pad(b"f"), "my");
329        assert_eq!(encode_base32_lower_no_pad(b"fo"), "mzxq");
330        assert_eq!(encode_base32_lower_no_pad(b"foo"), "mzxw6");
331        assert_eq!(encode_base32_lower_no_pad(b"foob"), "mzxw6yq");
332        assert_eq!(encode_base32_lower_no_pad(b"fooba"), "mzxw6ytb");
333        assert_eq!(encode_base32_lower_no_pad(b"foobar"), "mzxw6ytboi");
334    }
335
336    #[test]
337    fn drop_id_text_roundtrip() {
338        let bytes = [
339            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
340            0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
341            0x1c, 0x1d, 0x1e, 0x1f,
342        ];
343        let id = DropId::from_bytes(bytes);
344        let text = id.to_text();
345        assert!(text.starts_with("b3:"));
346        let parsed = DropId::parse_text(&text).expect("roundtrip");
347        assert_eq!(parsed, id);
348    }
349
350    #[test]
351    fn drop_id_parse_rejects_wrong_prefix() {
352        assert!(DropId::parse_text("zb3:aaaa").is_none());
353        assert!(DropId::parse_text("aaaa").is_none());
354    }
355
356    #[test]
357    fn drop_id_parse_rejects_wrong_length() {
358        assert!(DropId::parse_text("b3:my").is_none());
359    }
360
361    #[test]
362    fn slab_id_roundtrip() {
363        let id = SlabId::new(0x0123_4567_89ab_cdef, [0xaa; 32]);
364        let bytes = id.to_bytes();
365        assert_eq!(bytes.len(), SLAB_ID_LEN);
366        let back = SlabId::from_bytes(&bytes);
367        assert_eq!(back, id);
368        assert_eq!(back.ordinal, 0x0123_4567_89ab_cdef);
369        assert_eq!(back.hash, [0xaa; 32]);
370    }
371
372    #[test]
373    fn slab_id_ordinal_is_little_endian() {
374        let id = SlabId::new(1, [0; 32]);
375        let bytes = id.to_bytes();
376        assert_eq!(bytes[0], 0x01);
377        for byte in &bytes[1..8] {
378            assert_eq!(*byte, 0);
379        }
380    }
381
382    #[test]
383    fn manifest_root_display_matches_drop_id_format() {
384        let root = ManifestRoot::from_bytes([0x42; 32]);
385        let drop_id = DropId::from_bytes([0x42; 32]);
386        assert_eq!(root.to_text(), drop_id.to_text());
387    }
388
389    #[test]
390    fn tier_roundtrip() {
391        assert_eq!(Tier::from_byte(0x00), Some(Tier::Epilimnion));
392        assert_eq!(Tier::from_byte(0x01), Some(Tier::Metalimnion));
393        assert_eq!(Tier::from_byte(0x02), Some(Tier::Hypolimnion));
394        assert_eq!(Tier::from_byte(0x03), None);
395        assert_eq!(Tier::Epilimnion.to_byte(), 0x00);
396        assert_eq!(Tier::Metalimnion.to_byte(), 0x01);
397        assert_eq!(Tier::Hypolimnion.to_byte(), 0x02);
398    }
399
400    #[test]
401    fn representation_store_plaintext_constant() {
402        let r = Representation::STORE_PLAINTEXT;
403        assert!(r.is_plaintext());
404        assert!(r.has_no_ec());
405        assert_eq!(r.codec, 0x00);
406        assert_eq!(r.aead, 0x00);
407        assert_eq!(r.ec, 0x00);
408    }
409
410    #[test]
411    fn representation_byte_roundtrip() {
412        let r = Representation::new(0x01, 0x02, 0x03);
413        let bytes = r.to_bytes();
414        assert_eq!(bytes, [0x01, 0x02, 0x03]);
415        let back = Representation::from_bytes(bytes);
416        assert_eq!(back, r);
417    }
418
419    #[test]
420    fn representation_predicate_methods() {
421        let plaintext = Representation::new(0x01, 0x00, 0x05);
422        assert!(plaintext.is_plaintext());
423        assert!(!plaintext.has_no_ec());
424
425        let sealed = Representation::new(0x01, 0x02, 0x00);
426        assert!(!sealed.is_plaintext());
427        assert!(sealed.has_no_ec());
428    }
429
430    #[test]
431    fn magic_constants_match_spec() {
432        assert_eq!(&MANIFEST_MAGIC, b"LMFS");
433        assert_eq!(&SLAB_MAGIC, b"LIM1");
434        assert_eq!(MANIFEST_HEADER_LEN, 16);
435    }
436}