Skip to main content

limnifs_core/
inode.rs

1//! Inode record (spec §4.1, `bit-level/33-inode.md`).
2//!
3//! An inode represents one filesystem object. Every entry in the
4//! directory tree references an inode by its `number`. The inode
5//! carries POSIX attributes, optional xattrs, and a type-dependent
6//! content handle.
7
8use crate::cursor::ManifestCursor;
9use crate::error::CoreError;
10use limnifs_format::DropId;
11
12/// Width of the fixed prefix (before optional atime / xattrs / content handle).
13pub const INODE_FIXED_PREFIX_LEN: usize = 41;
14
15/// Flag: `atime_ns` field is present.
16pub const INODE_FLAG_ATIME: u8 = 0x01;
17/// Flag: xattr block is present.
18pub const INODE_FLAG_HAS_XATTRS: u8 = 0x02;
19/// Flag: inline data is present (regular files only).
20pub const INODE_FLAG_INLINE_DATA: u8 = 0x04;
21/// Flag bit indicating the inode's inline data is a shared-table
22/// reference (deduplicated). When set alongside `INODE_FLAG_INLINE_DATA`,
23/// the content handle body is a u32 index into the shared inline
24/// table at the end of the metadata blob, not inline bytes.
25pub const INODE_FLAG_SHARED_INLINE: u8 = 0x08;
26/// Mask for reserved flag bits (4-7). Bit 3 is the DEFINED
27/// [`INODE_FLAG_SHARED_INLINE`]; the previous value (0xF8) covered
28/// it, making the reader reject every deduplicated shared-inline
29/// inode the writer emits (issue #186).
30pub const INODE_FLAG_RESERVED_MASK: u8 = 0xF0;
31
32/// POSIX file type bits from `mode`.
33pub const S_IFMT: u32 = 0xF000;
34pub const S_IFREG: u32 = 0x8000;
35pub const S_IFDIR: u32 = 0x4000;
36pub const S_IFLNK: u32 = 0xA000;
37pub const S_IFBLK: u32 = 0x6000;
38pub const S_IFCHR: u32 = 0x2000;
39pub const S_IFIFO: u32 = 0x1000;
40pub const S_IFSOCK: u32 = 0xC000;
41
42/// Default inline-data ceiling (spec §4.3: threshold is spec-pinned).
43pub const DEFAULT_INLINE_DATA_MAX_BYTES: u32 = 4 * 1024;
44
45/// One extended attribute.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct XAttr {
48    pub namespace: u8,
49    pub key: String,
50    pub value: Vec<u8>,
51}
52
53/// Type-dependent content handle.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub enum ContentHandle {
56    /// Regular file with inline data.
57    InlineData(Vec<u8>),
58    /// Regular file whose inline data is in the shared inline table
59    /// (deduplicated). The index is resolved to `InlineData` after
60    /// the full metadata blob is parsed.
61    SharedInline(usize),
62    /// Regular file with a slice map.
63    SliceMap(Vec<SliceRef>),
64    /// Directory: BLAKE3 hash of the root Merkle B-tree node.
65    Directory([u8; 32]),
66    /// Symlink: target path.
67    Symlink(String),
68    /// Block or char device: device number.
69    Device(u64),
70    /// FIFO or socket: pipe identifier.
71    Pipe(u64),
72}
73
74/// One entry in a slice map: maps a byte range of the file to a
75/// byte range of a drop's plaintext.
76#[derive(Copy, Clone, Debug, Eq, PartialEq)]
77pub struct SliceRef {
78    pub file_byte_start: u64,
79    pub file_byte_end: u64,
80    pub drop_id: DropId,
81    pub drop_byte_start: u32,
82    pub drop_byte_len: u32,
83}
84
85/// Parsed inode record.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct Inode {
88    pub number: u64,
89    pub mode: u32,
90    pub uid: u32,
91    pub gid: u32,
92    pub mtime_ns: u64,
93    pub ctime_ns: u64,
94    pub nlink: u32,
95    pub atime_ns: Option<u64>,
96    pub xattrs: Vec<XAttr>,
97    pub content_handle: ContentHandle,
98}
99
100impl Inode {
101    /// Returns the file type bits from `mode`.
102    #[must_use]
103    pub fn file_type(&self) -> u32 {
104        self.mode & S_IFMT
105    }
106
107    /// True iff this inode is a regular file.
108    #[must_use]
109    pub fn is_regular(&self) -> bool {
110        self.file_type() == S_IFREG
111    }
112
113    /// True iff this inode is a directory.
114    #[must_use]
115    pub fn is_directory(&self) -> bool {
116        self.file_type() == S_IFDIR
117    }
118}
119
120/// Parse an inode record from the cursor's current position.
121///
122/// Uses the default inline-data ceiling (4 KiB).
123///
124/// # Errors
125///
126/// - [`CoreError::Corrupt`] if reserved flag bits are set, the file
127///   type is unknown, the inline data exceeds the ceiling, or any
128///   slice/xattr field is malformed.
129/// - [`CoreError::TooShort`] if the cursor underruns.
130pub fn parse_inode(cursor: &mut ManifestCursor<'_>) -> Result<Inode, CoreError> {
131    parse_inode_with_ceiling(cursor, DEFAULT_INLINE_DATA_MAX_BYTES)
132}
133
134/// Same as [`parse_inode`] but with a caller-supplied inline-data ceiling.
135///
136/// # Errors
137///
138/// Inherits all errors from [`parse_inode`].
139pub fn parse_inode_with_ceiling(
140    cursor: &mut ManifestCursor<'_>,
141    max_inline_bytes: u32,
142) -> Result<Inode, CoreError> {
143    let number = cursor.read_u64_le()?;
144    let mode = cursor.read_u32_le()?;
145    let uid = cursor.read_u32_le()?;
146    let gid = cursor.read_u32_le()?;
147    let mtime_ns = cursor.read_u64_le()?;
148    let ctime_ns = cursor.read_u64_le()?;
149    let nlink = cursor.read_u32_le()?;
150    let flags = cursor.read_u8()?;
151
152    if flags & INODE_FLAG_RESERVED_MASK != 0 {
153        return Err(CoreError::Corrupt {
154            reason: format!("inode {number}: reserved flag bits set (0x{flags:02X})"),
155        });
156    }
157
158    let atime_ns = if flags & INODE_FLAG_ATIME != 0 {
159        Some(cursor.read_u64_le()?)
160    } else {
161        None
162    };
163
164    let xattrs = if flags & INODE_FLAG_HAS_XATTRS != 0 {
165        parse_xattr_block(cursor)?
166    } else {
167        Vec::new()
168    };
169
170    let content_handle = parse_content_handle(cursor, mode, flags, number, max_inline_bytes)?;
171
172    Ok(Inode {
173        number,
174        mode,
175        uid,
176        gid,
177        mtime_ns,
178        ctime_ns,
179        nlink,
180        atime_ns,
181        xattrs,
182        content_handle,
183    })
184}
185
186fn parse_xattr_block(cursor: &mut ManifestCursor<'_>) -> Result<Vec<XAttr>, CoreError> {
187    let count = cursor.read_u32_le()?;
188    let count_us = usize::try_from(count).map_err(|_| CoreError::Corrupt {
189        reason: format!("xattr_count {count} exceeds usize"),
190    })?;
191    let mut xattrs = Vec::with_capacity(count_us);
192    for _ in 0..count_us {
193        let namespace = cursor.read_u8()?;
194        if namespace > 0x03 {
195            return Err(CoreError::Corrupt {
196                reason: format!("xattr namespace 0x{namespace:02X} out of range (0x00..0x03)"),
197            });
198        }
199        let key_len = cursor.read_u32_le()?;
200        let key_len_us = usize::try_from(key_len).map_err(|_| CoreError::Corrupt {
201            reason: format!("xattr key_len {key_len} exceeds usize"),
202        })?;
203        let key_bytes = cursor.read_n(key_len_us)?;
204        let key = std::str::from_utf8(key_bytes).map_err(|_| CoreError::Corrupt {
205            reason: "xattr key is not valid UTF-8".into(),
206        })?;
207        if key.contains('\0') {
208            return Err(CoreError::Corrupt {
209                reason: "xattr key contains NUL byte".into(),
210            });
211        }
212        let value_len = cursor.read_u32_le()?;
213        let value_len_us = usize::try_from(value_len).map_err(|_| CoreError::Corrupt {
214            reason: format!("xattr value_len {value_len} exceeds usize"),
215        })?;
216        let value = cursor.read_n_owned(value_len_us)?;
217        xattrs.push(XAttr {
218            namespace,
219            key: key.to_owned(),
220            value,
221        });
222    }
223    Ok(xattrs)
224}
225
226fn parse_content_handle(
227    cursor: &mut ManifestCursor<'_>,
228    mode: u32,
229    flags: u8,
230    inode_number: u64,
231    max_inline_bytes: u32,
232) -> Result<ContentHandle, CoreError> {
233    let file_type = mode & S_IFMT;
234    match file_type {
235        S_IFREG => {
236            if flags & INODE_FLAG_SHARED_INLINE != 0 {
237                // Shared inline: content is a u32 index into the
238                // shared inline table at the end of the metadata blob.
239                // The caller (parse_metadata_blob) resolves it.
240                let index = cursor.read_u32_le()?;
241                let index_us = usize::try_from(index).map_err(|_| CoreError::Corrupt {
242                    reason: format!("shared_inline_index {index} exceeds usize"),
243                })?;
244                Ok(ContentHandle::SharedInline(index_us))
245            } else if flags & INODE_FLAG_INLINE_DATA != 0 {
246                let inline_len = cursor.read_u32_le()?;
247                if inline_len > max_inline_bytes {
248                    return Err(CoreError::Corrupt {
249                        reason: format!(
250                            "inode {inode_number}: inline_data_len {inline_len} exceeds ceiling {max_inline_bytes}"
251                        ),
252                    });
253                }
254                let inline_len_us =
255                    usize::try_from(inline_len).map_err(|_| CoreError::Corrupt {
256                        reason: format!("inline_data_len {inline_len} exceeds usize"),
257                    })?;
258                let data = cursor.read_n_owned(inline_len_us)?;
259                Ok(ContentHandle::InlineData(data))
260            } else {
261                let slice_count = cursor.read_u32_le()?;
262                let count_us = usize::try_from(slice_count).map_err(|_| CoreError::Corrupt {
263                    reason: format!("slice_count {slice_count} exceeds usize"),
264                })?;
265                let mut slices = Vec::with_capacity(count_us);
266                for _ in 0..count_us {
267                    let file_byte_start = cursor.read_u64_le()?;
268                    let file_byte_end = cursor.read_u64_le()?;
269                    if file_byte_start >= file_byte_end {
270                        return Err(CoreError::Corrupt {
271                            reason: format!(
272                                "inode {inode_number}: slice has file_byte_start ({file_byte_start}) >= file_byte_end ({file_byte_end})"
273                            ),
274                        });
275                    }
276                    let drop_id_bytes = cursor.read_n(32)?;
277                    let mut drop_id_arr = [0u8; 32];
278                    drop_id_arr.copy_from_slice(drop_id_bytes);
279                    let drop_id = DropId::from_bytes(drop_id_arr);
280                    let drop_byte_start = cursor.read_u32_le()?;
281                    let drop_byte_len = cursor.read_u32_le()?;
282                    slices.push(SliceRef {
283                        file_byte_start,
284                        file_byte_end,
285                        drop_id,
286                        drop_byte_start,
287                        drop_byte_len,
288                    });
289                }
290                Ok(ContentHandle::SliceMap(slices))
291            }
292        }
293        S_IFDIR => {
294            let hash_bytes = cursor.read_n(32)?;
295            let mut hash = [0u8; 32];
296            hash.copy_from_slice(hash_bytes);
297            Ok(ContentHandle::Directory(hash))
298        }
299        S_IFLNK => {
300            let target_len = cursor.read_u32_le()?;
301            let target_len_us = usize::try_from(target_len).map_err(|_| CoreError::Corrupt {
302                reason: format!("target_len {target_len} exceeds usize"),
303            })?;
304            let target_bytes = cursor.read_n(target_len_us)?;
305            let target = std::str::from_utf8(target_bytes).map_err(|_| CoreError::Corrupt {
306                reason: "symlink target is not valid UTF-8".into(),
307            })?;
308            Ok(ContentHandle::Symlink(target.to_owned()))
309        }
310        S_IFBLK | S_IFCHR => {
311            let dev = cursor.read_u64_le()?;
312            Ok(ContentHandle::Device(dev))
313        }
314        S_IFIFO | S_IFSOCK => {
315            let pipe_id = cursor.read_u64_le()?;
316            Ok(ContentHandle::Pipe(pipe_id))
317        }
318        _ => Err(CoreError::Corrupt {
319            reason: format!("inode {inode_number}: unknown file type 0x{file_type:04X}"),
320        }),
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn make_regular_inline_inode(number: u64, mode: u32, inline_data: &[u8]) -> Vec<u8> {
329        let mut bytes = Vec::new();
330        bytes.extend_from_slice(&number.to_le_bytes());
331        bytes.extend_from_slice(&mode.to_le_bytes());
332        bytes.extend_from_slice(&0u32.to_le_bytes()); // uid
333        bytes.extend_from_slice(&0u32.to_le_bytes()); // gid
334        bytes.extend_from_slice(&0u64.to_le_bytes()); // mtime
335        bytes.extend_from_slice(&0u64.to_le_bytes()); // ctime
336        bytes.extend_from_slice(&1u32.to_le_bytes()); // nlink
337        bytes.push(INODE_FLAG_INLINE_DATA); // flags
338        let inline_len = u32::try_from(inline_data.len()).unwrap();
339        bytes.extend_from_slice(&inline_len.to_le_bytes());
340        bytes.extend_from_slice(inline_data);
341        bytes
342    }
343
344    fn make_directory_inode(number: u64) -> Vec<u8> {
345        let mode = S_IFDIR | 0o755;
346        let mut bytes = Vec::new();
347        bytes.extend_from_slice(&number.to_le_bytes());
348        bytes.extend_from_slice(&mode.to_le_bytes());
349        bytes.extend_from_slice(&0u32.to_le_bytes()); // uid
350        bytes.extend_from_slice(&0u32.to_le_bytes()); // gid
351        bytes.extend_from_slice(&0u64.to_le_bytes()); // mtime
352        bytes.extend_from_slice(&0u64.to_le_bytes()); // ctime
353        bytes.extend_from_slice(&2u32.to_le_bytes()); // nlink
354        bytes.push(0); // flags
355        bytes.extend_from_slice(&[0xBB; 32]); // btree_node_hash
356        bytes
357    }
358
359    #[test]
360    fn parses_regular_inline_file() {
361        let data = b"hello world";
362        let bytes = make_regular_inline_inode(42, S_IFREG | 0o644, data);
363        let mut cursor = ManifestCursor::new(&bytes);
364        let inode = parse_inode(&mut cursor).expect("inline file parses");
365        assert_eq!(inode.number, 42);
366        assert!(inode.is_regular());
367        assert!(!inode.is_directory());
368        assert!(inode.atime_ns.is_none());
369        assert!(inode.xattrs.is_empty());
370        match &inode.content_handle {
371            ContentHandle::InlineData(d) => assert_eq!(d, data),
372            other => panic!("expected InlineData, got {other:?}"),
373        }
374        assert_eq!(cursor.position(), bytes.len());
375    }
376
377    #[test]
378    fn parses_directory() {
379        let bytes = make_directory_inode(0);
380        let mut cursor = ManifestCursor::new(&bytes);
381        let inode = parse_inode(&mut cursor).expect("directory parses");
382        assert_eq!(inode.number, 0);
383        assert!(inode.is_directory());
384        match &inode.content_handle {
385            ContentHandle::Directory(hash) => assert_eq!(hash, &[0xBB; 32]),
386            other => panic!("expected Directory, got {other:?}"),
387        }
388    }
389
390    #[test]
391    fn rejects_reserved_flag_bits() {
392        let mut bytes = make_regular_inline_inode(1, S_IFREG | 0o644, b"x");
393        // Set a reserved flag bit (bit 4; bit 3 is the defined
394        // SHARED_INLINE flag — see issue #186).
395        bytes[INODE_FIXED_PREFIX_LEN - 1] |= 0x10;
396        let mut cursor = ManifestCursor::new(&bytes);
397        match parse_inode(&mut cursor) {
398            Err(CoreError::Corrupt { reason }) => {
399                assert!(reason.contains("reserved"), "got: {reason}");
400            }
401            other => panic!("expected Corrupt, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn rejects_unknown_file_type() {
407        let mut bytes = Vec::new();
408        bytes.extend_from_slice(&1u64.to_le_bytes());
409        bytes.extend_from_slice(&0x0000u32.to_le_bytes()); // mode = unknown type
410        bytes.extend_from_slice(&0u32.to_le_bytes()); // uid
411        bytes.extend_from_slice(&0u32.to_le_bytes()); // gid
412        bytes.extend_from_slice(&0u64.to_le_bytes()); // mtime
413        bytes.extend_from_slice(&0u64.to_le_bytes()); // ctime
414        bytes.extend_from_slice(&1u32.to_le_bytes()); // nlink
415        bytes.push(0); // flags
416        let mut cursor = ManifestCursor::new(&bytes);
417        match parse_inode(&mut cursor) {
418            Err(CoreError::Corrupt { reason }) => {
419                assert!(reason.contains("unknown file type"), "got: {reason}");
420            }
421            other => panic!("expected Corrupt, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn parses_with_atime() {
427        let mode = S_IFREG | 0o644;
428        let mut bytes = Vec::new();
429        bytes.extend_from_slice(&1u64.to_le_bytes());
430        bytes.extend_from_slice(&mode.to_le_bytes());
431        bytes.extend_from_slice(&0u32.to_le_bytes()); // uid
432        bytes.extend_from_slice(&0u32.to_le_bytes()); // gid
433        bytes.extend_from_slice(&0u64.to_le_bytes()); // mtime
434        bytes.extend_from_slice(&0u64.to_le_bytes()); // ctime
435        bytes.extend_from_slice(&1u32.to_le_bytes()); // nlink
436        bytes.push(INODE_FLAG_ATIME); // flags = atime only
437        bytes.extend_from_slice(&999u64.to_le_bytes()); // atime_ns
438        bytes.extend_from_slice(&0u32.to_le_bytes()); // slice_count = 0 (empty slice map)
439        let mut cursor = ManifestCursor::new(&bytes);
440        let inode = parse_inode(&mut cursor).expect("atime inode parses");
441        assert_eq!(inode.atime_ns, Some(999));
442    }
443
444    #[test]
445    fn rejects_inline_above_ceiling() {
446        let oversized = vec![0xFF; (DEFAULT_INLINE_DATA_MAX_BYTES as usize) + 1];
447        let bytes = make_regular_inline_inode(1, S_IFREG | 0o644, &oversized);
448        let mut cursor = ManifestCursor::new(&bytes);
449        match parse_inode(&mut cursor) {
450            Err(CoreError::Corrupt { reason }) => {
451                assert!(reason.contains("ceiling"), "got: {reason}");
452            }
453            other => panic!("expected Corrupt, got {other:?}"),
454        }
455    }
456}