ext4fs-core 0.2.0

Forensic-grade ext4 filesystem parser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
#![forbid(unsafe_code)]
use crate::error::{Ext4Error, Result};
use crate::ondisk::superblock::EXT4_CRC32C;
use bitflags::bitflags;
use crc::Crc;

// ---------------------------------------------------------------------------
// Timestamp
// ---------------------------------------------------------------------------

/// A nanosecond-precision timestamp as used in ext4 inodes.
///
/// The `seconds` field is the full 34-bit signed epoch extended via the two
/// low bits of the "extra" word. `nanoseconds` is always in [0, 999_999_999].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Timestamp {
    pub seconds: i64,
    pub nanoseconds: u32,
}

impl PartialOrd for Timestamp {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Timestamp {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.seconds
            .cmp(&other.seconds)
            .then(self.nanoseconds.cmp(&other.nanoseconds))
    }
}

/// Decode a 32-bit inode timestamp, optionally extended by a 32-bit "extra"
/// word that carries epoch bits [1:0] and nanoseconds in bits [31:2].
///
/// Without an extra word the 32-bit value is sign-extended to i64 (covering
/// dates up to 2038). With the extra word the epoch extension shifts the
/// range to cover dates up to ~2446.
fn decode_timestamp(secs_raw: u32, extra: Option<u32>) -> Timestamp {
    match extra {
        None => Timestamp {
            seconds: i64::from(secs_raw as i32),
            nanoseconds: 0,
        },
        Some(ex) => {
            let epoch_bits = i64::from(ex & 0x3);
            let nanoseconds = ex >> 2;
            // The 34-bit seconds: upper 2 bits come from the extra word, the
            // lower 32 bits are the raw seconds field treated as unsigned.
            let seconds = ((epoch_bits) << 32) | i64::from(secs_raw);
            Timestamp {
                seconds,
                nanoseconds,
            }
        }
    }
}

// ---------------------------------------------------------------------------
// InodeFlags
// ---------------------------------------------------------------------------

bitflags! {
    /// Inode flags (i_flags field at offset 0x20).
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct InodeFlags: u32 {
        const SYNC         = 0x0000_0010;
        const IMMUTABLE    = 0x0000_0020;
        const APPEND       = 0x0000_0040;
        const NODUMP       = 0x0000_0080;
        const NOATIME      = 0x0000_0100;
        const ENCRYPT      = 0x0000_0800;
        const INDEX        = 0x0000_1000;
        const HUGE_FILE    = 0x0004_0000;
        const EXTENTS      = 0x0008_0000;
        const EA_INODE     = 0x0020_0000;
        const INLINE_DATA  = 0x1000_0000;
        const CASEFOLD     = 0x2000_0000;
        const VERITY       = 0x8000_0000;
    }
}

// ---------------------------------------------------------------------------
// FileType
// ---------------------------------------------------------------------------

/// File type derived from the high 4 bits of `i_mode`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
    Unknown,
    Fifo,
    CharDevice,
    Directory,
    BlockDevice,
    RegularFile,
    Symlink,
    Socket,
}

impl FileType {
    fn from_mode(mode: u16) -> Self {
        match mode >> 12 {
            0x1 => FileType::Fifo,
            0x2 => FileType::CharDevice,
            0x4 => FileType::Directory,
            0x6 => FileType::BlockDevice,
            0x8 => FileType::RegularFile,
            0xA => FileType::Symlink,
            0xC => FileType::Socket,
            _ => FileType::Unknown,
        }
    }
}

// ---------------------------------------------------------------------------
// Inode
// ---------------------------------------------------------------------------

/// Parsed on-disk ext4 inode (128-byte base + optional extended fields).
#[derive(Debug, Clone)]
pub struct Inode {
    // --- mode / ownership ---
    pub mode: u16,
    pub uid: u32,
    pub gid: u32,

    // --- size ---
    /// Full 64-bit file size (i_size_lo | i_size_hi << 32).
    pub size: u64,

    // --- timestamps ---
    pub atime: Timestamp,
    pub ctime: Timestamp,
    pub mtime: Timestamp,
    /// Deletion time (raw 32-bit seconds; 0 = not deleted).
    pub dtime: u32,
    /// Creation time — only valid when `extra_isize` >= 28.
    pub crtime: Timestamp,

    // --- link / block counts ---
    pub links_count: u16,
    /// Full 48-bit block count in 512-byte units (i_blocks_lo | i_blocks_hi << 32).
    pub blocks_count: u64,

    // --- flags ---
    pub flags: InodeFlags,

    // --- raw block map / extent tree ---
    pub i_block: [u8; 60],

    // --- misc ---
    pub generation: u32,
    pub file_acl: u64,
    pub extra_isize: u16,
    /// Combined 32-bit checksum (lo 16 at 0x7C, hi 16 at 0x82).
    pub checksum: u32,
    pub projid: u32,
}

// Minimum size of the fixed 128-byte inode base structure.
const INODE_BASE_SIZE: usize = 128;

impl Inode {
    /// Parse an inode from `buf`.
    ///
    /// `inode_size` is the value from the superblock (`s_inode_size`); it
    /// determines how many bytes are available for extended fields.
    pub fn parse(buf: &[u8], inode_size: u16) -> Result<Self> {
        let inode_size = inode_size as usize;
        let required = INODE_BASE_SIZE.min(inode_size);
        if buf.len() < required || buf.len() < INODE_BASE_SIZE {
            return Err(Ext4Error::TooShort {
                structure: "inode",
                expected: INODE_BASE_SIZE,
                found: buf.len(),
            });
        }

        // --- helpers ---
        let u16_at = |off: usize| -> u16 { u16::from_le_bytes([buf[off], buf[off + 1]]) };
        let u32_at = |off: usize| -> u32 {
            u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
        };

        // --- base fields ---
        let mode = u16_at(0x00);
        let uid_lo = u32::from(u16_at(0x02));
        let size_lo = u64::from(u32_at(0x04));
        let atime_raw = u32_at(0x08);
        let ctime_raw = u32_at(0x0C);
        let mtime_raw = u32_at(0x10);
        let dtime = u32_at(0x14);
        let gid_lo = u32::from(u16_at(0x18));
        let links_count = u16_at(0x1A);
        let blocks_lo = u64::from(u32_at(0x1C));
        let flags_raw = u32_at(0x20);

        let mut i_block = [0u8; 60];
        i_block.copy_from_slice(&buf[0x28..0x64]);

        let generation = u32_at(0x64);
        let file_acl_lo = u64::from(u32_at(0x68));
        let size_hi = u64::from(u32_at(0x6C));
        let blocks_hi = u64::from(u16_at(0x74));
        let file_acl_hi = u64::from(u16_at(0x76));
        let uid_hi = u32::from(u16_at(0x78));
        let gid_hi = u32::from(u16_at(0x7A));
        let checksum_lo = u32::from(u16_at(0x7C));

        // --- extended fields (present when inode_size > 128 and buf is large enough) ---
        let (
            extra_isize,
            checksum_hi,
            ctime_extra,
            mtime_extra,
            atime_extra,
            crtime_raw,
            crtime_extra,
            projid,
        ) = if buf.len() > INODE_BASE_SIZE {
            let extra_isize = u16_at(0x80);
            let checksum_hi = u32::from(u16_at(0x82));
            // Extended timestamps are present when extra_isize >= 28 (covers up to 0x94+4 = 0x98).
            // extra_isize field is at 0x80; extended ts start at 0x84.
            // We need buf to contain at least 0x80 + extra_isize bytes.
            let ext_end = 0x80 + (extra_isize as usize);
            let (ctime_extra, mtime_extra, atime_extra, crtime_raw, crtime_extra) =
                if extra_isize >= 28 && buf.len() >= ext_end {
                    (
                        Some(u32_at(0x84)),
                        Some(u32_at(0x88)),
                        Some(u32_at(0x8C)),
                        Some(u32_at(0x90)),
                        Some(u32_at(0x94)),
                    )
                } else {
                    (None, None, None, None, None)
                };
            // projid at 0x9C (extra_isize >= 32 means 0x80+32=0xA0, but projid is at 0x9C = offset 28 from 0x80+4)
            let projid = if extra_isize >= 32 && buf.len() >= 0xA0 {
                u32_at(0x9C)
            } else {
                0
            };
            (
                extra_isize,
                checksum_hi,
                ctime_extra,
                mtime_extra,
                atime_extra,
                crtime_raw,
                crtime_extra,
                projid,
            )
        } else {
            (0, 0, None, None, None, None, None, 0)
        };

        // --- compose multi-word fields ---
        let uid = uid_lo | (uid_hi << 16);
        let gid = gid_lo | (gid_hi << 16);
        let size = size_lo | (size_hi << 32);
        let blocks_count = blocks_lo | (blocks_hi << 32);
        let file_acl = file_acl_lo | (file_acl_hi << 32);
        let checksum = checksum_lo | (checksum_hi << 16);
        let flags = InodeFlags::from_bits_truncate(flags_raw);

        // --- timestamps ---
        let atime = decode_timestamp(atime_raw, atime_extra);
        let ctime = decode_timestamp(ctime_raw, ctime_extra);
        let mtime = decode_timestamp(mtime_raw, mtime_extra);
        let crtime = match (crtime_raw, crtime_extra) {
            (Some(secs), extra) => decode_timestamp(secs, extra),
            _ => Timestamp::default(),
        };

        Ok(Inode {
            mode,
            uid,
            gid,
            size,
            atime,
            ctime,
            mtime,
            dtime,
            crtime,
            links_count,
            blocks_count,
            flags,
            i_block,
            generation,
            file_acl,
            extra_isize,
            checksum,
            projid,
        })
    }

    /// Extract the file type from the high nibble of `i_mode`.
    pub fn file_type(&self) -> FileType {
        FileType::from_mode(self.mode)
    }

    /// Extract the permission bits (low 12 bits of `i_mode`).
    pub fn mode_permissions(&self) -> u16 {
        self.mode & 0x0FFF
    }

    /// True if this inode uses the extent tree (EXTENTS flag set).
    pub fn uses_extents(&self) -> bool {
        self.flags.contains(InodeFlags::EXTENTS)
    }

    /// True if this inode stores data inline in `i_block`.
    pub fn has_inline_data(&self) -> bool {
        self.flags.contains(InodeFlags::INLINE_DATA)
    }

    /// True if this directory uses an HTree (htree/INDEX flag set).
    pub fn has_htree(&self) -> bool {
        self.flags.contains(InodeFlags::INDEX)
    }

    /// True if the inode has been deleted (`dtime` != 0).
    ///
    /// This is the authoritative deletion marker in ext4. When the kernel
    /// deletes a file, it sets `dtime` to the current time. An inode with
    /// `links_count == 0` but `dtime == 0` is an **orphan** (unlinked while
    /// still open, or system crashed mid-deletion) — a forensically distinct
    /// state. Use [`is_orphan`] to detect those.
    pub fn is_deleted(&self) -> bool {
        self.dtime != 0
    }

    /// Verify the inode's CRC32C checksum (METADATA_CSUM feature).
    ///
    /// The algorithm:
    /// 1. Seed from `csum_seed` (or `crc32c(0xFFFFFFFF, uuid)` if zero)
    /// 2. Feed `le32(ino)` then `le32(generation)`
    /// 3. Feed the full raw inode bytes with both checksum fields zeroed
    /// 4. Compare the 32-bit result against `self.checksum`
    pub fn verify_checksum(
        &self,
        raw_buf: &[u8],
        uuid: &[u8; 16],
        ino: u32,
        generation: u32,
        csum_seed: u32,
    ) -> bool {
        let crc32c = Crc::<u32>::new(&EXT4_CRC32C);

        let seed = if csum_seed != 0 {
            csum_seed
        } else {
            let mut d = crc32c.digest();
            d.update(uuid);
            d.finalize()
        };

        let mut digest = crc32c.digest_with_initial(seed.reverse_bits());
        digest.update(&ino.to_le_bytes());
        digest.update(&generation.to_le_bytes());

        // Zero out the checksum fields before computing
        let mut buf = raw_buf.to_vec();
        // lo16 at 0x7C
        if buf.len() > 0x7E {
            buf[0x7C] = 0;
            buf[0x7D] = 0;
        }
        // hi16 at 0x82 (only if extended inode)
        if buf.len() > 0x84 {
            buf[0x82] = 0;
            buf[0x83] = 0;
        }
        digest.update(&buf);

        let computed = digest.finalize();
        computed == self.checksum
    }

    /// True if the inode is an orphan: unlinked (`links_count == 0`) but
    /// never fully deleted (`dtime == 0`) and still has content (`mode != 0`).
    ///
    /// Orphans typically result from:
    /// - A file unlinked while still held open by a process (common temp file pattern)
    /// - A system crash during file deletion before `dtime` was written
    /// - An unclean shutdown leaving inodes in the orphan list
    ///
    /// Forensically distinct from deleted inodes — orphans were not
    /// intentionally removed by the user/process at the time of imaging.
    pub fn is_orphan(&self) -> bool {
        self.links_count == 0 && self.dtime == 0 && self.mode != 0
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn make_inode_bytes(mode: u16, size: u32) -> Vec<u8> {
        let mut buf = vec![0u8; 256];
        buf[0x00] = (mode & 0xFF) as u8;
        buf[0x01] = (mode >> 8) as u8;
        buf[0x04] = (size & 0xFF) as u8;
        buf[0x05] = ((size >> 8) & 0xFF) as u8;
        buf[0x06] = ((size >> 16) & 0xFF) as u8;
        buf[0x07] = ((size >> 24) & 0xFF) as u8;
        let atime: u32 = 1_700_000_000;
        buf[0x08..0x0C].copy_from_slice(&atime.to_le_bytes());
        buf[0x1A] = 1; // links_count
        buf[0x20] = 0x00;
        buf[0x21] = 0x00;
        buf[0x22] = 0x08;
        buf[0x23] = 0x00; // EXTENTS flag
        buf[0x80] = 32; // extra_isize
        let crtime: u32 = 1_699_000_000;
        buf[0x90..0x94].copy_from_slice(&crtime.to_le_bytes());
        buf
    }

    #[test]
    fn parse_regular_file_inode() {
        let buf = make_inode_bytes(0x8180, 12);
        let inode = Inode::parse(&buf, 256).unwrap();
        assert_eq!(inode.file_type(), FileType::RegularFile);
        assert_eq!(inode.mode_permissions(), 0o600);
        assert_eq!(inode.size, 12);
        assert_eq!(inode.links_count, 1);
        assert!(inode.flags.contains(InodeFlags::EXTENTS));
        assert_eq!(inode.atime.seconds, 1_700_000_000);
        assert_eq!(inode.crtime.seconds, 1_699_000_000);
    }

    #[test]
    fn parse_directory_inode() {
        let buf = make_inode_bytes(0x4180, 4096);
        let inode = Inode::parse(&buf, 256).unwrap();
        assert_eq!(inode.file_type(), FileType::Directory);
    }

    #[test]
    fn timestamp_nanoseconds() {
        let mut buf = make_inode_bytes(0x8180, 100);
        let extra = 500_000_000u32 << 2;
        buf[0x8C..0x90].copy_from_slice(&extra.to_le_bytes());
        let inode = Inode::parse(&buf, 256).unwrap();
        assert_eq!(inode.atime.nanoseconds, 500_000_000);
    }

    #[test]
    fn dtime_set_means_deleted() {
        let mut buf = make_inode_bytes(0x8180, 100);
        let dtime: u32 = 1_700_001_000;
        buf[0x14..0x18].copy_from_slice(&dtime.to_le_bytes());
        buf[0x1A] = 0;
        let inode = Inode::parse(&buf, 256).unwrap();
        assert_eq!(inode.dtime, 1_700_001_000);
        assert_eq!(inode.links_count, 0);
    }

    #[test]
    fn i_block_60_bytes() {
        let mut buf = make_inode_bytes(0x8180, 100);
        for i in 0..60 {
            buf[0x28 + i] = i as u8;
        }
        let inode = Inode::parse(&buf, 256).unwrap();
        assert_eq!(inode.i_block.len(), 60);
        assert_eq!(inode.i_block[0], 0);
        assert_eq!(inode.i_block[59], 59);
    }

    #[test]
    fn verify_inode_checksum_on_forensic_img() {
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
        let data = if let Ok(d) = std::fs::read(path) {
            d
        } else {
            eprintln!("skip: forensic.img not found");
            return;
        };
        use crate::ondisk::superblock::Superblock;
        let sb = Superblock::parse(&data[1024..]).unwrap();
        assert!(
            sb.has_metadata_csum(),
            "forensic.img should have metadata_csum"
        );

        let inode_size = sb.inode_size;
        let inodes_per_group = sb.inodes_per_group;

        // Read group descriptor to find inode table
        let desc_size = sb.desc_size;
        let gdt_offset = sb.block_size as usize;
        use crate::ondisk::group_desc::GroupDescriptor;
        let gd = GroupDescriptor::parse(
            &data[gdt_offset..gdt_offset + desc_size as usize],
            desc_size,
        )
        .unwrap();

        // Read inode 12 (hello.txt) raw bytes from inode table
        let ino: u32 = 12;
        let index = (ino - 1) % inodes_per_group;
        let byte_offset =
            gd.inode_table * u64::from(sb.block_size) + u64::from(index) * u64::from(inode_size);
        let raw = &data[byte_offset as usize..(byte_offset + u64::from(inode_size)) as usize];
        let inode = Inode::parse(raw, inode_size).unwrap();

        assert!(
            inode.verify_checksum(raw, &sb.uuid, ino, inode.generation, sb.checksum_seed),
            "inode 12 checksum should verify"
        );
    }

    #[test]
    fn inode_predicate_uses_extents() {
        let buf = make_inode_bytes(0x8180, 100);
        let inode = Inode::parse(&buf, 256).unwrap();
        assert!(inode.uses_extents());
    }

    #[test]
    fn inode_predicate_has_inline_data() {
        let mut buf = make_inode_bytes(0x8180, 100);
        // Set INLINE_DATA flag (0x1000_0000)
        let flags = 0x1000_0000u32;
        buf[0x20..0x24].copy_from_slice(&flags.to_le_bytes());
        let inode = Inode::parse(&buf, 256).unwrap();
        assert!(inode.has_inline_data());
        assert!(!inode.uses_extents());
    }

    #[test]
    fn inode_predicate_has_htree() {
        let mut buf = make_inode_bytes(0x4180, 4096); // directory
                                                      // Set INDEX flag (0x1000)
        let flags = 0x1000u32;
        buf[0x20..0x24].copy_from_slice(&flags.to_le_bytes());
        let inode = Inode::parse(&buf, 256).unwrap();
        assert!(inode.has_htree());
    }

    #[test]
    fn inode_predicate_is_deleted() {
        let mut buf = make_inode_bytes(0x8180, 100);
        buf[0x14..0x18].copy_from_slice(&1000u32.to_le_bytes()); // dtime
        let inode = Inode::parse(&buf, 256).unwrap();
        assert!(inode.is_deleted());
        assert!(!inode.is_orphan());
    }

    #[test]
    fn inode_predicate_is_orphan() {
        let mut buf = make_inode_bytes(0x8180, 100);
        buf[0x1A..0x1C].copy_from_slice(&0u16.to_le_bytes()); // links_count = 0
                                                              // dtime stays 0
        let inode = Inode::parse(&buf, 256).unwrap();
        assert!(inode.is_orphan());
        assert!(!inode.is_deleted());
    }

    #[test]
    fn reject_too_short() {
        let buf = vec![0u8; 50];
        let err = Inode::parse(&buf, 256).unwrap_err();
        assert!(matches!(err, crate::error::Ext4Error::TooShort { .. }));
    }
}