Skip to main content

ext4fs/ondisk/
superblock.rs

1#![forbid(unsafe_code)]
2
3use crate::error::{Ext4Error, Result};
4use bitflags::bitflags;
5use crc::{Algorithm, Crc};
6
7// ---------------------------------------------------------------------------
8// Helper functions for little-endian reads
9// ---------------------------------------------------------------------------
10
11fn le16(buf: &[u8], off: usize) -> u16 {
12    u16::from_le_bytes([buf[off], buf[off + 1]])
13}
14
15fn le32(buf: &[u8], off: usize) -> u32 {
16    u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
17}
18
19// ---------------------------------------------------------------------------
20// Feature-flag bitflags
21// ---------------------------------------------------------------------------
22
23bitflags! {
24    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25    pub struct CompatFeatures: u32 {
26        const HAS_JOURNAL   = 0x0004;
27        const EXT_ATTR      = 0x0008;
28        const RESIZE_INODE  = 0x0010;
29        const DIR_INDEX     = 0x0020;
30        const SPARSE_SUPER2 = 0x0200;
31    }
32}
33
34bitflags! {
35    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36    pub struct IncompatFeatures: u32 {
37        const FILETYPE     = 0x0002;
38        const RECOVER      = 0x0004;
39        const JOURNAL_DEV  = 0x0008;
40        const META_BG      = 0x0010;
41        const EXTENTS      = 0x0040;
42        const IS_64BIT     = 0x0080;
43        const MMP          = 0x0100;
44        const FLEX_BG      = 0x0200;
45        const EA_INODE     = 0x0400;
46        const DIRDATA      = 0x1000;
47        const CSUM_SEED    = 0x2000;
48        const LARGEDIR     = 0x4000;
49        const INLINE_DATA  = 0x8000;
50        const ENCRYPT      = 0x1_0000;
51        const CASEFOLD     = 0x2_0000;
52    }
53}
54
55bitflags! {
56    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57    pub struct RoCompatFeatures: u32 {
58        const SPARSE_SUPER  = 0x0001;
59        const LARGE_FILE    = 0x0002;
60        const BTREE_DIR     = 0x0004;
61        const HUGE_FILE     = 0x0008;
62        const GDT_CSUM      = 0x0010;
63        const DIR_NLINK     = 0x0020;
64        const EXTRA_ISIZE   = 0x0040;
65        const HAS_SNAPSHOT  = 0x0080;
66        const QUOTA          = 0x0100;
67        const BIGALLOC       = 0x0200;
68        const METADATA_CSUM  = 0x0400;
69        const ORPHAN_PRESENT = 0x8000;
70    }
71}
72
73// ---------------------------------------------------------------------------
74// CRC32C algorithm matching Linux kernel's crc32c_le()
75// ---------------------------------------------------------------------------
76
77/// CRC-32C (Castagnoli) matching the Linux kernel's `crc32c_le()` function.
78///
79/// The standard CRC-32/ISCSI algorithm has `xorout = 0xFFFFFFFF`, meaning
80/// the finalized result is XORed with all-ones.  The Linux kernel's CRC32C
81/// implementation does **not** apply this final XOR — it returns the raw CRC
82/// register state.  We define a custom algorithm with `xorout = 0` to match.
83pub(crate) const EXT4_CRC32C: Algorithm<u32> = Algorithm {
84    width: 32,
85    poly: 0x1EDC_6F41,
86    init: 0xFFFF_FFFF,
87    refin: true,
88    refout: true,
89    xorout: 0x0000_0000, // kernel does NOT apply final XOR
90    check: 0x0,          // not used
91    residue: 0x0,        // not used
92};
93
94// ---------------------------------------------------------------------------
95// Superblock
96// ---------------------------------------------------------------------------
97
98/// Minimum buffer length required to parse through `s_inode_size` at offset 0x5A.
99const MIN_SUPERBLOCK_LEN: usize = 0x5A;
100
101/// Parsed ext4 superblock.  All multi-byte fields are converted from
102/// little-endian on disk to native endianness.
103#[derive(Debug, Clone)]
104pub struct Superblock {
105    pub inodes_count: u32,
106    /// Combined lo + hi (64-bit mode).
107    pub blocks_count: u64,
108    pub reserved_blocks: u64,
109    pub free_blocks: u64,
110    pub free_inodes: u32,
111    pub first_data_block: u32,
112    /// Computed: `2^(10 + s_log_block_size)`.
113    pub block_size: u32,
114    pub blocks_per_group: u32,
115    pub inodes_per_group: u32,
116    pub magic: u16,
117    pub state: u16,
118    pub rev_level: u32,
119    pub inode_size: u16,
120    pub desc_size: u16,
121    pub feature_compat: CompatFeatures,
122    pub feature_incompat: IncompatFeatures,
123    pub feature_ro_compat: RoCompatFeatures,
124    pub uuid: [u8; 16],
125    pub volume_name: [u8; 16],
126    pub last_mounted: [u8; 64],
127    pub mkfs_time: u32,
128    pub mount_time: u32,
129    pub write_time: u32,
130    pub lastcheck_time: u32,
131    pub journal_inum: u32,
132    pub hash_seed: [u32; 4],
133    pub def_hash_version: u8,
134    pub checksum_type: u8,
135    pub checksum_seed: u32,
136    pub checksum: u32,
137    pub is_64bit: bool,
138    pub log_groups_per_flex: u32,
139    pub last_orphan: u32,
140    pub first_error_time: u32,
141    pub last_error_time: u32,
142}
143
144impl Superblock {
145    /// Parse an ext4 superblock from the given byte slice.
146    ///
147    /// The slice should begin at the start of the superblock (i.e. byte 1024
148    /// of the filesystem image has already been skipped by the caller).
149    /// A minimum of `MIN_SUPERBLOCK_LEN` bytes is required; a full 1024-byte
150    /// superblock is needed to access all extended fields.
151    pub fn parse(buf: &[u8]) -> Result<Self> {
152        if buf.len() < MIN_SUPERBLOCK_LEN {
153            return Err(Ext4Error::TooShort {
154                structure: "superblock",
155                expected: MIN_SUPERBLOCK_LEN,
156                found: buf.len(),
157            });
158        }
159
160        let magic = le16(buf, 0x38);
161        if magic != 0xEF53 {
162            return Err(Ext4Error::InvalidMagic { found: magic });
163        }
164
165        let rev_level = le32(buf, 0x4C);
166        let inode_size = if rev_level >= 1 { le16(buf, 0x58) } else { 128 };
167
168        let log_block_size = le32(buf, 0x18);
169        let shift = 10u32
170            .checked_add(log_block_size)
171            .filter(|&s| s < 32)
172            .ok_or_else(|| {
173                Ext4Error::InvalidSuperblock(format!(
174                    "log_block_size {log_block_size} out of range (max 21)"
175                ))
176            })?;
177        let block_size = 1u32 << shift;
178
179        let feature_incompat_raw = if buf.len() > 0x64 { le32(buf, 0x60) } else { 0 };
180        let feature_incompat = IncompatFeatures::from_bits_truncate(feature_incompat_raw);
181        let is_64bit = feature_incompat.contains(IncompatFeatures::IS_64BIT);
182
183        let blocks_count_lo = u64::from(le32(buf, 0x04));
184        let blocks_count_hi = if is_64bit && buf.len() > 0x154 {
185            u64::from(le32(buf, 0x150))
186        } else {
187            0
188        };
189        let blocks_count = blocks_count_lo | (blocks_count_hi << 32);
190
191        let r_blocks_lo = u64::from(le32(buf, 0x08));
192        let r_blocks_hi = if is_64bit && buf.len() > 0x158 {
193            u64::from(le32(buf, 0x154))
194        } else {
195            0
196        };
197        let reserved_blocks = r_blocks_lo | (r_blocks_hi << 32);
198
199        let free_blocks_lo = u64::from(le32(buf, 0x0C));
200        let free_blocks_hi = if is_64bit && buf.len() > 0x15C {
201            u64::from(le32(buf, 0x158))
202        } else {
203            0
204        };
205        let free_blocks = free_blocks_lo | (free_blocks_hi << 32);
206
207        let feature_compat_raw = if buf.len() > 0x60 { le32(buf, 0x5C) } else { 0 };
208        let feature_ro_raw = if buf.len() > 0x68 { le32(buf, 0x64) } else { 0 };
209
210        let mut uuid = [0u8; 16];
211        if buf.len() >= 0x78 {
212            uuid.copy_from_slice(&buf[0x68..0x78]);
213        }
214
215        let mut volume_name = [0u8; 16];
216        if buf.len() >= 0x88 {
217            volume_name.copy_from_slice(&buf[0x78..0x88]);
218        }
219
220        let mut last_mounted = [0u8; 64];
221        if buf.len() >= 0xC8 {
222            last_mounted.copy_from_slice(&buf[0x88..0xC8]);
223        }
224
225        // s_desc_size == 0 means default (32); values 1–31 are invalid.
226        let desc_size = {
227            let raw = if buf.len() > 0x100 {
228                le16(buf, 0xFE)
229            } else {
230                0
231            };
232            if raw == 0 {
233                32u16
234            } else {
235                raw
236            }
237        };
238        if desc_size < 32 {
239            return Err(Ext4Error::InvalidSuperblock(format!(
240                "desc_size {desc_size} out of range (must be 0 or >= 32)"
241            )));
242        }
243
244        let journal_inum = if buf.len() > 0xE4 { le32(buf, 0xE0) } else { 0 };
245        let last_orphan = if buf.len() > 0xEC { le32(buf, 0xE8) } else { 0 };
246
247        let hash_seed = if buf.len() >= 0xFC {
248            [
249                le32(buf, 0xEC),
250                le32(buf, 0xF0),
251                le32(buf, 0xF4),
252                le32(buf, 0xF8),
253            ]
254        } else {
255            [0u32; 4]
256        };
257
258        let def_hash_version = if buf.len() > 0xFC { buf[0xFC] } else { 0 };
259
260        let mkfs_time = if buf.len() > 0x10C {
261            le32(buf, 0x108)
262        } else {
263            0
264        };
265
266        let log_groups_per_flex = if buf.len() > 0x16C {
267            le32(buf, 0x168)
268        } else {
269            0
270        };
271        let checksum_type = if buf.len() > 0x16D { buf[0x16C] } else { 0 };
272
273        let first_error_time = if buf.len() > 0x194 {
274            le32(buf, 0x190)
275        } else {
276            0
277        };
278        let last_error_time = if buf.len() > 0x1B4 {
279            le32(buf, 0x1B0)
280        } else {
281            0
282        };
283
284        let checksum_seed = if buf.len() > 0x260 {
285            le32(buf, 0x25C)
286        } else {
287            0
288        };
289        let checksum = if buf.len() >= 0x400 {
290            le32(buf, 0x3FC)
291        } else {
292            0
293        };
294
295        Ok(Superblock {
296            inodes_count: le32(buf, 0x00),
297            blocks_count,
298            reserved_blocks,
299            free_blocks,
300            free_inodes: le32(buf, 0x10),
301            first_data_block: le32(buf, 0x14),
302            block_size,
303            blocks_per_group: le32(buf, 0x20),
304            inodes_per_group: le32(buf, 0x28),
305            magic,
306            state: le16(buf, 0x3A),
307            rev_level,
308            inode_size,
309            desc_size,
310            feature_compat: CompatFeatures::from_bits_truncate(feature_compat_raw),
311            feature_incompat,
312            feature_ro_compat: RoCompatFeatures::from_bits_truncate(feature_ro_raw),
313            uuid,
314            volume_name,
315            last_mounted,
316            mkfs_time,
317            mount_time: le32(buf, 0x2C),
318            write_time: le32(buf, 0x30),
319            lastcheck_time: if buf.len() > 0x44 { le32(buf, 0x40) } else { 0 },
320            journal_inum,
321            hash_seed,
322            def_hash_version,
323            checksum_type,
324            checksum_seed,
325            checksum,
326            is_64bit,
327            log_groups_per_flex,
328            last_orphan,
329            first_error_time,
330            last_error_time,
331        })
332    }
333
334    /// Volume label as a UTF-8 string, trimmed of trailing null bytes.
335    pub fn label(&self) -> &str {
336        let end = self
337            .volume_name
338            .iter()
339            .position(|&b| b == 0)
340            .unwrap_or(self.volume_name.len());
341        std::str::from_utf8(&self.volume_name[..end]).unwrap_or("")
342    }
343
344    /// UUID formatted as a standard hyphenated string.
345    pub fn uuid_string(&self) -> String {
346        let u = &self.uuid;
347        format!(
348            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
349            u[0], u[1], u[2], u[3],
350            u[4], u[5],
351            u[6], u[7],
352            u[8], u[9],
353            u[10], u[11], u[12], u[13], u[14], u[15],
354        )
355    }
356
357    /// Whether the filesystem has metadata checksumming enabled.
358    pub fn has_metadata_csum(&self) -> bool {
359        self.feature_ro_compat
360            .contains(RoCompatFeatures::METADATA_CSUM)
361    }
362
363    /// Whether the filesystem uses extents (vs. block maps).
364    pub fn has_extents(&self) -> bool {
365        self.feature_incompat.contains(IncompatFeatures::EXTENTS)
366    }
367
368    /// Verify the superblock CRC32C checksum.
369    ///
370    /// The checksum covers bytes 0..0x3FC (everything except the 4-byte
371    /// checksum field itself at offset 0x3FC).
372    ///
373    /// The superblock checksum is special: unlike other ext4 metadata, it is
374    /// computed with the default CRC32C initial value (~0 / 0xFFFFFFFF) rather
375    /// than the UUID-derived or stored seed.  This is because the superblock
376    /// itself contains the UUID used to derive seeds for other structures.
377    ///
378    /// The Linux kernel's `crc32c_le()` does NOT apply the final XOR that the
379    /// standard CRC-32/ISCSI algorithm specifies (xorout=0xFFFFFFFF).  We use
380    /// a custom algorithm definition with `xorout=0` to match kernel behavior.
381    pub fn verify_checksum(&self, raw_buf: &[u8]) -> bool {
382        if !self.has_metadata_csum() {
383            return true; // feature not enabled, nothing to verify
384        }
385        if raw_buf.len() < 0x400 {
386            return false; // buffer too short to contain checksum field
387        }
388
389        let crc32c = Crc::<u32>::new(&EXT4_CRC32C);
390
391        // Superblock uses default initial value (~0), not the UUID/seed.
392        let mut digest = crc32c.digest();
393        digest.update(&raw_buf[..0x3FC]);
394        let computed = digest.finalize();
395
396        computed == self.checksum
397    }
398
399    /// Whether inline data is supported.
400    pub fn has_inline_data(&self) -> bool {
401        self.feature_incompat
402            .contains(IncompatFeatures::INLINE_DATA)
403    }
404
405    /// Whether the filesystem has a journal.
406    pub fn has_journal(&self) -> bool {
407        self.feature_compat.contains(CompatFeatures::HAS_JOURNAL)
408    }
409
410    /// Number of block groups (rounded up).
411    pub fn group_count(&self) -> u32 {
412        if self.blocks_per_group == 0 {
413            return 0;
414        }
415        let total = self.blocks_count;
416        let per = u64::from(self.blocks_per_group);
417        total.div_ceil(per) as u32
418    }
419}
420
421// ===========================================================================
422// Tests
423// ===========================================================================
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    fn minimal_superblock_bytes() -> Vec<u8> {
430        let mut buf = vec![0u8; 1024];
431        buf[0x38] = 0x53;
432        buf[0x39] = 0xEF; // magic = 0xEF53
433        buf[0x18] = 2; // log_block_size = 2 -> 4096
434        buf[0x00] = 64; // inodes_count = 64
435        buf[0x04] = 0x00;
436        buf[0x05] = 0x04; // blocks_count_lo = 1024
437        buf[0x28] = 64; // inodes_per_group = 64
438        buf[0x20] = 0x00;
439        buf[0x21] = 0x04; // blocks_per_group = 1024
440        buf[0x58] = 0x00;
441        buf[0x59] = 0x01; // inode_size = 256
442        buf[0x4C] = 1; // rev_level = 1
443        buf[0xFE] = 32; // desc_size = 32
444        buf
445    }
446
447    #[test]
448    fn parse_valid_superblock() {
449        let buf = minimal_superblock_bytes();
450        let sb = Superblock::parse(&buf).unwrap();
451        assert_eq!(sb.magic, 0xEF53);
452        assert_eq!(sb.block_size, 4096);
453        assert_eq!(sb.inodes_count, 64);
454        assert_eq!(sb.blocks_count, 1024);
455        assert_eq!(sb.inodes_per_group, 64);
456        assert_eq!(sb.inode_size, 256);
457    }
458
459    #[test]
460    fn reject_invalid_magic() {
461        let mut buf = minimal_superblock_bytes();
462        buf[0x38] = 0x00;
463        buf[0x39] = 0x00;
464        let err = Superblock::parse(&buf).unwrap_err();
465        assert!(matches!(
466            err,
467            crate::error::Ext4Error::InvalidMagic { found: 0 }
468        ));
469    }
470
471    #[test]
472    fn reject_too_short_buffer() {
473        let buf = vec![0u8; 50];
474        let err = Superblock::parse(&buf).unwrap_err();
475        assert!(matches!(err, crate::error::Ext4Error::TooShort { .. }));
476    }
477
478    #[test]
479    fn parse_from_minimal_image() {
480        let img_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
481        if !std::path::Path::new(img_path).exists() {
482            eprintln!("Skipping: minimal.img not found");
483            return;
484        }
485        let data = std::fs::read(img_path).unwrap();
486        let sb = Superblock::parse(&data[1024..2048]).unwrap();
487        assert_eq!(sb.magic, 0xEF53);
488        assert_eq!(sb.block_size, 4096);
489        assert_eq!(sb.label(), "test-ext4");
490        assert!(sb.has_extents());
491        assert!(sb.has_metadata_csum());
492        assert!(sb.is_64bit);
493        assert!(sb.inodes_count > 0);
494        assert!(sb.blocks_count > 0);
495    }
496
497    #[test]
498    fn group_count_calculation() {
499        let buf = minimal_superblock_bytes();
500        let sb = Superblock::parse(&buf).unwrap();
501        // blocks_count=1024, blocks_per_group=1024 => 1 group
502        assert_eq!(sb.group_count(), 1);
503    }
504
505    #[test]
506    fn inode_size_default_for_rev0() {
507        let mut buf = minimal_superblock_bytes();
508        // Set rev_level = 0
509        buf[0x4C] = 0;
510        buf[0x4D] = 0;
511        buf[0x4E] = 0;
512        buf[0x4F] = 0;
513        let sb = Superblock::parse(&buf).unwrap();
514        assert_eq!(sb.inode_size, 128);
515    }
516
517    #[test]
518    fn uuid_string_format() {
519        let mut buf = minimal_superblock_bytes();
520        // Write a known UUID at offset 0x68
521        let uuid_bytes: [u8; 16] = [
522            0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
523            0x32, 0x10,
524        ];
525        buf[0x68..0x78].copy_from_slice(&uuid_bytes);
526        let sb = Superblock::parse(&buf).unwrap();
527        assert_eq!(sb.uuid_string(), "01234567-89ab-cdef-fedc-ba9876543210");
528    }
529
530    #[test]
531    fn label_with_nulls() {
532        let mut buf = minimal_superblock_bytes();
533        // Write "hello" followed by nulls at offset 0x78
534        buf[0x78] = b'h';
535        buf[0x79] = b'e';
536        buf[0x7A] = b'l';
537        buf[0x7B] = b'l';
538        buf[0x7C] = b'o';
539        // rest is already zeros
540        let sb = Superblock::parse(&buf).unwrap();
541        assert_eq!(sb.label(), "hello");
542    }
543
544    #[test]
545    fn feature_flags_roundtrip() {
546        let mut buf = minimal_superblock_bytes();
547        // Set feature_compat = HAS_JOURNAL | DIR_INDEX
548        let compat = CompatFeatures::HAS_JOURNAL | CompatFeatures::DIR_INDEX;
549        let compat_bytes = compat.bits().to_le_bytes();
550        buf[0x5C..0x60].copy_from_slice(&compat_bytes);
551
552        // Set feature_incompat = EXTENTS | IS_64BIT | FLEX_BG
553        let incompat =
554            IncompatFeatures::EXTENTS | IncompatFeatures::IS_64BIT | IncompatFeatures::FLEX_BG;
555        let incompat_bytes = incompat.bits().to_le_bytes();
556        buf[0x60..0x64].copy_from_slice(&incompat_bytes);
557
558        // Set feature_ro_compat = METADATA_CSUM | HUGE_FILE
559        let ro = RoCompatFeatures::METADATA_CSUM | RoCompatFeatures::HUGE_FILE;
560        let ro_bytes = ro.bits().to_le_bytes();
561        buf[0x64..0x68].copy_from_slice(&ro_bytes);
562
563        let sb = Superblock::parse(&buf).unwrap();
564        assert!(sb.has_journal());
565        assert!(sb.has_extents());
566        assert!(sb.has_metadata_csum());
567        assert!(sb.is_64bit);
568        assert_eq!(sb.feature_compat, compat);
569        assert_eq!(sb.feature_incompat, incompat);
570        assert_eq!(sb.feature_ro_compat, ro);
571    }
572
573    #[test]
574    fn verify_superblock_checksum() {
575        let img_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
576        if !std::path::Path::new(img_path).exists() {
577            eprintln!("skip: minimal.img not found");
578            return;
579        }
580        let data = std::fs::read(img_path).unwrap();
581        let sb = Superblock::parse(&data[1024..2048]).unwrap();
582        if sb.has_metadata_csum() {
583            assert!(sb.verify_checksum(&data[1024..2048]));
584        }
585    }
586
587    #[test]
588    fn has_inline_data_feature() {
589        let mut buf = minimal_superblock_bytes();
590        // Set incompat feature with INLINE_DATA (0x8000)
591        buf[0x60..0x64].copy_from_slice(&0x8000u32.to_le_bytes());
592        let sb = Superblock::parse(&buf).unwrap();
593        assert!(sb.has_inline_data());
594    }
595
596    #[test]
597    fn blocks_count_64bit() {
598        let mut buf = minimal_superblock_bytes();
599        // Enable 64bit feature
600        let incompat = IncompatFeatures::IS_64BIT;
601        let incompat_bytes = incompat.bits().to_le_bytes();
602        buf[0x60..0x64].copy_from_slice(&incompat_bytes);
603
604        // blocks_count_lo = 0x1000
605        buf[0x04] = 0x00;
606        buf[0x05] = 0x10;
607        buf[0x06] = 0x00;
608        buf[0x07] = 0x00;
609
610        // blocks_count_hi = 0x02
611        buf[0x150] = 0x02;
612        buf[0x151] = 0x00;
613        buf[0x152] = 0x00;
614        buf[0x153] = 0x00;
615
616        let sb = Superblock::parse(&buf).unwrap();
617        // Expected: 0x02_0000_1000
618        assert_eq!(sb.blocks_count, 0x02_0000_1000);
619    }
620}