am-fs-ext4 0.5.1

Pure-Rust ext4 filesystem driver. Exposes a C ABI (fs_ext4_*) suitable for FFI from C/C++/Go/etc.
Documentation
//! ext4 feature flags — COMPAT, INCOMPAT, RO_COMPAT.
//!
//! Spec source: kernel.org/doc/html/latest/filesystems/ext4/super.html

use bitflags::bitflags;

bitflags! {
    /// COMPAT features — safe to ignore if unknown.
    #[derive(Debug, Clone, Copy)]
    pub struct Compat: u32 {
        const DIR_PREALLOC      = 0x0001;
        const IMAGIC_INODES     = 0x0002;
        const HAS_JOURNAL       = 0x0004;
        const EXT_ATTR          = 0x0008;
        const RESIZE_INODE      = 0x0010;
        const DIR_INDEX         = 0x0020;  // HTree
        const LAZY_BG           = 0x0040;
        const SPARSE_SUPER2     = 0x0200;
        const FAST_COMMIT       = 0x0400;
        const ORPHAN_FILE       = 0x1000;
    }

    /// INCOMPAT features — kernel MUST understand or refuse to mount.
    #[derive(Debug, Clone, Copy)]
    pub struct Incompat: u32 {
        const COMPRESSION       = 0x00001;
        const FILETYPE          = 0x00002;
        const RECOVER           = 0x00004;
        const JOURNAL_DEV       = 0x00008;
        const META_BG           = 0x00010;
        const EXTENTS           = 0x00040;
        const BIT64             = 0x00080;
        const MMP               = 0x00100;
        const FLEX_BG           = 0x00200;
        const EA_INODE          = 0x00400;
        const DIRDATA           = 0x01000;
        const CSUM_SEED         = 0x02000;
        const LARGEDIR          = 0x04000;
        const INLINE_DATA       = 0x08000;
        const ENCRYPT           = 0x10000;
        const CASEFOLD          = 0x20000;
    }

    /// RO_COMPAT features — must mount read-only if unknown.
    #[derive(Debug, Clone, Copy)]
    pub struct RoCompat: u32 {
        const SPARSE_SUPER      = 0x0001;
        const LARGE_FILE        = 0x0002;
        const BTREE_DIR         = 0x0004;
        const HUGE_FILE         = 0x0008;
        const GDT_CSUM          = 0x0010;
        const DIR_NLINK         = 0x0020;
        const EXTRA_ISIZE       = 0x0040;
        const HAS_SNAPSHOT      = 0x0080;
        const QUOTA             = 0x0100;
        const BIGALLOC          = 0x0200;
        const METADATA_CSUM     = 0x0400;
        const REPLICA           = 0x0800;
        const READONLY          = 0x1000;
        const PROJECT           = 0x2000;
        const VERITY            = 0x8000;
        const ORPHAN_PRESENT    = 0x10000;
    }
}

/// INCOMPAT bits we know how to handle (Phase 1 read-only goal).
/// Anything else in feature_incompat means refuse-to-mount.
pub const SUPPORTED_INCOMPAT: u32 = Incompat::FILETYPE.bits()
    | Incompat::EXTENTS.bits()
    | Incompat::BIT64.bits()
    | Incompat::FLEX_BG.bits()
    | Incompat::CSUM_SEED.bits()
    // The features below are tolerated for read-only mount even if not fully implemented:
    | Incompat::RECOVER.bits()      // we'll skip journal replay for now (warn)
    | Incompat::MMP.bits()          // ignore for read-only
    | Incompat::INLINE_DATA.bits()  // we'll handle the flag, even if data overflow uses xattr later
    | Incompat::LARGEDIR.bits()
    | Incompat::EA_INODE.bits()
    | Incompat::CASEFOLD.bits();

/// RO_COMPAT bits we tolerate (since we mount read-only anyway).
pub const SUPPORTED_RO_COMPAT: u32 = RoCompat::SPARSE_SUPER.bits()
    | RoCompat::LARGE_FILE.bits()
    | RoCompat::HUGE_FILE.bits()
    | RoCompat::GDT_CSUM.bits()
    | RoCompat::DIR_NLINK.bits()
    | RoCompat::EXTRA_ISIZE.bits()
    | RoCompat::QUOTA.bits()
    | RoCompat::METADATA_CSUM.bits()
    | RoCompat::PROJECT.bits()
    | RoCompat::ORPHAN_PRESENT.bits();

/// Filesystem dialect — derived from the on-disk feature flags at mount time.
/// Drives runtime behaviour where ext2 / ext3 / ext4 differ:
///
/// - inode block-mapping scheme (extent tree vs legacy direct/indirect)
/// - presence of a journal (replay path runs only for `Ext3`/`Ext4`)
/// - which features new inodes opt into when the driver creates them
///
/// The classification mirrors what the Linux kernel's single `ext4` driver
/// uses internally — there is no separate ext2 driver in this crate, just
/// runtime dispatch keyed on this enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FsFlavor {
    /// No EXTENTS, no HAS_JOURNAL.
    Ext2,
    /// No EXTENTS, HAS_JOURNAL set (jbd2-style log on a hidden journal inode).
    Ext3,
    /// EXTENTS set (with or without a journal — modern ext4 typically has one).
    Ext4,
}

impl FsFlavor {
    /// Derive flavor from the parsed superblock's COMPAT/INCOMPAT bits.
    pub fn detect(feature_compat: u32, feature_incompat: u32) -> Self {
        let has_extents = (feature_incompat & Incompat::EXTENTS.bits()) != 0;
        let has_journal = (feature_compat & Compat::HAS_JOURNAL.bits()) != 0;
        match (has_extents, has_journal) {
            (true, _) => FsFlavor::Ext4,
            (false, true) => FsFlavor::Ext3,
            (false, false) => FsFlavor::Ext2,
        }
    }

    /// True when the driver should allocate new inodes with `EXT4_EXTENTS_FL`
    /// set (so file contents are tracked by an extent tree). False for ext2/3,
    /// which use the legacy direct/indirect block-pointer scheme.
    pub fn uses_extents(&self) -> bool {
        matches!(self, FsFlavor::Ext4)
    }

    /// True when this volume has a journal that must be replayed (or honoured
    /// on writes). Ext2 has none.
    pub fn has_journal(&self) -> bool {
        matches!(self, FsFlavor::Ext3 | FsFlavor::Ext4)
    }

    pub fn name(&self) -> &'static str {
        match self {
            FsFlavor::Ext2 => "ext2",
            FsFlavor::Ext3 => "ext3",
            FsFlavor::Ext4 => "ext4",
        }
    }

    /// Returns `(inode_size, desc_size, csum_enabled, dir_csum_tail)` tuple.
    pub(crate) fn geometry(&self) -> (u16, u16, bool, usize) {
        let csum_enabled = matches!(self, FsFlavor::Ext4);
        let inode_size: u16 = if csum_enabled { 256 } else { 128 };
        let desc_size: u16 = if csum_enabled { 64 } else { 32 };
        let dir_csum_tail: usize = if csum_enabled { 12 } else { 0 };
        (inode_size, desc_size, csum_enabled, dir_csum_tail)
    }
}

/// RO_COMPAT bits that change how the filesystem is *read*, and so
/// cannot be ignored even by a read-only mount.
///
/// The compatibility model says an unknown RO_COMPAT bit is safe to
/// mount read-only, and for almost every bit that is true: they
/// describe things a reader may ignore (quota accounting, project
/// IDs, the orphan list). This crate relied on that blanket rule.
///
/// **BIGALLOC is the exception, and it is not a small one.** It
/// changes the allocation unit from the block to the *cluster*:
/// `s_log_cluster_size` exceeds `s_log_block_size`, the block bitmaps
/// track clusters, and `s_clusters_per_group` replaces
/// `s_blocks_per_group` as the group stride. A reader that assumes
/// cluster == block computes every block-group offset wrong and
/// returns whatever happens to live there — silently, because nothing
/// about the read fails.
///
/// The rule "unknown RO_COMPAT is safe read-only" holds for a reader
/// that *understands* the bit and merely chooses not to act on it. It
/// does not hold for one that has never heard of it and whose
/// arithmetic it invalidates. Until the cluster arithmetic exists,
/// refusing is the honest answer.
pub const READ_BREAKING_RO_COMPAT: u32 = RoCompat::BIGALLOC.bits();

/// Check whether the filesystem can be mounted read-only.
/// Returns Err with the unsupported bits if not.
pub fn check_mountable(feature_incompat: u32, feature_ro_compat: u32) -> crate::error::Result<()> {
    let unsupported_incompat = feature_incompat & !SUPPORTED_INCOMPAT;
    if unsupported_incompat != 0 {
        return Err(crate::error::Error::UnsupportedIncompat(
            unsupported_incompat,
        ));
    }
    // Most RO_COMPAT bits are safe to ignore on a read-only mount, per
    // the compatibility model. The exceptions are the ones that change
    // how bytes are located — see READ_BREAKING_RO_COMPAT.
    let breaking = feature_ro_compat & READ_BREAKING_RO_COMPAT;
    if breaking != 0 {
        return Err(crate::error::Error::UnsupportedRoCompat(breaking));
    }
    Ok(())
}

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

    /// A bigalloc filesystem is refused rather than misread.
    ///
    /// This is the case the blanket "unknown RO_COMPAT is safe
    /// read-only" rule got wrong. With bigalloc the allocation unit is
    /// the cluster, so a reader assuming cluster == block computes
    /// every block-group offset wrong and returns whatever lives
    /// there. Refusing is not a limitation being admitted; it is the
    /// difference between an error and silent corruption.
    #[test]
    fn a_bigalloc_filesystem_is_refused() {
        let err = check_mountable(0, RoCompat::BIGALLOC.bits())
            .expect_err("bigalloc changes the allocation unit and must not be mounted blind");
        match err {
            crate::error::Error::UnsupportedRoCompat(bits) => {
                assert_eq!(bits, RoCompat::BIGALLOC.bits());
            }
            other => panic!("expected UnsupportedRoCompat, got {other:?}"),
        }
    }

    /// Every other RO_COMPAT bit still mounts. The point is a targeted
    /// refusal, not a stricter filesystem.
    /// Every tolerated RO_COMPAT bit mounts.
    ///
    /// Derived from `SUPPORTED_RO_COMPAT` rather than hand-listed. A
    /// hand-written list is a second place to remember a bit, and the
    /// one it forgot was GDT_CSUM: supported since the first release,
    /// never asserted, so a regression rejecting it would have passed
    /// this suite.
    #[test]
    fn the_other_ro_compat_bits_still_mount() {
        let tolerated = SUPPORTED_RO_COMPAT & !READ_BREAKING_RO_COMPAT;
        assert_ne!(tolerated, 0, "nothing to check — the masks are wrong");
        for shift in 0..32 {
            let bit = 1u32 << shift;
            if tolerated & bit == 0 {
                continue;
            }
            check_mountable(0, bit)
                .unwrap_or_else(|e| panic!("ro_compat {bit:#x} should mount read-only, got {e:?}"));
        }
    }

    /// An RO_COMPAT bit this crate has never heard of still mounts.
    ///
    /// That is the compatibility model working as intended, and the
    /// reason the refusal above has to be an explicit list rather than
    /// "anything outside SUPPORTED_RO_COMPAT".
    #[test]
    fn an_unknown_ro_compat_bit_still_mounts() {
        check_mountable(0, 0x8000_0000).expect("unknown RO_COMPAT is safe on a read-only mount");
    }

    /// The two masks must not contradict each other: a bit cannot be
    /// both supported and read-breaking.
    #[test]
    fn the_supported_and_breaking_masks_are_disjoint() {
        assert_eq!(
            SUPPORTED_RO_COMPAT & READ_BREAKING_RO_COMPAT,
            0,
            "a bit cannot be both tolerated and refused"
        );
    }

    /// An unsupported INCOMPAT bit is still refused — the existing
    /// behaviour, pinned so the RO_COMPAT change did not disturb it.
    #[test]
    fn an_unsupported_incompat_bit_is_still_refused() {
        assert!(check_mountable(0x8000_0000, 0).is_err());
    }
}