Skip to main content

ext4fs/ondisk/
inode.rs

1#![forbid(unsafe_code)]
2use crate::error::{Ext4Error, Result};
3use crate::ondisk::superblock::EXT4_CRC32C;
4use bitflags::bitflags;
5use crc::Crc;
6
7// ---------------------------------------------------------------------------
8// Timestamp
9// ---------------------------------------------------------------------------
10
11/// A nanosecond-precision timestamp as used in ext4 inodes.
12///
13/// The `seconds` field is the full 34-bit signed epoch extended via the two
14/// low bits of the "extra" word. `nanoseconds` is always in [0, 999_999_999].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub struct Timestamp {
17    pub seconds: i64,
18    pub nanoseconds: u32,
19}
20
21impl PartialOrd for Timestamp {
22    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
23        Some(self.cmp(other))
24    }
25}
26
27impl Ord for Timestamp {
28    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
29        self.seconds
30            .cmp(&other.seconds)
31            .then(self.nanoseconds.cmp(&other.nanoseconds))
32    }
33}
34
35/// Decode a 32-bit inode timestamp, optionally extended by a 32-bit "extra"
36/// word that carries epoch bits [1:0] and nanoseconds in bits [31:2].
37///
38/// Without an extra word the 32-bit value is sign-extended to i64 (covering
39/// dates up to 2038). With the extra word the epoch extension shifts the
40/// range to cover dates up to ~2446.
41fn decode_timestamp(secs_raw: u32, extra: Option<u32>) -> Timestamp {
42    match extra {
43        None => Timestamp {
44            seconds: i64::from(secs_raw as i32),
45            nanoseconds: 0,
46        },
47        Some(ex) => {
48            let epoch_bits = i64::from(ex & 0x3);
49            let nanoseconds = ex >> 2;
50            // The 34-bit seconds: upper 2 bits come from the extra word, the
51            // lower 32 bits are the raw seconds field treated as unsigned.
52            let seconds = ((epoch_bits) << 32) | i64::from(secs_raw);
53            Timestamp {
54                seconds,
55                nanoseconds,
56            }
57        }
58    }
59}
60
61// ---------------------------------------------------------------------------
62// InodeFlags
63// ---------------------------------------------------------------------------
64
65bitflags! {
66    /// Inode flags (i_flags field at offset 0x20).
67    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68    pub struct InodeFlags: u32 {
69        const SYNC         = 0x0000_0010;
70        const IMMUTABLE    = 0x0000_0020;
71        const APPEND       = 0x0000_0040;
72        const NODUMP       = 0x0000_0080;
73        const NOATIME      = 0x0000_0100;
74        const ENCRYPT      = 0x0000_0800;
75        const INDEX        = 0x0000_1000;
76        const HUGE_FILE    = 0x0004_0000;
77        const EXTENTS      = 0x0008_0000;
78        const EA_INODE     = 0x0020_0000;
79        const INLINE_DATA  = 0x1000_0000;
80        const CASEFOLD     = 0x2000_0000;
81        const VERITY       = 0x8000_0000;
82    }
83}
84
85// ---------------------------------------------------------------------------
86// FileType
87// ---------------------------------------------------------------------------
88
89/// File type derived from the high 4 bits of `i_mode`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum FileType {
92    Unknown,
93    Fifo,
94    CharDevice,
95    Directory,
96    BlockDevice,
97    RegularFile,
98    Symlink,
99    Socket,
100}
101
102impl FileType {
103    fn from_mode(mode: u16) -> Self {
104        match mode >> 12 {
105            0x1 => FileType::Fifo,
106            0x2 => FileType::CharDevice,
107            0x4 => FileType::Directory,
108            0x6 => FileType::BlockDevice,
109            0x8 => FileType::RegularFile,
110            0xA => FileType::Symlink,
111            0xC => FileType::Socket,
112            _ => FileType::Unknown,
113        }
114    }
115}
116
117// ---------------------------------------------------------------------------
118// Inode
119// ---------------------------------------------------------------------------
120
121/// Parsed on-disk ext4 inode (128-byte base + optional extended fields).
122#[derive(Debug, Clone)]
123pub struct Inode {
124    // --- mode / ownership ---
125    pub mode: u16,
126    pub uid: u32,
127    pub gid: u32,
128
129    // --- size ---
130    /// Full 64-bit file size (i_size_lo | i_size_hi << 32).
131    pub size: u64,
132
133    // --- timestamps ---
134    pub atime: Timestamp,
135    pub ctime: Timestamp,
136    pub mtime: Timestamp,
137    /// Deletion time (raw 32-bit seconds; 0 = not deleted).
138    pub dtime: u32,
139    /// Creation time — only valid when `extra_isize` >= 28.
140    pub crtime: Timestamp,
141
142    // --- link / block counts ---
143    pub links_count: u16,
144    /// Full 48-bit block count in 512-byte units (i_blocks_lo | i_blocks_hi << 32).
145    pub blocks_count: u64,
146
147    // --- flags ---
148    pub flags: InodeFlags,
149
150    // --- raw block map / extent tree ---
151    pub i_block: [u8; 60],
152
153    // --- misc ---
154    pub generation: u32,
155    pub file_acl: u64,
156    pub extra_isize: u16,
157    /// Combined 32-bit checksum (lo 16 at 0x7C, hi 16 at 0x82).
158    pub checksum: u32,
159    pub projid: u32,
160}
161
162// Minimum size of the fixed 128-byte inode base structure.
163const INODE_BASE_SIZE: usize = 128;
164
165impl Inode {
166    /// Parse an inode from `buf`.
167    ///
168    /// `inode_size` is the value from the superblock (`s_inode_size`); it
169    /// determines how many bytes are available for extended fields.
170    pub fn parse(buf: &[u8], inode_size: u16) -> Result<Self> {
171        let inode_size = inode_size as usize;
172        let required = INODE_BASE_SIZE.min(inode_size);
173        if buf.len() < required || buf.len() < INODE_BASE_SIZE {
174            return Err(Ext4Error::TooShort {
175                structure: "inode",
176                expected: INODE_BASE_SIZE,
177                found: buf.len(),
178            });
179        }
180
181        // --- helpers ---
182        let u16_at = |off: usize| -> u16 { u16::from_le_bytes([buf[off], buf[off + 1]]) };
183        let u32_at = |off: usize| -> u32 {
184            u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
185        };
186
187        // --- base fields ---
188        let mode = u16_at(0x00);
189        let uid_lo = u32::from(u16_at(0x02));
190        let size_lo = u64::from(u32_at(0x04));
191        let atime_raw = u32_at(0x08);
192        let ctime_raw = u32_at(0x0C);
193        let mtime_raw = u32_at(0x10);
194        let dtime = u32_at(0x14);
195        let gid_lo = u32::from(u16_at(0x18));
196        let links_count = u16_at(0x1A);
197        let blocks_lo = u64::from(u32_at(0x1C));
198        let flags_raw = u32_at(0x20);
199
200        let mut i_block = [0u8; 60];
201        i_block.copy_from_slice(&buf[0x28..0x64]);
202
203        let generation = u32_at(0x64);
204        let file_acl_lo = u64::from(u32_at(0x68));
205        let size_hi = u64::from(u32_at(0x6C));
206        let blocks_hi = u64::from(u16_at(0x74));
207        let file_acl_hi = u64::from(u16_at(0x76));
208        let uid_hi = u32::from(u16_at(0x78));
209        let gid_hi = u32::from(u16_at(0x7A));
210        let checksum_lo = u32::from(u16_at(0x7C));
211
212        // --- extended fields (present when inode_size > 128 and buf is large enough) ---
213        let (
214            extra_isize,
215            checksum_hi,
216            ctime_extra,
217            mtime_extra,
218            atime_extra,
219            crtime_raw,
220            crtime_extra,
221            projid,
222        ) = if buf.len() > INODE_BASE_SIZE {
223            let extra_isize = u16_at(0x80);
224            let checksum_hi = u32::from(u16_at(0x82));
225            // Extended timestamps are present when extra_isize >= 28 (covers up to 0x94+4 = 0x98).
226            // extra_isize field is at 0x80; extended ts start at 0x84.
227            // We need buf to contain at least 0x80 + extra_isize bytes.
228            let ext_end = 0x80 + (extra_isize as usize);
229            let (ctime_extra, mtime_extra, atime_extra, crtime_raw, crtime_extra) =
230                if extra_isize >= 28 && buf.len() >= ext_end {
231                    (
232                        Some(u32_at(0x84)),
233                        Some(u32_at(0x88)),
234                        Some(u32_at(0x8C)),
235                        Some(u32_at(0x90)),
236                        Some(u32_at(0x94)),
237                    )
238                } else {
239                    (None, None, None, None, None)
240                };
241            // projid at 0x9C (extra_isize >= 32 means 0x80+32=0xA0, but projid is at 0x9C = offset 28 from 0x80+4)
242            let projid = if extra_isize >= 32 && buf.len() >= 0xA0 {
243                u32_at(0x9C)
244            } else {
245                0
246            };
247            (
248                extra_isize,
249                checksum_hi,
250                ctime_extra,
251                mtime_extra,
252                atime_extra,
253                crtime_raw,
254                crtime_extra,
255                projid,
256            )
257        } else {
258            (0, 0, None, None, None, None, None, 0)
259        };
260
261        // --- compose multi-word fields ---
262        let uid = uid_lo | (uid_hi << 16);
263        let gid = gid_lo | (gid_hi << 16);
264        let size = size_lo | (size_hi << 32);
265        let blocks_count = blocks_lo | (blocks_hi << 32);
266        let file_acl = file_acl_lo | (file_acl_hi << 32);
267        let checksum = checksum_lo | (checksum_hi << 16);
268        let flags = InodeFlags::from_bits_truncate(flags_raw);
269
270        // --- timestamps ---
271        let atime = decode_timestamp(atime_raw, atime_extra);
272        let ctime = decode_timestamp(ctime_raw, ctime_extra);
273        let mtime = decode_timestamp(mtime_raw, mtime_extra);
274        let crtime = match (crtime_raw, crtime_extra) {
275            (Some(secs), extra) => decode_timestamp(secs, extra),
276            _ => Timestamp::default(),
277        };
278
279        Ok(Inode {
280            mode,
281            uid,
282            gid,
283            size,
284            atime,
285            ctime,
286            mtime,
287            dtime,
288            crtime,
289            links_count,
290            blocks_count,
291            flags,
292            i_block,
293            generation,
294            file_acl,
295            extra_isize,
296            checksum,
297            projid,
298        })
299    }
300
301    /// Extract the file type from the high nibble of `i_mode`.
302    pub fn file_type(&self) -> FileType {
303        FileType::from_mode(self.mode)
304    }
305
306    /// Extract the permission bits (low 12 bits of `i_mode`).
307    pub fn mode_permissions(&self) -> u16 {
308        self.mode & 0x0FFF
309    }
310
311    /// True if this inode uses the extent tree (EXTENTS flag set).
312    pub fn uses_extents(&self) -> bool {
313        self.flags.contains(InodeFlags::EXTENTS)
314    }
315
316    /// True if this inode stores data inline in `i_block`.
317    pub fn has_inline_data(&self) -> bool {
318        self.flags.contains(InodeFlags::INLINE_DATA)
319    }
320
321    /// True if this directory uses an HTree (htree/INDEX flag set).
322    pub fn has_htree(&self) -> bool {
323        self.flags.contains(InodeFlags::INDEX)
324    }
325
326    /// True if the inode has been deleted (`dtime` != 0).
327    ///
328    /// This is the authoritative deletion marker in ext4. When the kernel
329    /// deletes a file, it sets `dtime` to the current time. An inode with
330    /// `links_count == 0` but `dtime == 0` is an **orphan** (unlinked while
331    /// still open, or system crashed mid-deletion) — a forensically distinct
332    /// state. Use [`is_orphan`] to detect those.
333    pub fn is_deleted(&self) -> bool {
334        self.dtime != 0
335    }
336
337    /// Verify the inode's CRC32C checksum (METADATA_CSUM feature).
338    ///
339    /// The algorithm:
340    /// 1. Seed from `csum_seed` (or `crc32c(0xFFFFFFFF, uuid)` if zero)
341    /// 2. Feed `le32(ino)` then `le32(generation)`
342    /// 3. Feed the full raw inode bytes with both checksum fields zeroed
343    /// 4. Compare the 32-bit result against `self.checksum`
344    pub fn verify_checksum(
345        &self,
346        raw_buf: &[u8],
347        uuid: &[u8; 16],
348        ino: u32,
349        generation: u32,
350        csum_seed: u32,
351    ) -> bool {
352        let crc32c = Crc::<u32>::new(&EXT4_CRC32C);
353
354        let seed = if csum_seed != 0 {
355            csum_seed
356        } else {
357            let mut d = crc32c.digest();
358            d.update(uuid);
359            d.finalize()
360        };
361
362        let mut digest = crc32c.digest_with_initial(seed.reverse_bits());
363        digest.update(&ino.to_le_bytes());
364        digest.update(&generation.to_le_bytes());
365
366        // Zero out the checksum fields before computing
367        let mut buf = raw_buf.to_vec();
368        // lo16 at 0x7C
369        if buf.len() > 0x7E {
370            buf[0x7C] = 0;
371            buf[0x7D] = 0;
372        }
373        // hi16 at 0x82 (only if extended inode)
374        if buf.len() > 0x84 {
375            buf[0x82] = 0;
376            buf[0x83] = 0;
377        }
378        digest.update(&buf);
379
380        let computed = digest.finalize();
381        computed == self.checksum
382    }
383
384    /// True if the inode is an orphan: unlinked (`links_count == 0`) but
385    /// never fully deleted (`dtime == 0`) and still has content (`mode != 0`).
386    ///
387    /// Orphans typically result from:
388    /// - A file unlinked while still held open by a process (common temp file pattern)
389    /// - A system crash during file deletion before `dtime` was written
390    /// - An unclean shutdown leaving inodes in the orphan list
391    ///
392    /// Forensically distinct from deleted inodes — orphans were not
393    /// intentionally removed by the user/process at the time of imaging.
394    pub fn is_orphan(&self) -> bool {
395        self.links_count == 0 && self.dtime == 0 && self.mode != 0
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Tests
401// ---------------------------------------------------------------------------
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn make_inode_bytes(mode: u16, size: u32) -> Vec<u8> {
408        let mut buf = vec![0u8; 256];
409        buf[0x00] = (mode & 0xFF) as u8;
410        buf[0x01] = (mode >> 8) as u8;
411        buf[0x04] = (size & 0xFF) as u8;
412        buf[0x05] = ((size >> 8) & 0xFF) as u8;
413        buf[0x06] = ((size >> 16) & 0xFF) as u8;
414        buf[0x07] = ((size >> 24) & 0xFF) as u8;
415        let atime: u32 = 1_700_000_000;
416        buf[0x08..0x0C].copy_from_slice(&atime.to_le_bytes());
417        buf[0x1A] = 1; // links_count
418        buf[0x20] = 0x00;
419        buf[0x21] = 0x00;
420        buf[0x22] = 0x08;
421        buf[0x23] = 0x00; // EXTENTS flag
422        buf[0x80] = 32; // extra_isize
423        let crtime: u32 = 1_699_000_000;
424        buf[0x90..0x94].copy_from_slice(&crtime.to_le_bytes());
425        buf
426    }
427
428    #[test]
429    fn parse_regular_file_inode() {
430        let buf = make_inode_bytes(0x8180, 12);
431        let inode = Inode::parse(&buf, 256).unwrap();
432        assert_eq!(inode.file_type(), FileType::RegularFile);
433        assert_eq!(inode.mode_permissions(), 0o600);
434        assert_eq!(inode.size, 12);
435        assert_eq!(inode.links_count, 1);
436        assert!(inode.flags.contains(InodeFlags::EXTENTS));
437        assert_eq!(inode.atime.seconds, 1_700_000_000);
438        assert_eq!(inode.crtime.seconds, 1_699_000_000);
439    }
440
441    #[test]
442    fn parse_directory_inode() {
443        let buf = make_inode_bytes(0x4180, 4096);
444        let inode = Inode::parse(&buf, 256).unwrap();
445        assert_eq!(inode.file_type(), FileType::Directory);
446    }
447
448    #[test]
449    fn timestamp_nanoseconds() {
450        let mut buf = make_inode_bytes(0x8180, 100);
451        let extra = 500_000_000u32 << 2;
452        buf[0x8C..0x90].copy_from_slice(&extra.to_le_bytes());
453        let inode = Inode::parse(&buf, 256).unwrap();
454        assert_eq!(inode.atime.nanoseconds, 500_000_000);
455    }
456
457    #[test]
458    fn dtime_set_means_deleted() {
459        let mut buf = make_inode_bytes(0x8180, 100);
460        let dtime: u32 = 1_700_001_000;
461        buf[0x14..0x18].copy_from_slice(&dtime.to_le_bytes());
462        buf[0x1A] = 0;
463        let inode = Inode::parse(&buf, 256).unwrap();
464        assert_eq!(inode.dtime, 1_700_001_000);
465        assert_eq!(inode.links_count, 0);
466    }
467
468    #[test]
469    fn i_block_60_bytes() {
470        let mut buf = make_inode_bytes(0x8180, 100);
471        for i in 0..60 {
472            buf[0x28 + i] = i as u8;
473        }
474        let inode = Inode::parse(&buf, 256).unwrap();
475        assert_eq!(inode.i_block.len(), 60);
476        assert_eq!(inode.i_block[0], 0);
477        assert_eq!(inode.i_block[59], 59);
478    }
479
480    #[test]
481    fn verify_inode_checksum_on_forensic_img() {
482        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
483        let data = if let Ok(d) = std::fs::read(path) {
484            d
485        } else {
486            eprintln!("skip: forensic.img not found");
487            return;
488        };
489        use crate::ondisk::superblock::Superblock;
490        let sb = Superblock::parse(&data[1024..]).unwrap();
491        assert!(
492            sb.has_metadata_csum(),
493            "forensic.img should have metadata_csum"
494        );
495
496        let inode_size = sb.inode_size;
497        let inodes_per_group = sb.inodes_per_group;
498
499        // Read group descriptor to find inode table
500        let desc_size = sb.desc_size;
501        let gdt_offset = sb.block_size as usize;
502        use crate::ondisk::group_desc::GroupDescriptor;
503        let gd = GroupDescriptor::parse(
504            &data[gdt_offset..gdt_offset + desc_size as usize],
505            desc_size,
506        )
507        .unwrap();
508
509        // Read inode 12 (hello.txt) raw bytes from inode table
510        let ino: u32 = 12;
511        let index = (ino - 1) % inodes_per_group;
512        let byte_offset =
513            gd.inode_table * u64::from(sb.block_size) + u64::from(index) * u64::from(inode_size);
514        let raw = &data[byte_offset as usize..(byte_offset + u64::from(inode_size)) as usize];
515        let inode = Inode::parse(raw, inode_size).unwrap();
516
517        assert!(
518            inode.verify_checksum(raw, &sb.uuid, ino, inode.generation, sb.checksum_seed),
519            "inode 12 checksum should verify"
520        );
521    }
522
523    #[test]
524    fn inode_predicate_uses_extents() {
525        let buf = make_inode_bytes(0x8180, 100);
526        let inode = Inode::parse(&buf, 256).unwrap();
527        assert!(inode.uses_extents());
528    }
529
530    #[test]
531    fn inode_predicate_has_inline_data() {
532        let mut buf = make_inode_bytes(0x8180, 100);
533        // Set INLINE_DATA flag (0x1000_0000)
534        let flags = 0x1000_0000u32;
535        buf[0x20..0x24].copy_from_slice(&flags.to_le_bytes());
536        let inode = Inode::parse(&buf, 256).unwrap();
537        assert!(inode.has_inline_data());
538        assert!(!inode.uses_extents());
539    }
540
541    #[test]
542    fn inode_predicate_has_htree() {
543        let mut buf = make_inode_bytes(0x4180, 4096); // directory
544                                                      // Set INDEX flag (0x1000)
545        let flags = 0x1000u32;
546        buf[0x20..0x24].copy_from_slice(&flags.to_le_bytes());
547        let inode = Inode::parse(&buf, 256).unwrap();
548        assert!(inode.has_htree());
549    }
550
551    #[test]
552    fn inode_predicate_is_deleted() {
553        let mut buf = make_inode_bytes(0x8180, 100);
554        buf[0x14..0x18].copy_from_slice(&1000u32.to_le_bytes()); // dtime
555        let inode = Inode::parse(&buf, 256).unwrap();
556        assert!(inode.is_deleted());
557        assert!(!inode.is_orphan());
558    }
559
560    #[test]
561    fn inode_predicate_is_orphan() {
562        let mut buf = make_inode_bytes(0x8180, 100);
563        buf[0x1A..0x1C].copy_from_slice(&0u16.to_le_bytes()); // links_count = 0
564                                                              // dtime stays 0
565        let inode = Inode::parse(&buf, 256).unwrap();
566        assert!(inode.is_orphan());
567        assert!(!inode.is_deleted());
568    }
569
570    #[test]
571    fn reject_too_short() {
572        let buf = vec![0u8; 50];
573        let err = Inode::parse(&buf, 256).unwrap_err();
574        assert!(matches!(err, crate::error::Ext4Error::TooShort { .. }));
575    }
576}