Skip to main content

fs_ext4/
features.rs

1//! ext4 feature flags — COMPAT, INCOMPAT, RO_COMPAT.
2//!
3//! Spec source: kernel.org/doc/html/latest/filesystems/ext4/super.html
4
5use bitflags::bitflags;
6
7bitflags! {
8    /// COMPAT features — safe to ignore if unknown.
9    #[derive(Debug, Clone, Copy)]
10    pub struct Compat: u32 {
11        const DIR_PREALLOC      = 0x0001;
12        const IMAGIC_INODES     = 0x0002;
13        const HAS_JOURNAL       = 0x0004;
14        const EXT_ATTR          = 0x0008;
15        const RESIZE_INODE      = 0x0010;
16        const DIR_INDEX         = 0x0020;  // HTree
17        const LAZY_BG           = 0x0040;
18        const SPARSE_SUPER2     = 0x0200;
19        const FAST_COMMIT       = 0x0400;
20        const ORPHAN_FILE       = 0x1000;
21    }
22
23    /// INCOMPAT features — kernel MUST understand or refuse to mount.
24    #[derive(Debug, Clone, Copy)]
25    pub struct Incompat: u32 {
26        const COMPRESSION       = 0x00001;
27        const FILETYPE          = 0x00002;
28        const RECOVER           = 0x00004;
29        const JOURNAL_DEV       = 0x00008;
30        const META_BG           = 0x00010;
31        const EXTENTS           = 0x00040;
32        const BIT64             = 0x00080;
33        const MMP               = 0x00100;
34        const FLEX_BG           = 0x00200;
35        const EA_INODE          = 0x00400;
36        const DIRDATA           = 0x01000;
37        const CSUM_SEED         = 0x02000;
38        const LARGEDIR          = 0x04000;
39        const INLINE_DATA       = 0x08000;
40        const ENCRYPT           = 0x10000;
41        const CASEFOLD          = 0x20000;
42    }
43
44    /// RO_COMPAT features — must mount read-only if unknown.
45    #[derive(Debug, Clone, Copy)]
46    pub struct RoCompat: u32 {
47        const SPARSE_SUPER      = 0x0001;
48        const LARGE_FILE        = 0x0002;
49        const BTREE_DIR         = 0x0004;
50        const HUGE_FILE         = 0x0008;
51        const GDT_CSUM          = 0x0010;
52        const DIR_NLINK         = 0x0020;
53        const EXTRA_ISIZE       = 0x0040;
54        const HAS_SNAPSHOT      = 0x0080;
55        const QUOTA             = 0x0100;
56        const BIGALLOC          = 0x0200;
57        const METADATA_CSUM     = 0x0400;
58        const REPLICA           = 0x0800;
59        const READONLY          = 0x1000;
60        const PROJECT           = 0x2000;
61        const VERITY            = 0x8000;
62        const ORPHAN_PRESENT    = 0x10000;
63    }
64}
65
66/// INCOMPAT bits this driver mounts. Anything else in feature_incompat
67/// means refuse-to-mount. Some of these can be read and not written: see
68/// [`WRITE_BREAKING_INCOMPAT`].
69pub const SUPPORTED_INCOMPAT: u32 = Incompat::FILETYPE.bits()
70    | Incompat::EXTENTS.bits()
71    | Incompat::BIT64.bits()
72    | Incompat::FLEX_BG.bits()
73    | Incompat::CSUM_SEED.bits()
74    // Descriptors kept per meta group rather than in one table: found
75    // through `Superblock::descriptor_location` (#73).
76    | Incompat::META_BG.bits()
77    // "Some inode may be encrypted", not "the volume is": fscrypt is a
78    // per-directory policy and everything outside it is plain. Encrypted
79    // contents and names are refused per inode (`file_io::refuse_encrypted`,
80    // #76); writes are refused volume-wide, see WRITE_BREAKING_INCOMPAT.
81    | Incompat::ENCRYPT.bits()
82    // A dirty journal: replayed onto the device by a writable mount
83    // (`journal_apply::replay_if_dirty`), and into the buffer cache by a
84    // read-only one (`journal_apply::replay_into_cache`, #72).
85    | Incompat::RECOVER.bits()
86    // Read-only only: see WRITE_BREAKING_INCOMPAT.
87    | Incompat::MMP.bits()
88    // Inline files are read in full, `i_block` then the `system.data`
89    // xattr (`inline_data::read_all`). An inline directory is searched in
90    // `i_block` only: see G3 in docs/format-conformance-gaps.md.
91    | Incompat::INLINE_DATA.bits()
92    | Incompat::LARGEDIR.bits()
93    | Incompat::EA_INODE.bits()
94    | Incompat::CASEFOLD.bits();
95
96/// INCOMPAT bits that are safe to READ and not safe to WRITE.
97///
98/// These are the mirror of [`MAINTAINED_RO_COMPAT`] on the incompat side.
99/// The RO_COMPAT rule — a reader that does not know the bit may read and
100/// must not write — has no INCOMPAT equivalent in the format itself, but
101/// some INCOMPAT features have exactly that shape in practice: this
102/// driver can locate every byte on such a volume, and cannot keep the
103/// structures the bit describes correct once it starts writing.
104///
105/// - `MMP` (multi-mount protection) stops two hosts mounting one
106///   filesystem read-write at once. Honouring it means reading the MMP
107///   block, checking its sequence, writing our own node name, waiting and
108///   re-checking; none of that exists. A reader cannot corrupt anything
109///   and does not affect the other host's protection, so read-only is
110///   fine and writing is not.
111///
112/// - `CASEFOLD` changes how a directory entry's htree slot is CHOSEN: the
113///   directory's htree hash is computed over the normalised, case-folded
114///   name (`casefold::casefold_name_hash`), and the entry is filed into
115///   the leaf that hash selects. This driver's write path hashes the raw
116///   bytes. Reads survive that by accident,
117///   because `path::find_entry` falls back to a linear scan when the
118///   htree descent misses — slower, and case-insensitive lookup does not
119///   work, but nothing is lost. A write does not survive it: the entry
120///   goes into the leaf the wrong hash names, this driver's linear scan
121///   keeps finding it, and the kernel's htree descent does not. The file
122///   becomes invisible on Linux while still holding its inode and its
123///   directory slot, and `e2fsck` reports the htree as inconsistent.
124///
125/// `casefold.rs` implements the hash and has no callers; neither
126/// `s_encoding` nor `EXT4_CASEFOLD_FL` is read anywhere, so the driver
127/// cannot currently tell that a directory is casefolded at all. Wiring
128/// that up is what would let this bit move out of here.
129///
130/// - `ENCRYPT` is read per inode, and a write would have to be refused per
131///   inode too: a name created in an encrypted directory, or data written
132///   to an encrypted file, is plaintext where the kernel reads ciphertext.
133///   Nothing on the write side checks `EXT4_ENCRYPT_FL`, so the volume is
134///   read-only here (#76).
135pub const WRITE_BREAKING_INCOMPAT: u32 =
136    Incompat::MMP.bits() | Incompat::CASEFOLD.bits() | Incompat::ENCRYPT.bits();
137
138/// The INCOMPAT bits on this volume that a write here would not keep
139/// consistent. Zero means the volume may be mounted read-write.
140pub fn write_breaking_incompat(feature_incompat: u32) -> u32 {
141    feature_incompat & WRITE_BREAKING_INCOMPAT
142}
143
144/// RO_COMPAT bits a mount accepts. Accepting one is not the same as keeping
145/// it correct through a write: see [`MAINTAINED_RO_COMPAT`].
146pub const SUPPORTED_RO_COMPAT: u32 = RoCompat::SPARSE_SUPER.bits()
147    | RoCompat::LARGE_FILE.bits()
148    | RoCompat::HUGE_FILE.bits()
149    | RoCompat::GDT_CSUM.bits()
150    | RoCompat::DIR_NLINK.bits()
151    | RoCompat::EXTRA_ISIZE.bits()
152    | RoCompat::QUOTA.bits()
153    | RoCompat::METADATA_CSUM.bits()
154    | RoCompat::PROJECT.bits()
155    | RoCompat::ORPHAN_PRESENT.bits()
156    // Read like any volume; not maintained, so not written (#75).
157    | RoCompat::BIGALLOC.bits();
158
159/// Filesystem dialect — derived from the on-disk feature flags at mount time.
160/// Drives runtime behaviour where ext2 / ext3 / ext4 differ:
161///
162/// - inode block-mapping scheme (extent tree vs legacy direct/indirect)
163/// - presence of a journal (replay path runs only for `Ext3`/`Ext4`)
164/// - which features new inodes opt into when the driver creates them
165///
166/// The classification mirrors what the Linux kernel's single `ext4` driver
167/// uses internally — there is no separate ext2 driver in this crate, just
168/// runtime dispatch keyed on this enum.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum FsFlavor {
171    /// No EXTENTS, no HAS_JOURNAL.
172    Ext2,
173    /// No EXTENTS, HAS_JOURNAL set (jbd2-style log on a hidden journal inode).
174    Ext3,
175    /// EXTENTS set (with or without a journal — modern ext4 typically has one).
176    Ext4,
177}
178
179impl FsFlavor {
180    /// Derive flavor from the parsed superblock's COMPAT/INCOMPAT bits.
181    pub fn detect(feature_compat: u32, feature_incompat: u32) -> Self {
182        let has_extents = (feature_incompat & Incompat::EXTENTS.bits()) != 0;
183        let has_journal = (feature_compat & Compat::HAS_JOURNAL.bits()) != 0;
184        match (has_extents, has_journal) {
185            (true, _) => FsFlavor::Ext4,
186            (false, true) => FsFlavor::Ext3,
187            (false, false) => FsFlavor::Ext2,
188        }
189    }
190
191    /// True when the driver should allocate new inodes with `EXT4_EXTENTS_FL`
192    /// set (so file contents are tracked by an extent tree). False for ext2/3,
193    /// which use the legacy direct/indirect block-pointer scheme.
194    pub fn uses_extents(&self) -> bool {
195        matches!(self, FsFlavor::Ext4)
196    }
197
198    /// True when this volume has a journal that must be replayed (or honoured
199    /// on writes). Ext2 has none.
200    pub fn has_journal(&self) -> bool {
201        matches!(self, FsFlavor::Ext3 | FsFlavor::Ext4)
202    }
203
204    pub fn name(&self) -> &'static str {
205        match self {
206            FsFlavor::Ext2 => "ext2",
207            FsFlavor::Ext3 => "ext3",
208            FsFlavor::Ext4 => "ext4",
209        }
210    }
211
212    /// Returns `(inode_size, desc_size, csum_enabled, dir_csum_tail)` tuple.
213    pub(crate) fn geometry(&self) -> (u16, u16, bool, usize) {
214        let csum_enabled = matches!(self, FsFlavor::Ext4);
215        let inode_size: u16 = if csum_enabled { 256 } else { 128 };
216        let desc_size: u16 = if csum_enabled { 64 } else { 32 };
217        let dir_csum_tail: usize = if csum_enabled { 12 } else { 0 };
218        (inode_size, desc_size, csum_enabled, dir_csum_tail)
219    }
220}
221
222/// RO_COMPAT bits that change how the filesystem is *read*, and so
223/// cannot be ignored even by a read-only mount. None, today.
224///
225/// The compatibility model says an unknown RO_COMPAT bit is safe to
226/// mount read-only: it describes something a reader may ignore. A bit
227/// that invalidated how this reader locates bytes would be the exception
228/// and would belong here.
229///
230/// `BIGALLOC` was here, on the premise that it replaces
231/// `s_blocks_per_group` as the group stride. It does not (#75). The kernel
232/// refuses a volume whose `s_blocks_per_group` is not `s_clusters_per_group`
233/// times the cluster ratio, so group offsets, inode tables and extents --
234/// everything a read locates -- stay in blocks. What counts clusters is the
235/// block bitmaps and the descriptors' free counts, which only allocation and
236/// the audits read. So a bigalloc volume reads as any other: it is in
237/// [`SUPPORTED_RO_COMPAT`] and not [`MAINTAINED_RO_COMPAT`], which refuses
238/// writes, and `fsck::audit` and `verify::verify` refuse it by name.
239pub const READ_BREAKING_RO_COMPAT: u32 = 0;
240
241/// RO_COMPAT bits this driver **maintains**, as opposed to tolerates.
242///
243/// [`SUPPORTED_RO_COMPAT`] is the read set: bits a reader may ignore.
244/// This is the smaller write set, and the difference between them is
245/// the point.
246///
247/// - `QUOTA` and `PROJECT` are absent because nothing here touches the
248///   quota inodes. The string "quota" appears in this file and nowhere
249///   else in `src/`. A create on a quota-enabled volume charges nobody
250///   for it and leaves counters that no longer describe the filesystem,
251///   which `e2fsck` reports later as a mismatch the user cannot connect
252///   to having plugged the disk into a Mac.
253/// - `ORPHAN_PRESENT` is absent for the same reason: the orphan file is
254///   read (see `Filesystem::orphan_list`) and not maintained.
255/// - `GDT_CSUM` is present, and was not at first. Its group-descriptor
256///   checksums are crc16 rather than `METADATA_CSUM`'s crc32c, and until
257///   [`crate::checksum::group_desc_csum`] existed nothing here computed
258///   them, so every descriptor a write touched was left stale. It is
259///   what `mke2fs` produced by default before 1.43, so the ordinary
260///   shape of an older disk.
261///
262/// Everything else in [`SUPPORTED_RO_COMPAT`] describes structures the
263/// write paths already keep correct.
264pub const MAINTAINED_RO_COMPAT: u32 = RoCompat::SPARSE_SUPER.bits()
265    | RoCompat::LARGE_FILE.bits()
266    | RoCompat::HUGE_FILE.bits()
267    | RoCompat::DIR_NLINK.bits()
268    | RoCompat::EXTRA_ISIZE.bits()
269    | RoCompat::GDT_CSUM.bits()
270    | RoCompat::METADATA_CSUM.bits();
271
272/// The RO_COMPAT bits on this volume that a write here would not keep
273/// consistent: anything outside [`MAINTAINED_RO_COMPAT`].
274///
275/// # Why this is separate from [`check_mountable`]
276///
277/// `RO_COMPAT` names one rule with two halves — a reader that does not
278/// know the bit may READ, and must not WRITE. `check_mountable` decides
279/// the first half and its comments said so ("Check whether the
280/// filesystem can be mounted read-only"), but this driver has not been
281/// read-only for a long time: it creates, unlinks, writes, truncates,
282/// and formats. So the second half was not enforced anywhere, and a
283/// volume with an unrecognised bit was mounted writable with a test
284/// pinning that it should be.
285///
286/// Zero means the volume may be written.
287pub fn unmaintained_ro_compat(feature_ro_compat: u32) -> u32 {
288    feature_ro_compat & !MAINTAINED_RO_COMPAT
289}
290
291/// Check whether the filesystem can be mounted read-only.
292/// Returns Err with the unsupported bits if not.
293pub fn check_mountable(feature_incompat: u32, feature_ro_compat: u32) -> crate::error::Result<()> {
294    let unsupported_incompat = feature_incompat & !SUPPORTED_INCOMPAT;
295    if unsupported_incompat != 0 {
296        return Err(crate::error::Error::UnsupportedIncompat(
297            unsupported_incompat,
298        ));
299    }
300    // Most RO_COMPAT bits are safe to ignore on a read-only mount, per
301    // the compatibility model. The exceptions are the ones that change
302    // how bytes are located — see READ_BREAKING_RO_COMPAT.
303    let breaking = feature_ro_compat & READ_BREAKING_RO_COMPAT;
304    if breaking != 0 {
305        return Err(crate::error::Error::UnsupportedRoCompat(breaking));
306    }
307    Ok(())
308}
309
310#[cfg(test)]
311mod mountability_tests {
312    use super::*;
313
314    /// A bigalloc filesystem mounts to be read, and is not written (#75):
315    /// reads locate everything in blocks, and only allocation counts
316    /// clusters.
317    #[test]
318    fn a_bigalloc_filesystem_reads_and_is_not_written() {
319        check_mountable(0, RoCompat::BIGALLOC.bits())
320            .expect("bigalloc leaves every read-side offset in blocks");
321        assert_eq!(
322            unmaintained_ro_compat(RoCompat::BIGALLOC.bits()),
323            RoCompat::BIGALLOC.bits(),
324            "a write would allocate blocks where the bitmap counts clusters"
325        );
326    }
327
328    /// Every other RO_COMPAT bit still mounts. The point is a targeted
329    /// refusal, not a stricter filesystem.
330    /// Every tolerated RO_COMPAT bit mounts.
331    ///
332    /// Derived from `SUPPORTED_RO_COMPAT` rather than hand-listed. A
333    /// hand-written list is a second place to remember a bit, and the
334    /// one it forgot was GDT_CSUM: supported since the first release,
335    /// never asserted, so a regression rejecting it would have passed
336    /// this suite.
337    #[test]
338    fn the_other_ro_compat_bits_still_mount() {
339        let tolerated = SUPPORTED_RO_COMPAT & !READ_BREAKING_RO_COMPAT;
340        assert_ne!(tolerated, 0, "nothing to check — the masks are wrong");
341        for shift in 0..32 {
342            let bit = 1u32 << shift;
343            if tolerated & bit == 0 {
344                continue;
345            }
346            check_mountable(0, bit)
347                .unwrap_or_else(|e| panic!("ro_compat {bit:#x} should mount read-only, got {e:?}"));
348        }
349    }
350
351    /// An RO_COMPAT bit this crate has never heard of still mounts.
352    ///
353    /// That is the compatibility model working as intended, and the
354    /// reason the refusal above has to be an explicit list rather than
355    /// "anything outside SUPPORTED_RO_COMPAT".
356    #[test]
357    fn an_unknown_ro_compat_bit_still_mounts() {
358        check_mountable(0, 0x8000_0000).expect("unknown RO_COMPAT is safe on a read-only mount");
359    }
360
361    /// The two masks must not contradict each other: a bit cannot be
362    /// both supported and read-breaking.
363    #[test]
364    fn the_supported_and_breaking_masks_are_disjoint() {
365        assert_eq!(
366            SUPPORTED_RO_COMPAT & READ_BREAKING_RO_COMPAT,
367            0,
368            "a bit cannot be both tolerated and refused"
369        );
370    }
371
372    /// An unsupported INCOMPAT bit is still refused — the existing
373    /// behaviour, pinned so the RO_COMPAT change did not disturb it.
374    #[test]
375    fn an_unsupported_incompat_bit_is_still_refused() {
376        assert!(check_mountable(0x8000_0000, 0).is_err());
377    }
378}