Skip to main content

fs_ext4/
dir.rs

1//! Linear directory entry parsing.
2//!
3//! Spec: kernel.org/doc/html/latest/filesystems/ext4/directory.html
4//!
5//! A directory is a regular file whose contents are a sequence of variable-length
6//! records (`ext4_dir_entry_2`). Each record:
7//!   0x00 u32 inode      (0 = unused entry)
8//!   0x04 u16 rec_len    (distance to next record; multiple of 4)
9//!   0x06 u8  name_len   (low 8 bits when FILETYPE; rev<0.5 had a u16 split)
10//!   0x07 u8  file_type  (when FILETYPE feature; otherwise high byte of name_len)
11//!   0x08 .. name (name_len bytes, NOT null-terminated, padded to 4-byte align)
12//!
13//! When METADATA_CSUM is enabled, the last 12 bytes of each block are a fake
14//! "tail" entry: inode=0, rec_len=12, name_len=0, file_type=0xDE, csum (u32).
15
16use crate::checksum::Checksummer;
17use crate::error::{Error, Result};
18
19/// File-type byte in a directory entry (when FILETYPE feature is set).
20#[repr(u8)]
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DirEntryType {
23    Unknown = 0,
24    RegFile = 1,
25    Directory = 2,
26    CharDev = 3,
27    BlockDev = 4,
28    Fifo = 5,
29    Socket = 6,
30    Symlink = 7,
31}
32
33impl DirEntryType {
34    /// The entry type for an inode of mode `mode` (only its `S_IFMT` bits
35    /// are read), per the format documentation's `file_type` table. Every
36    /// writer that
37    /// names an inode maps its mode here, so an entry's type byte cannot
38    /// disagree with its inode by a writer knowing fewer types than another
39    /// (#386). A mode that is no file type maps to `Unknown`.
40    pub fn from_mode(mode: u16) -> Self {
41        use crate::inode::{
42            S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_IFSOCK,
43        };
44        match mode & S_IFMT {
45            S_IFREG => Self::RegFile,
46            S_IFDIR => Self::Directory,
47            S_IFCHR => Self::CharDev,
48            S_IFBLK => Self::BlockDev,
49            S_IFIFO => Self::Fifo,
50            S_IFSOCK => Self::Socket,
51            S_IFLNK => Self::Symlink,
52            _ => Self::Unknown,
53        }
54    }
55
56    pub fn from_u8(b: u8) -> Self {
57        match b {
58            1 => Self::RegFile,
59            2 => Self::Directory,
60            3 => Self::CharDev,
61            4 => Self::BlockDev,
62            5 => Self::Fifo,
63            6 => Self::Socket,
64            7 => Self::Symlink,
65            _ => Self::Unknown,
66        }
67    }
68}
69
70/// Parsed directory entry.
71#[derive(Debug, Clone)]
72pub struct DirEntry {
73    pub inode: u32,
74    pub name: Vec<u8>,
75    pub file_type: DirEntryType,
76}
77
78/// Iterator over directory entries in a single data block.
79///
80/// Skips entries with `inode == 0` (unused / tombstone) and the metadata-csum
81/// tail entry (file_type == 0xDE, name_len == 0).
82pub struct DirBlockIter<'a> {
83    buf: &'a [u8],
84    offset: usize,
85    /// When true, the FILETYPE feature is on so byte 0x07 is file_type;
86    /// otherwise it's the high byte of name_len.
87    has_file_type: bool,
88}
89
90impl<'a> DirBlockIter<'a> {
91    pub fn new(buf: &'a [u8], has_file_type: bool) -> Self {
92        Self {
93            buf,
94            offset: 0,
95            has_file_type,
96        }
97    }
98}
99
100impl<'a> Iterator for DirBlockIter<'a> {
101    type Item = Result<DirEntry>;
102
103    fn next(&mut self) -> Option<Self::Item> {
104        loop {
105            if self.offset + 8 > self.buf.len() {
106                return None;
107            }
108
109            let inode_bytes = &self.buf[self.offset..self.offset + 4];
110            let inode = u32::from_le_bytes(inode_bytes.try_into().unwrap());
111            let rec_len = u16::from_le_bytes(
112                self.buf[self.offset + 4..self.offset + 6]
113                    .try_into()
114                    .unwrap(),
115            );
116
117            // Sanity-check rec_len before trusting it for the next iteration.
118            if rec_len < 8
119                || (rec_len as usize) > self.buf.len() - self.offset
120                || (rec_len % 4) != 0
121            {
122                return Some(Err(Error::CorruptDirEntry("bad rec_len")));
123            }
124
125            let name_len_lo = self.buf[self.offset + 6];
126            let type_or_namehi = self.buf[self.offset + 7];
127
128            // Detect csum tail: inode==0, rec_len==12, name_len==0, type==0xDE.
129            let is_csum_tail =
130                inode == 0 && rec_len == 12 && name_len_lo == 0 && type_or_namehi == 0xDE;
131
132            let entry_end = self.offset + rec_len as usize;
133
134            // Skip unused / tail entries.
135            if inode == 0 || is_csum_tail {
136                self.offset = entry_end;
137                if is_csum_tail {
138                    return None; // tail marks end of useful entries in this block
139                }
140                continue;
141            }
142
143            let name_len = if self.has_file_type {
144                name_len_lo as usize
145            } else {
146                ((type_or_namehi as usize) << 8) | name_len_lo as usize
147            };
148
149            if 8 + name_len > rec_len as usize {
150                return Some(Err(Error::CorruptDirEntry("name overflows rec_len")));
151            }
152
153            let name_start = self.offset + 8;
154            let name = self.buf[name_start..name_start + name_len].to_vec();
155            let file_type = if self.has_file_type {
156                DirEntryType::from_u8(type_or_namehi)
157            } else {
158                DirEntryType::Unknown
159            };
160
161            self.offset = entry_end;
162            return Some(Ok(DirEntry {
163                inode,
164                name,
165                file_type,
166            }));
167        }
168    }
169}
170
171/// Parse all entries from one directory data block.
172pub fn parse_block(buf: &[u8], has_file_type: bool) -> Result<Vec<DirEntry>> {
173    let mut out = Vec::new();
174    for entry in DirBlockIter::new(buf, has_file_type) {
175        out.push(entry?);
176    }
177    Ok(out)
178}
179
180/// True if this block ends in a metadata-csum tail entry
181/// (`inode=0, rec_len=12, name_len=0, file_type=0xDE`).
182pub fn has_csum_tail(buf: &[u8]) -> bool {
183    if buf.len() < 12 {
184        return false;
185    }
186    let off = buf.len() - 12;
187    let inode = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
188    let rec_len = u16::from_le_bytes(buf[off + 4..off + 6].try_into().unwrap());
189    inode == 0 && rec_len == 12 && buf[off + 6] == 0 && buf[off + 7] == 0xDE
190}
191
192/// Verify a directory block's CRC32C tail (if present) and parse its entries.
193///
194/// When `csum.enabled` AND the block ends in a recognisable
195/// `ext4_dir_entry_tail`, the tail's stored CRC32C is checked against
196/// `crc32c(seed, ino || generation || block_with_tail_csum_zeroed)`.
197/// Mismatch yields `Error::BadChecksum { what: "directory block" }`.
198///
199/// Blocks without the tail are parsed unchanged — older directories on
200/// metadata-csum-enabled filesystems remain readable until they're rewritten.
201pub fn parse_block_verified(
202    buf: &[u8],
203    has_file_type: bool,
204    ino: u32,
205    generation: u32,
206    csum: &Checksummer,
207) -> Result<Vec<DirEntry>> {
208    if csum.enabled && has_csum_tail(buf) && !csum.verify_dir_entry_tail(ino, generation, buf) {
209        return Err(Error::BadChecksum {
210            what: "directory block",
211        });
212    }
213    parse_block(buf, has_file_type)
214}
215
216// ---------------------------------------------------------------------------
217// Mutation helpers — E8 (Phase 4 write path, plan layer, linear dirs only)
218// ---------------------------------------------------------------------------
219
220/// Record length an entry with `name_len` bytes occupies on disk: 8-byte
221/// header + name, padded to a 4-byte boundary.
222#[inline]
223pub const fn entry_rec_len(name_len: usize) -> usize {
224    8 + ((name_len + 3) & !3)
225}
226
227/// Write one entry header + name at `off` inside `buf`. Caller is responsible
228/// for `rec_len` being correct and fitting.
229fn write_entry(
230    buf: &mut [u8],
231    off: usize,
232    inode: u32,
233    rec_len: u16,
234    name: &[u8],
235    file_type: DirEntryType,
236    has_file_type: bool,
237) {
238    buf[off..off + 4].copy_from_slice(&inode.to_le_bytes());
239    buf[off + 4..off + 6].copy_from_slice(&rec_len.to_le_bytes());
240    let name_len = name.len();
241    if has_file_type {
242        buf[off + 6] = name_len as u8;
243        buf[off + 7] = file_type as u8;
244    } else {
245        buf[off + 6] = (name_len & 0xFF) as u8;
246        buf[off + 7] = ((name_len >> 8) & 0xFF) as u8;
247    }
248    buf[off + 8..off + 8 + name_len].copy_from_slice(name);
249    // Zero the padding bytes between name and rec_len end — helps deterministic
250    // checksums downstream.
251    let pad_start = off + 8 + name_len;
252    let pad_end = off + rec_len as usize;
253    if pad_end > pad_start {
254        for b in &mut buf[pad_start..pad_end] {
255            *b = 0;
256        }
257    }
258}
259
260/// Insert a new directory entry into a single linear block.
261///
262/// Walks entries looking for one whose padded size is smaller than its
263/// `rec_len` (i.e. has a free tail); splits it so the new entry fits in the
264/// gap. Also finds a stale tombstone (`inode == 0`) whose `rec_len` is big
265/// enough and reuses it.
266///
267/// `reserved_tail` is the number of trailing bytes the caller reserves
268/// (e.g. 12 for the metadata-csum tail entry). Those bytes are untouched.
269///
270/// Returns `Ok(())` on success, `Err(Error::OutOfBounds)` if no slot fits.
271pub fn add_entry_to_block(
272    buf: &mut [u8],
273    inode: u32,
274    name: &[u8],
275    file_type: DirEntryType,
276    has_file_type: bool,
277    reserved_tail: usize,
278) -> Result<()> {
279    if inode == 0 {
280        return Err(Error::CorruptDirEntry(
281            "refuse to insert entry with inode 0",
282        ));
283    }
284    if name.is_empty() || name.len() > 255 {
285        return Err(Error::CorruptDirEntry("name length out of range"));
286    }
287    let usable = buf
288        .len()
289        .checked_sub(reserved_tail)
290        .ok_or(Error::OutOfBounds)?;
291    let needed = entry_rec_len(name.len());
292    if needed > usable {
293        return Err(Error::OutOfBounds);
294    }
295
296    let mut off = 0usize;
297    while off + 8 <= usable {
298        let cur_inode = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
299        let rec_len = u16::from_le_bytes(buf[off + 4..off + 6].try_into().unwrap()) as usize;
300        if rec_len < 8 || !rec_len.is_multiple_of(4) || off + rec_len > usable {
301            return Err(Error::CorruptDirEntry("bad rec_len during add"));
302        }
303
304        if cur_inode == 0 {
305            // Reuse a tombstone slot in-place (keep its rec_len).
306            if rec_len >= needed {
307                write_entry(
308                    buf,
309                    off,
310                    inode,
311                    rec_len as u16,
312                    name,
313                    file_type,
314                    has_file_type,
315                );
316                return Ok(());
317            }
318        } else {
319            let cur_name_lo = buf[off + 6];
320            let cur_type_or_hi = buf[off + 7];
321            let cur_name_len = if has_file_type {
322                cur_name_lo as usize
323            } else {
324                ((cur_type_or_hi as usize) << 8) | cur_name_lo as usize
325            };
326            let cur_actual = entry_rec_len(cur_name_len);
327            if rec_len >= cur_actual + needed {
328                // Split: shrink current to its actual size, put new entry in the tail.
329                buf[off + 4..off + 6].copy_from_slice(&(cur_actual as u16).to_le_bytes());
330                let new_off = off + cur_actual;
331                let new_rec_len = rec_len - cur_actual;
332                write_entry(
333                    buf,
334                    new_off,
335                    inode,
336                    new_rec_len as u16,
337                    name,
338                    file_type,
339                    has_file_type,
340                );
341                return Ok(());
342            }
343        }
344
345        off += rec_len;
346    }
347
348    Err(Error::OutOfBounds)
349}
350
351/// Remove the first entry matching `name` from a linear directory block.
352///
353/// Coalesces the removed entry's `rec_len` into the previous entry's `rec_len`
354/// so the block stays densely packed and iterable. If the match is the very
355/// first entry in the block, its inode is zeroed (tombstone) — the kernel
356/// keeps `rec_len` intact so readers still skip it via `inode == 0`.
357///
358/// Returns `Ok(true)` if an entry was removed, `Ok(false)` if not found.
359pub fn remove_entry_from_block(
360    buf: &mut [u8],
361    name: &[u8],
362    has_file_type: bool,
363    reserved_tail: usize,
364) -> Result<bool> {
365    let usable = buf
366        .len()
367        .checked_sub(reserved_tail)
368        .ok_or(Error::OutOfBounds)?;
369    let mut off = 0usize;
370    let mut prev_off: Option<usize> = None;
371
372    while off + 8 <= usable {
373        let cur_inode = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
374        let rec_len = u16::from_le_bytes(buf[off + 4..off + 6].try_into().unwrap()) as usize;
375        if rec_len < 8 || !rec_len.is_multiple_of(4) || off + rec_len > usable {
376            return Err(Error::CorruptDirEntry("bad rec_len during remove"));
377        }
378
379        if cur_inode != 0 {
380            let cur_name_lo = buf[off + 6];
381            let cur_type_or_hi = buf[off + 7];
382            let cur_name_len = if has_file_type {
383                cur_name_lo as usize
384            } else {
385                ((cur_type_or_hi as usize) << 8) | cur_name_lo as usize
386            };
387            if 8 + cur_name_len <= rec_len && &buf[off + 8..off + 8 + cur_name_len] == name {
388                if let Some(prev) = prev_off {
389                    // Coalesce: previous entry absorbs this one's space.
390                    let prev_rec_len =
391                        u16::from_le_bytes(buf[prev + 4..prev + 6].try_into().unwrap()) as usize;
392                    let merged = prev_rec_len + rec_len;
393                    if merged > u16::MAX as usize {
394                        return Err(Error::CorruptDirEntry("merged rec_len overflows u16"));
395                    }
396                    buf[prev + 4..prev + 6].copy_from_slice(&(merged as u16).to_le_bytes());
397                    // Zero the now-absorbed header so a stale read cannot mis-parse.
398                    for b in &mut buf[off..off + 8] {
399                        *b = 0;
400                    }
401                } else {
402                    // First entry in block: tombstone it (inode = 0), keep rec_len.
403                    buf[off..off + 4].copy_from_slice(&0u32.to_le_bytes());
404                }
405                return Ok(true);
406            }
407        }
408
409        prev_off = Some(off);
410        off += rec_len;
411    }
412
413    Ok(false)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// #386: every file type maps to its own entry type, permission bits
421    /// ignored, and matches the on-disk byte `from_u8` reads back.
422    #[test]
423    fn from_mode_knows_every_file_type() {
424        use crate::inode::{S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFREG, S_IFSOCK};
425        for (mode, want) in [
426            (S_IFREG, DirEntryType::RegFile),
427            (S_IFDIR, DirEntryType::Directory),
428            (S_IFCHR, DirEntryType::CharDev),
429            (S_IFBLK, DirEntryType::BlockDev),
430            (S_IFIFO, DirEntryType::Fifo),
431            (S_IFSOCK, DirEntryType::Socket),
432            (S_IFLNK, DirEntryType::Symlink),
433            (0, DirEntryType::Unknown),
434        ] {
435            let got = DirEntryType::from_mode(mode | 0o7777);
436            assert_eq!(got, want, "mode {mode:#o}");
437            assert_eq!(DirEntryType::from_u8(got as u8), want);
438        }
439    }
440
441    /// Hand-rolled directory block: ".", "..", "hello".
442    fn synth_block() -> Vec<u8> {
443        let block_size = 4096;
444        let mut buf = vec![0u8; block_size];
445
446        // "." entry: inode=2, rec_len=12, name_len=1, file_type=2 (DIR), name=".\0\0\0"
447        buf[0..4].copy_from_slice(&2u32.to_le_bytes());
448        buf[4..6].copy_from_slice(&12u16.to_le_bytes());
449        buf[6] = 1;
450        buf[7] = 2;
451        buf[8] = b'.';
452
453        // ".." entry: inode=2, rec_len=12, name_len=2, file_type=2, name="..\0\0"
454        buf[12..16].copy_from_slice(&2u32.to_le_bytes());
455        buf[16..18].copy_from_slice(&12u16.to_le_bytes());
456        buf[18] = 2;
457        buf[19] = 2;
458        buf[20] = b'.';
459        buf[21] = b'.';
460
461        // "hello" entry: inode=12, rec_len=block_size-24=4072 (rest of block),
462        // name_len=5, file_type=1 (REG), name="hello\0\0\0"
463        buf[24..28].copy_from_slice(&12u32.to_le_bytes());
464        buf[28..30].copy_from_slice(&((block_size - 24) as u16).to_le_bytes());
465        buf[30] = 5;
466        buf[31] = 1;
467        buf[32..37].copy_from_slice(b"hello");
468
469        buf
470    }
471
472    #[test]
473    fn parses_dot_dotdot_hello() {
474        let buf = synth_block();
475        let entries = parse_block(&buf, true).expect("parse");
476
477        assert_eq!(entries.len(), 3);
478        assert_eq!(entries[0].name, b".");
479        assert_eq!(entries[0].inode, 2);
480        assert_eq!(entries[0].file_type, DirEntryType::Directory);
481
482        assert_eq!(entries[1].name, b"..");
483        assert_eq!(entries[1].file_type, DirEntryType::Directory);
484
485        assert_eq!(entries[2].name, b"hello");
486        assert_eq!(entries[2].inode, 12);
487        assert_eq!(entries[2].file_type, DirEntryType::RegFile);
488    }
489
490    #[test]
491    fn rejects_bad_rec_len() {
492        let mut buf = vec![0u8; 64];
493        buf[0..4].copy_from_slice(&2u32.to_le_bytes());
494        // rec_len = 3 (less than 8 minimum)
495        buf[4..6].copy_from_slice(&3u16.to_le_bytes());
496        buf[6] = 1;
497        buf[7] = 2;
498        buf[8] = b'.';
499
500        let result = parse_block(&buf, true);
501        assert!(matches!(result, Err(Error::CorruptDirEntry(_))));
502    }
503
504    // ---- E8 mutation tests ---------------------------------------------
505
506    #[test]
507    fn rec_len_matches_kernel_layout() {
508        assert_eq!(entry_rec_len(1), 12); // "."
509        assert_eq!(entry_rec_len(2), 12); // ".."
510        assert_eq!(entry_rec_len(5), 16); // "hello"
511        assert_eq!(entry_rec_len(8), 16); // exact 4-byte alignment
512        assert_eq!(entry_rec_len(9), 20);
513    }
514
515    #[test]
516    fn add_entry_splits_terminal_record() {
517        let mut buf = synth_block();
518        add_entry_to_block(&mut buf, 99, b"world", DirEntryType::RegFile, true, 0)
519            .expect("add_entry");
520
521        let entries = parse_block(&buf, true).expect("parse");
522        assert_eq!(entries.len(), 4);
523        assert_eq!(entries[3].name, b"world");
524        assert_eq!(entries[3].inode, 99);
525        assert_eq!(entries[3].file_type, DirEntryType::RegFile);
526
527        // The existing "hello" entry should now have its rec_len shrunk to
528        // the minimum for a 5-char name; the new "world" entry inherits the rest.
529        let hello_rec_len = u16::from_le_bytes(buf[28..30].try_into().unwrap());
530        assert_eq!(hello_rec_len, 16, "hello rec_len shrinks to actual size");
531    }
532
533    #[test]
534    fn add_entry_enospc_when_no_room() {
535        // Block of 32 bytes; dot+dotdot+hello already fills it.
536        let block_size = 32;
537        let mut buf = vec![0u8; block_size];
538        // "." rec_len=12
539        buf[0..4].copy_from_slice(&2u32.to_le_bytes());
540        buf[4..6].copy_from_slice(&12u16.to_le_bytes());
541        buf[6] = 1;
542        buf[7] = 2;
543        buf[8] = b'.';
544        // ".." rec_len=12
545        buf[12..16].copy_from_slice(&2u32.to_le_bytes());
546        buf[16..18].copy_from_slice(&12u16.to_le_bytes());
547        buf[18] = 2;
548        buf[19] = 2;
549        buf[20] = b'.';
550        buf[21] = b'.';
551        // "a" rec_len=8 (just header+1 padded to 12 — but set rec_len to exactly the remaining 8)
552        buf[24..28].copy_from_slice(&12u32.to_le_bytes());
553        buf[28..30].copy_from_slice(&8u16.to_le_bytes());
554        buf[30] = 1;
555        buf[31] = 1;
556
557        let err = add_entry_to_block(&mut buf, 42, b"xx", DirEntryType::RegFile, true, 0);
558        assert!(matches!(err, Err(Error::OutOfBounds)));
559    }
560
561    #[test]
562    fn add_entry_respects_reserved_tail() {
563        // 64-byte block with 12-byte csum tail; usable region = 52 bytes.
564        // dot (12) + dotdot (12) leaves 28 usable. A name of len 21 needs 32
565        // bytes and must be rejected; len 13 needs 24 bytes and must fit.
566        let mut buf = vec![0u8; 64];
567        buf[0..4].copy_from_slice(&2u32.to_le_bytes());
568        buf[4..6].copy_from_slice(&12u16.to_le_bytes());
569        buf[6] = 1;
570        buf[7] = 2;
571        buf[8] = b'.';
572        buf[12..16].copy_from_slice(&2u32.to_le_bytes());
573        // ".." claims the rest of the usable area (40 bytes: 52 - 12) so its
574        // rec_len extends right up to the reserved csum tail.
575        buf[16..18].copy_from_slice(&40u16.to_le_bytes());
576        buf[18] = 2;
577        buf[19] = 2;
578        buf[20] = b'.';
579        buf[21] = b'.';
580
581        // Mark the last 12 bytes as a csum tail (untouched by any call below).
582        buf[52..56].copy_from_slice(&0u32.to_le_bytes());
583        buf[56..58].copy_from_slice(&12u16.to_le_bytes());
584        buf[58] = 0;
585        buf[59] = 0xDE;
586
587        // 21-char name needs 32 bytes → must fail.
588        let too_big = b"aaaaaaaaaaaaaaaaaaaaa"; // 21 'a'
589        assert!(matches!(
590            add_entry_to_block(&mut buf, 10, too_big, DirEntryType::RegFile, true, 12),
591            Err(Error::OutOfBounds)
592        ));
593        // 13-char name needs 24 bytes → fits in the 28-byte dotdot tail.
594        let ok = b"aaaaaaaaaaaaa"; // 13 'a'
595        add_entry_to_block(&mut buf, 10, ok, DirEntryType::RegFile, true, 12)
596            .expect("add should succeed within reserved tail");
597
598        // Csum tail bytes must be untouched.
599        assert_eq!(buf[58], 0);
600        assert_eq!(buf[59], 0xDE);
601    }
602
603    #[test]
604    fn remove_entry_coalesces_into_previous() {
605        let mut buf = synth_block();
606        let ok = remove_entry_from_block(&mut buf, b"hello", true, 0).expect("remove");
607        assert!(ok);
608
609        let entries = parse_block(&buf, true).expect("parse");
610        assert_eq!(entries.len(), 2);
611        assert_eq!(entries[0].name, b".");
612        assert_eq!(entries[1].name, b"..");
613
614        // ".." rec_len now absorbs hello's rec_len (was 4072, grows to 4084).
615        let dotdot_rec_len = u16::from_le_bytes(buf[16..18].try_into().unwrap());
616        assert_eq!(dotdot_rec_len, 4084);
617    }
618
619    #[test]
620    fn remove_entry_tombstones_first_slot() {
621        let mut buf = synth_block();
622        // Overwrite "." with a regular-file entry so removal hits the first-slot path.
623        buf[0..4].copy_from_slice(&17u32.to_le_bytes());
624        buf[6] = 4; // name_len 4
625        buf[7] = 1; // REG
626        buf[8..12].copy_from_slice(b"root");
627
628        let ok = remove_entry_from_block(&mut buf, b"root", true, 0).expect("remove");
629        assert!(ok);
630        // Inode zeroed, rec_len preserved.
631        let first_inode = u32::from_le_bytes(buf[0..4].try_into().unwrap());
632        assert_eq!(first_inode, 0);
633        let first_rec = u16::from_le_bytes(buf[4..6].try_into().unwrap());
634        assert_eq!(first_rec, 12);
635
636        // Parser should skip the tombstone and return just the other two.
637        let entries = parse_block(&buf, true).expect("parse");
638        assert_eq!(entries.len(), 2);
639    }
640
641    #[test]
642    fn remove_entry_reports_missing() {
643        let mut buf = synth_block();
644        let ok = remove_entry_from_block(&mut buf, b"nope", true, 0).expect("remove");
645        assert!(!ok);
646
647        let entries = parse_block(&buf, true).expect("parse");
648        assert_eq!(entries.len(), 3);
649    }
650
651    #[test]
652    fn add_then_remove_round_trips() {
653        let mut buf = synth_block();
654        add_entry_to_block(&mut buf, 77, b"fresh", DirEntryType::RegFile, true, 0).unwrap();
655        assert_eq!(parse_block(&buf, true).unwrap().len(), 4);
656
657        assert!(remove_entry_from_block(&mut buf, b"fresh", true, 0).unwrap());
658        let entries = parse_block(&buf, true).unwrap();
659        assert_eq!(entries.len(), 3);
660        assert!(!entries.iter().any(|e| e.name == b"fresh"));
661    }
662
663    #[test]
664    fn add_entry_reuses_tombstone_in_middle() {
665        // Layout: ".", tombstone (rec_len 16), "tail" (rec_len block-28).
666        let block_size = 4096;
667        let mut buf = vec![0u8; block_size];
668        buf[0..4].copy_from_slice(&2u32.to_le_bytes());
669        buf[4..6].copy_from_slice(&12u16.to_le_bytes());
670        buf[6] = 1;
671        buf[7] = 2;
672        buf[8] = b'.';
673        // Tombstone (inode=0), rec_len=16 — big enough for a 1-byte name entry.
674        buf[12..14].copy_from_slice(&0u16.to_le_bytes());
675        buf[14..16].copy_from_slice(&0u16.to_le_bytes());
676        buf[16..18].copy_from_slice(&16u16.to_le_bytes());
677        // Remaining tail entry.
678        let tail_off = 28;
679        let tail_rec_len = (block_size - tail_off) as u16;
680        buf[tail_off..tail_off + 4].copy_from_slice(&5u32.to_le_bytes());
681        buf[tail_off + 4..tail_off + 6].copy_from_slice(&tail_rec_len.to_le_bytes());
682        buf[tail_off + 6] = 4;
683        buf[tail_off + 7] = 1;
684        buf[tail_off + 8..tail_off + 12].copy_from_slice(b"tail");
685
686        add_entry_to_block(&mut buf, 99, b"x", DirEntryType::RegFile, true, 0).unwrap();
687        // New entry should have landed in the tombstone slot (offset 12).
688        let new_inode = u32::from_le_bytes(buf[12..16].try_into().unwrap());
689        assert_eq!(new_inode, 99);
690        let new_rec_len = u16::from_le_bytes(buf[16..18].try_into().unwrap());
691        assert_eq!(new_rec_len, 16, "tombstone slot rec_len preserved");
692        assert_eq!(&buf[20..21], b"x");
693
694        let entries = parse_block(&buf, true).unwrap();
695        assert_eq!(entries.len(), 3);
696        assert_eq!(entries[1].name, b"x");
697        assert_eq!(entries[2].name, b"tail");
698    }
699}