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}