# DF-3082 — ext2 htree-indexed directory: create() truncates the directory mid-tree (mass silent data loss on a valid fs, then kernel panic)

## Summary

`ext2_lookup_ino()` (sys/vfs/ext2fs/ext2_lookup.c:377-397) dispatches **every**
namei op — including `NAMEI_CREATE` — to `ext2_htree_lookup()` when the
directory carries `EXT2_INDEX_FL` (→ `IN_E3INDEX`, set from disk at
sys/vfs/ext2fs/ext2_inode_cnv.c:170) and the fs has the `dir_index`
compat feature. On a miss, `ext2_htree_lookup()` returns `ENOENT` after
searching **only the leaf blocks on the hash path**
(sys/vfs/ext2fs/ext2_htree.c:381-415): it leaves the caller's
`struct ext2fs_searchslot ss` pointing at a slot inside one leaf block and
`*endusefulp` (ext2_htree.c:389-392, updated leaf-locally by
`ext2_search_dirblock`) at the end of the last used entry **of that leaf**.

`ext2_lookup_ino()`'s `case ENOENT:` (ext2_lookup.c:387-389) falls into the
`notfound:` label, which builds the create parameters from this **partial**
search state (ext2_lookup.c:495-505):

```c
dp->i_offset = ss.slotoffset;
dp->i_count  = ss.slotsize;
...
dp->i_endoff = roundup2(enduseful, DIRBLKSIZ);   /* leaf-local! */
```

`ext2_direnter()` (ext2_lookup.c:948-954) then inserts the entry and
"compacts" the directory by **truncating it to `i_endoff`**:

```c
error = ext2_add_entry(dvp, &newdir);
if (!error && dp->i_endoff && dp->i_endoff < dp->i_size)
        error = ext2_truncate(dvp, (off_t)dp->i_endoff, IO_SYNC, ...);
```

For a linear (non-indexed) search `enduseful` spans the whole directory and
the truncate is the historic UFS directory-compaction. For an **indexed**
directory `i_endoff` is the end of *one leaf block in the middle of the
file*: the truncate deletes **every directory block after that leaf** —
while the htree index (dx root still in block 0) keeps referencing the freed
blocks. All entries in later leaves are silently destroyed (`i_size`
collapses); the freed blocks read back as zero-filled holes, so the very
next lookup whose hash resolves into a freed leaf walks a zero block,
`ext2_check_direntry()` rejects `rec_len == 0`, and `ext2_dirbad()`
(ext2_lookup.c:771-784) **panics** the writable-mounted kernel
(see DF-3083).

Note: FreeBSD's ext2 keeps `ext2_htree_add_entry()` enabled in
`ext2_direnter()` and never runs the linear compact-truncate for indexed
dirs. DragonFly disabled the htree add path (`#if 0`,
ext2_lookup.c:920-941) but kept the truncate — a dfly-specific regression
that fires on **valid, e2fsck-clean, Linux-created ext3/ext2 images**
(Linux indexes directories by default: `dir_index` + `EXT2_INDEX_FL`).

All entry-insertion syscalls funnel here: create
(ext2_vnops.c:1784), mkdir (:1229), link (:620), rename-into (:885).

## Reachability

* fs must carry `EXT2F_COMPAT_DIRHASHINDEX` and the target directory
  `EXT2_INDEX_FL` — the Linux default for ext3 dirs with >1 block of
  entries; the image need not be "crafted", merely Linux-created and
  e2fsck-clean (our trigger image is exactly that).
* mount read-write (root mount, or `vfs.usermount=1` own mount).
* the creating user needs write permission in the directory — an
  **unprivileged** user once the fs is mounted.
* the new name must hash into a non-last leaf whose free slack fits the new
  entry (with ≥2 leaves and normal e2fsck packing this hits within a few
  creates; in our run the *first* create triggered it).

## Reproduced (baseline, stock INVARIANTS kernel #0)

```
mount -t ext2fs /dev/vn0 /mnt/e2          # htree.img: dir d, 100 entries,
                                          # 2 dx leaves (blocks 2,3), i_size 4096
$ ls /mnt/e2/d | wc -l                    # as unprivileged user maxx
100
$ rm payloadname01..50                    # (silently ENOENT — see notes)
$ touch zzq001
$ ls | wc -l
36                                        # 65 pre-existing entries DESTROYED
$ stat /mnt/e2/d/payloadname002
panic: ext2_dirbad: : bad dir ino 12 at offset 3072: mangled entry
ext2_dirbad() at ext2_dirbad+0x32
ext2_search_dirblock() at ext2_search_dirblock+0x194
ext2_htree_lookup() at ext2_htree_lookup+0x12e
ext2_lookup() at ext2_lookup+0x28d
```

offset 3072 = the freed leaf block 3: the index still points there, the
block reads back zero-filled, the first zero dirent is "mangled" → panic.

## Fix

`fix.diff` — never compact-truncate an indexed directory:

```diff
+	if (!error && !ext2_htree_has_idx(dp) &&
+	    dp->i_endoff && dp->i_endoff < dp->i_size)
 		error = ext2_truncate(dvp, (off_t)dp->i_endoff, IO_SYNC,
 		    cnp->cn_cred);
```

Validated: single-fix `make nativekernel` build, same image, same user —
no collapse (`ls` count keeps growing), no panic, mount survives stress.

## Contents

* `craft.sh` — host-side image builder (mke2fs + debugfs + `e2fsck -fD`)
* `mount.sh` — guest root: vn attach + RW mount + chmod 777 d
* `trigger.sh` — guest unprivileged (maxx): rm/touch/stat sequence
* `run.log`, `panic.txt`, `mount.log` — baseline evidence
* `fix.diff`, `fix_run.log`, `fix_build.log` — fix validation
* `verdict.json`, `manifest.json`, `VERDICT.md`
