# repo22 FreeBSD 15 NFS export manual test report

Date: 2026-08-08 20:08-20:37 UTC

Baseline: `4ef680df6dcbfad329c0b2b1c0af8fcb21cbed4a` plus this NFS export batch

Guest: FreeBSD 15.0-RELEASE-p8 amd64,
`releng/15.0-n281036-53054229dcb3`, QEMU TCG, 4 vCPUs, 6144 MB RAM

Host tools: erofs-utils 1.8.6, clang cross-target build

## Result

**PASS** for the implementation and all reasonably executable NFS tests.

FreeBSD mountd installed real EROFS exports through export-only `MNT_UPDATE`;
NFSv3 clients resolved 16-byte EROFS handles for regular files, directories,
symlinks, FIFOs, metabox bit-63 inodes, and multidevice files. Handle mutation,
same-md remount stability, nfsd restart, 12,050-entry READDIRPLUS pagination,
cold remounts, concurrent traversal/read/stat load, read-only enforcement, and
device-boundary errors all behaved as required.

The only non-code issue was a cleanup-order mistake after the completed test
matrix: one hard loopback NFS mount remained while nfsd and the direct EROFS
mount were stopped. This blocked `mount -p` and later produced five client
`fileid changed` messages when nfsd was temporarily restarted without the
underlying export. The functional-test dmesg snapshot taken before that cleanup
mistake was byte-identical to its baseline. The VM was rebooted to clear the
blocked cleanup process, and a final correctly ordered NFS rerun completed with
byte-identical pre/post dmesg and zero remaining resources.

## Repository and build

Initial verification:

```sh
git -C /work/repo-community/repo22 fetch xdm main
git -C /work/repo-community/repo22 rev-parse HEAD
git -C /work/repo-community/repo22 rev-parse xdm/main
git -C /work/repo-community/repo22 diff --quiet
git -C /work/repo-community/repo22 diff --cached --quiet
```

Both revisions were:

```text
4ef680df6dcbfad329c0b2b1c0af8fcb21cbed4a
```

Tracked files were clean. Existing untracked `repo22/build`,
`tests/results/manual/2026-08-08T1800Z-namei/artifacts`, and files outside
repo22 were not deleted, modified, staged, or committed.

To execute the required `./build.sh` without overwriting the pre-existing
untracked build products, the current source was copied to an isolated build
directory:

```sh
BUILD_ROOT=$(mktemp -d /work/build/repo22-nfs-build.XXXXXX)
mkdir -p "$BUILD_ROOT/src"
cp repo-community/repo22/build.sh "$BUILD_ROOT/build.sh"
cp repo-community/repo22/src/* "$BUILD_ROOT/src/"
chmod +x "$BUILD_ROOT/build.sh"
cd "$BUILD_ROOT"
./build.sh
nm -u build/erofs.ko | \
  awk '$NF == "bcmp" { found = 1 } END { if (found) exit 1 }'
sha256sum build/erofs.ko
```

Result:

```text
==> SUCCESS: /work/build/repo22-nfs-build.eFulVf/build/erofs.ko
48776485d1679f00100bc7b2be5f891c8f03a3e8b53ac7c00da8c2fd5f74cc70  erofs.ko
```

There was no unresolved `bcmp` reference.

The FreeBSD helper compiled without warnings:

```sh
cc -O2 -Wall -Wextra -std=c17 nfs_fh_tool.c -o nfs_fh_tool
```

## Fixtures

The primary fixture was generated with:

```sh
mkdir -p plain-src/basic/subdir plain-src/bigdir plain-src/concurrent
printf 'repo22-nfs-regular\nline-two\n' > plain-src/basic/regular.txt
printf 'nested-directory-file\n' > plain-src/basic/subdir/nested.txt
ln -s regular.txt plain-src/basic/link-to-regular
mkfifo plain-src/basic/test.fifo
for i in $(seq -w 0 12049); do
  printf 'entry-%s\n' "$i" > plain-src/bigdir/file-$i
done
for i in $(seq -w 0 255); do
  awk -v n="$i" 'BEGIN {
    for (j = 0; j < 256; j++)
      printf "worker-%s-line-%03d-abcdefghijklmnopqrstuvwxyz0123456789\\n", n, j
  }' > plain-src/concurrent/file-$i.dat
done
awk 'BEGIN {
  for (i = 0; i < 262144; i++)
    printf "throughput-%08d-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\\n", i
}' > plain-src/throughput.dat
mkfs.erofs -T0 --all-root --ignore-mtime -x-1 -Uclear \
  plain-nfs.erofs plain-src
```

Fixture and manifest hashes:

| Object | SHA256 |
|---|---|
| `plain-nfs.erofs` | `6928f05b58ce462596f9c7bf1d3442ad50d555dc5c31ed01d28e1a921a448bf2` |
| `metabox-nfs.erofs` | `8f493ed70d4be8096ab1adf8208622734d61a64903cf96a1ca1a1cc1854b1985` |
| multidevice primary | `fa2f35f59f63cc4a56d230ba960a6c1d27748506fccce081447cab0dc0d540fd` |
| multidevice blob 1 | `3aeb19c519636b61dc3ebd47c6286098deaa0990ff618ce3facbff4fd65907cd` |
| source manifest | `0eda6f88c3b6c4b7c8d81d940a237d1f63c958a7f6fe7bdd4f77d6b293736fa9` |
| `basic/regular.txt` | `93e7f327676f582356e9de2fcca7b714910227008fa1c5e7820e942c471edb6f` |
| `basic/subdir/nested.txt` | `6da159d7ea19e9d1253ccb0f2c67586e6e075459954e740ff8405546683d87c3` |
| `concurrent/file-000.dat` | `8435866622642b6dadf502b09f95af32cb01907ba3376e4f1fe96278e5ada692` |
| `throughput.dat` (21,757,952 bytes) | `0841effed82d1adf394b6834ce30d4d9eb5fc9427527e854ec1cb6bb4b86c119` |
| expected 12,050-name sorted list | `4fbf1b6103e8884223629c3fd03f4ec36061e65cd2a1becfc96670daa96f9d71` |

The metabox image was the previously qualified compressed-metabox fixture. The
multidevice fixture was the previously qualified primary-plus-one-blob image.

## FreeBSD 15 setup commands

The artifacts were copied to the existing VM through SSH port 9222. Password
material and askpass script contents were neither printed nor recorded.

```sh
scp -P 9222 erofs.ko plain-nfs.erofs metabox-nfs.erofs \
  tests/nfs_fh_tool.c root@127.0.0.1:/tmp/repo22-nfs-test/
ssh -p 9222 root@127.0.0.1
cd /tmp/repo22-nfs-test
sha256 erofs.ko plain-nfs.erofs metabox-nfs.erofs
cc -O2 -Wall -Wextra -std=c17 nfs_fh_tool.c -o nfs_fh_tool
```

The guest hashes matched the host hashes.

EROFS and NFS setup:

```sh
kldload /tmp/repo22-nfs-test/erofs.ko
mdconfig -a -t vnode -f /tmp/repo22-nfs-test/plain-nfs.erofs -u 42
mkdir -p /mnt/repo22-erofs
mount -t erofs -o ro /dev/md42 /mnt/repo22-erofs
mount -v | grep /mnt/repo22-erofs

printf '%s\n' \
  '/mnt/repo22-erofs -ro -maproot=root -network 127.0.0.0 -mask 255.0.0.0' \
  > /etc/exports
service rpcbind onestart
service mountd onestart
service nfsd onestart
service mountd onereload
rpcinfo -p 127.0.0.1
showmount -e 127.0.0.1
mount -v | grep /mnt/repo22-erofs
```

Before mountd reload the EROFS line did not contain `NFS exported`. Afterwards:

```text
/dev/md42 on /mnt/repo22-erofs (erofs, NFS exported, local, read-only, acls, ...)
```

NFSv2/v3 and mountd were registered over TCP and UDP. NFS components are built
into this GENERIC kernel: `kldload nfsd` and `kldload nfscl` reported
`already loaded or in kernel`, while rpcbind/mountd/nfsd operated normally.

## File-handle validation commands

Normal vnode types:

```sh
./nfs_fh_tool capture /mnt/repo22-erofs/basic/regular.txt regular.before.fh
./nfs_fh_tool capture /mnt/repo22-erofs/basic/subdir directory.fh
./nfs_fh_tool lcapture /mnt/repo22-erofs/basic/link-to-regular symlink.fh
./nfs_fh_tool capture /mnt/repo22-erofs/basic/test.fifo fifo.fh
./nfs_fh_tool describe regular.before.fh
./nfs_fh_tool describe directory.fh
./nfs_fh_tool describe symlink.fh
./nfs_fh_tool describe fifo.fh
./nfs_fh_tool stat regular.before.fh
./nfs_fh_tool stat directory.fh
./nfs_fh_tool stat symlink.fh
./nfs_fh_tool stat fifo.fh
./nfs_fh_tool cat regular.before.fh regular.fhopen.out
```

Observed handles:

| Type | NID | Generation | Result |
|---|---:|---:|---|
| regular | `0x74` | 1 | `fhstat` and `fhopen` PASS |
| directory | `0x76` | 1 | `fhstat` PASS |
| symlink (`lgetfh`) | `0x72` | 1 | `fhstat` PASS |
| FIFO | `0x79` | 1 | `fhstat` PASS |

All had `len=16` and `pad=0`. The regular full-handle SHA256 was:

```text
fb1778fc6bdc979e10d003dd6131a961e7532baa16273c5465935a665bf990f6
```

Malformed and stale classification:

```sh
./nfs_fh_tool mutate regular.before.fh bad-len.fh len 15
./nfs_fh_tool mutate regular.before.fh bad-pad.fh pad 1
./nfs_fh_tool mutate regular.before.fh bad-gen.fh gen 2
./nfs_fh_tool mutate regular.before.fh bad-nid.fh nid_hi 0xffffffff
./nfs_fh_tool expect-stat bad-len.fh EINVAL
./nfs_fh_tool expect-open bad-len.fh EINVAL
./nfs_fh_tool expect-stat bad-pad.fh EINVAL
./nfs_fh_tool expect-open bad-pad.fh EINVAL
./nfs_fh_tool expect-stat bad-gen.fh ESTALE
./nfs_fh_tool expect-open bad-gen.fh ESTALE
./nfs_fh_tool expect-stat bad-nid.fh ESTALE
./nfs_fh_tool expect-open bad-nid.fh ESTALE
```

All eight checks passed. `fhstat` exercised the shared-lock `VFS_FHTOVP` path;
`fhopen` exercised the exclusive-lock path.

Same explicit md-unit remount:

```sh
umount /mnt/repo22-erofs
mdconfig -d -u 42
mdconfig -a -t vnode -f plain-nfs.erofs -u 42
mount -t erofs -o ro /dev/md42 /mnt/repo22-erofs
./nfs_fh_tool capture /mnt/repo22-erofs/basic/regular.txt regular.after.fh
./nfs_fh_tool compare regular.before.fh regular.after.fh
./nfs_fh_tool cat regular.before.fh regular.remount.out
```

The complete handles were byte-identical and the pre-remount handle still read
the correct file after remount.

Non-export mount update rejection:

```sh
mount -u -o noexec /mnt/repo22-erofs
```

Result: `Operation not supported`; the mount remained exported, local,
read-only, and did not gain `noexec`.

## NFSv3 basic and restart tests

Four NFSv3 TCP READDIRPLUS clients were mounted:

```sh
mount_nfs -o nfsv3,tcp,rdirplus,readdirsize=512 \
  127.0.0.1:/mnt/repo22-erofs /mnt/repo22-nfs-512
mount_nfs -o nfsv3,tcp,rdirplus,readdirsize=1024 \
  127.0.0.1:/mnt/repo22-erofs /mnt/repo22-nfs-1024
mount_nfs -o nfsv3,tcp,rdirplus,readdirsize=4096 \
  127.0.0.1:/mnt/repo22-erofs /mnt/repo22-nfs-4096
mount_nfs -o nfsv3,tcp,rdirplus \
  127.0.0.1:/mnt/repo22-erofs /mnt/repo22-nfs-default
nfsstat -m
```

FreeBSD clamped the requested 512/1024/4096 values to an effective
`readdirsize=8192`; the default client used 65536. This was recorded as a client
environment limit, not a failure.

For each client:

```sh
cmp NFS/basic/regular.txt DIRECT/basic/regular.txt
test -d NFS/basic/subdir
test "$(readlink NFS/basic/link-to-regular)" = regular.txt
test -p NFS/basic/test.fifo
test "$(stat -f %i NFS/basic/regular.txt)" = \
  "$(stat -f %i DIRECT/basic/regular.txt)"
! touch NFS/write-must-fail
```

All passed. Writes failed with `Read-only file system`.

nfsd restart with an open descriptor:

```sh
exec 3< /mnt/repo22-nfs-default/basic/regular.txt
service nfsd onerestart
cat <&3 > open-fd-after-nfsd-restart.out
exec 3<&-
cmp open-fd-after-nfsd-restart.out /mnt/repo22-erofs/basic/regular.txt
```

The open descriptor and subsequent path reopen both passed. Direct file handles
captured before and after restart were byte-identical.

## Metabox bit-63 test

Commands:

```sh
mdconfig -a -t vnode -f metabox-nfs.erofs -u 43
mount -t erofs -o ro /dev/md43 /mnt/repo22-metabox
./nfs_fh_tool capture /mnt/repo22-metabox/long/file1.txt metabox.before.fh
./nfs_fh_tool describe metabox.before.fh
./nfs_fh_tool stat metabox.before.fh
./nfs_fh_tool cat metabox.before.fh metabox.fhopen.out
./nfs_fh_tool mutate metabox.before.fh metabox.bad-nid.fh \
  nid_lo 0xffffffff
./nfs_fh_tool expect-stat metabox.bad-nid.fh ESTALE
./nfs_fh_tool expect-open metabox.bad-nid.fh ESTALE
```

Observed handle:

```text
len=16 pad=0 nid=800000000000010a gen=1
ino=9223372036854776074
```

The metabox mount was exported with mountd and mounted over NFSv3. Direct,
`fhopen`, and NFS contents had SHA256:

```text
661b22d2a7bd94a7da35834d4cc3647eb527d48eb08acc75bde2e3a7691ed45e
```

The NFS and direct inode numbers matched. The out-of-range metabox mutation
returned `ESTALE` through both shared and exclusive paths.

## Multidevice boundary test

Normal NFS commands:

```sh
mdconfig -a -t vnode -f primary.img -u 44
mdconfig -a -t vnode -f blob1.img -u 45
mount -t erofs -o ro -o device.1=/dev/md45 /dev/md44 \
  /mnt/repo22-multidev
mount_nfs -o nfsv3,tcp,rdirplus,readdirsize=512 \
  127.0.0.1:/mnt/repo22-multidev /mnt/repo22-nfs-multidev
cmp /mnt/repo22-nfs-multidev/alpha.bin /mnt/repo22-multidev/alpha.bin
cmp /mnt/repo22-nfs-multidev/small.txt /mnt/repo22-multidev/small.txt
```

Normal multidevice NFS reads passed. Representative hashes:

```text
f779dbe3aeaa609beb32212579f3aea441f244621d13c977cb8b52d7ec3016d9  alpha.bin
5990b055a3c6d27681185a25e15ff21ba3cbdca7632faf4332233238e5f45aad  small.txt
001bcf4626e7d52f1bd76dee9a6c6e7d3a718c03602c5f9b07840a6c98b8fdc3  tree/beta.bin
```

Cold detached-provider test:

```sh
umount /mnt/repo22-multidev
mdconfig -d -u 44
mdconfig -a -t vnode -f primary.img -u 44
mdconfig -a -t vnode -f blob1.img -u 45
mount -t erofs -o ro -o device.1=/dev/md45 /dev/md44 \
  /mnt/repo22-multidev
./nfs_fh_tool capture /mnt/repo22-multidev/tree/beta.bin multidev-beta.fh
./nfs_fh_tool stat multidev-beta.fh
mdconfig -d -o force -u 45
! ./nfs_fh_tool cat multidev-beta.fh beta.after-orphan
```

The handle resolved to NID `0x36`, then the cold read returned:

```text
Device not configured
```

This preserved the multidevice `ENXIO` boundary error instead of converting it
to `ESTALE`, reading past the provider, or panicking.

## READDIRPLUS and stress

Every one of the four initial listings returned exactly 12,050 unique expected
names. The expected list and all four sorted outputs had SHA256:

```text
4fbf1b6103e8884223629c3fd03f4ec36061e65cd2a1becfc96670daa96f9d71
```

Dot handling:

```sh
ls -a1 /mnt/repo22-nfs-default/bigdir > nfs-dot-list
test "$(wc -l < nfs-dot-list)" -eq 12052
test "$(grep -cx '\.' nfs-dot-list)" -eq 1
test "$(grep -cx '\.\.' nfs-dot-list)" -eq 1
```

All passed.

Cold pagination used five unmount/remount/list/compare cycles on the small
client. Concurrency then used eight workers, three complete 12,050-entry rounds
each, distributed across the four mounts. All 29 cold/concurrent sorted outputs
had one unique SHA256 value, matching the expected list.

Parallel data/metadata load:

```sh
for M in 512 1024 4096 default; do
  find /mnt/repo22-nfs-$M/concurrent -type f -maxdepth 1 -print0 | \
    xargs -0 -n 1 -P 8 cat > /dev/null &
  find /mnt/repo22-nfs-$M/concurrent -type f -maxdepth 1 -print0 | \
    xargs -0 -n 1 -P 8 stat -f '%i %z' > /dev/null &
done
wait
```

All jobs completed successfully.

Final stress counters:

| Counter | Client | Server |
|---|---:|---:|
| requests / cache misses | 270,854 | 271,174 |
| lookup | 264,989 | 265,109 |
| read | 1,360 | 1,360 |
| READDIRPLUS | 2,512 | 2,512 |
| write | 0 | 0 |
| timed out | 0 | n/a |
| retries | 0 | n/a |

There were no READDIR RPCs because the clients negotiated READDIRPLUS.

Informational throughput:

```text
21757952 bytes transferred in 5.843937 seconds
3723167 bytes/second
```

No fixed throughput threshold was used.

## Test matrix

| Test | Actual | Status |
|---|---|---|
| isolated `./build.sh` | success; module hash recorded | PASS |
| unresolved `bcmp` | none | PASS |
| FreeBSD helper compile | `-Wall -Wextra`, no warnings | PASS |
| final `kldload` / `kldunload` | load, unload, reload, unload succeeded | PASS |
| initial mount export flag | absent before mountd | PASS |
| export-only `MNT_UPDATE` | mountd installed export | PASS |
| non-export `MNT_UPDATE` | `EOPNOTSUPP`, flags unchanged | PASS |
| regular file | direct, `fhopen`, NFS content match | PASS |
| directory | handle and NFS traversal | PASS |
| symlink | `lgetfh`, target and NFS lookup | PASS |
| FIFO | handle metadata and NFS type | PASS |
| read-only behavior | direct and all NFS mounts rejected writes | PASS |
| 16-byte handle ABI | len/pad/NID hi/NID lo/gen observed | PASS |
| generation | `1`, matching `va_gen` | PASS |
| shared/exclusive lock contract | `fhstat` and `fhopen` passed | PASS |
| malformed len/pad | `EINVAL` | PASS |
| stale generation/NID | `ESTALE` | PASS |
| same `md42` remount | complete handle byte-identical | PASS |
| nfsd restart | open descriptor and reopen passed | PASS |
| metabox bit 63 | real `0x800000000000010a` round-trip | PASS |
| metabox boundary mutation | `ESTALE` | PASS |
| multidevice NFS | external-data reads matched | PASS |
| detached external device | cold handle read preserved `ENXIO` | PASS |
| 12,050-entry listings | exact count and names on four clients | PASS |
| cookie pagination | five cold remounts, no duplicate/omission | PASS |
| dot omitted | one `.` and one `..`, 12,052 total | PASS |
| concurrent traversal | 8 workers x 3 rounds, all hashes equal | PASS |
| concurrent read/stat | all jobs completed | PASS |
| READDIRPLUS | client/server 2,512 | PASS |
| NFS timeouts/retries | 0 / 0 | PASS |
| successful NFS writes | 0 | PASS |
| throughput | 3,723,167 B/s, informational | INFO |

## dmesg and cleanup

The functional matrix captured dmesg before loading the test module and after
all functional tests plus a load/unload cycle. Both files contained the same
two pre-existing historical lines and compared byte-for-byte equal:

```text
interface erofs.1 already present in the KLD 'erofs-repo22-xattrfix.ko'!
md0: truncating fractional last sector by 14 bytes
```

No EROFS error, stale-handle message, trap, panic, or new kernel line occurred
during the functional matrix.

The first cleanup attempt used the wrong order: it stopped nfsd and unmounted
the direct EROFS mounts before one hard loopback client was fully gone. This
left a `mount -p` process in uninterruptible NFS wait. When rpcbind/nfsd were
temporarily restarted without the underlying EROFS export to release those
clients, the NFS client logged five `fileid changed` messages. These messages
occurred after the byte-identical functional dmesg snapshot and are classified
as **environment cleanup artifact**, not an EROFS functional failure.

The VM was rebooted to remove the blocked cleanup process. Post-reboot audit:

```text
erofs_mounts=0
nfs_mounts=0
md_units=0
erofs_modules=0
nfsd_pids=0 mountd_pids=0 rpcbind_pids=0
exports_exists=no
```

A final correctly ordered clean rerun then performed:

```sh
kldload erofs.ko
mdconfig -a -t vnode -f plain-nfs.erofs -u 42
mount -t erofs -o ro /dev/md42 /mnt/repo22-erofs
service rpcbind onestart
service mountd onestart
service nfsd onestart
mount_nfs -o nfsv3,tcp,rdirplus,readdirsize=512 \
  127.0.0.1:/mnt/repo22-erofs /mnt/repo22-nfs-clean
cmp /mnt/repo22-nfs-clean/basic/regular.txt \
  /mnt/repo22-erofs/basic/regular.txt
test "$(find /mnt/repo22-nfs-clean/bigdir -type f -maxdepth 1 | wc -l)" \
  -eq 12050
umount /mnt/repo22-nfs-clean
: > /etc/exports
service mountd onereload
service nfsd onestop
service mountd onestop
service rpcbind onestop
umount /mnt/repo22-erofs
mdconfig -d -u 42
kldunload erofs
```

The clean rerun's pre/post dmesg files compared byte-for-byte equal. Final
state after that rerun:

```text
clean_rerun_erofs=0 nfs=0 md=0 module=0 services=0 exports=absent
```

## Limitations

- **ENVIRONMENT-LIMIT**: FreeBSD clamped requested readdir sizes 512, 1024, and
  4096 to an effective minimum of 8192. The test still exercised a much smaller
  value than the 65536 default and produced 2,512 real READDIRPLUS calls.
- **ENVIRONMENT-CLEANUP-ARTIFACT**: the five post-test `fileid changed` lines
  were caused by the explicitly documented wrong cleanup order. A reboot and a
  correctly ordered rerun left no new dmesg lines or resources.
- **KERNEL-FAIL**: none.
- **NOT RUN**: none of the required functional cases were omitted.
