#+TITLE: M2a: BSD Test Coverage — Step Plan
#+DATE: 2026-03-18

*Status: DONE. All 18 tasks complete.*
*Branch: =m2-1.8-development=*

* Overview

Establish test coverage for FreeBSD and OpenBSD — the BSD platforms
we're keeping in v1.8.0. Currently =src/bsd/quota.c= (181 LOC) has
zero test coverage.

The BSD test framework lives in =test/bsd/= as a self-contained
parallel to the Linux tests in =test/=. Separate code, separate
entry point, separate concerns. Tests verify the same quota
operations but are adapted to BSD filesystem tools and behavior.

Architecture: QEMU full-OS VMs, SSH+scp for file sharing, build
quotatool inside each VM. No 9p, no virtme-ng, no host-compiled
binary sharing.

Research: =research/bsd-testing-research.org=

* Approach

1. FreeBSD first (official cloud-init qcow2 images — fast path)
2. OpenBSD second (autoinstall from ISO — more setup)
3. Latest release only per BSD (14.4, 7.8). Older versions later if needed.
4. Build inside VM — realistic for BSD users/maintainers
5. Iterate until the full flow works: git clone → setup → test
6. After framework is solid: fix #36, then full BSD code audit
7. Human installs packages; we track and document dependencies

Order: infrastructure → FreeBSD → OpenBSD → bug fixes → audit.

* Directory Structure

#+begin_example
test/bsd/
  run-tests              # BSD test entry point (--setup, --freebsd, --openbsd, --all)
  check-deps.sh          # Verify host-side dependencies
  DEPENDENCIES.md        # What to install (human reads this)
  lib/
    vm.sh                # QEMU boot, SSH wait, shutdown, image management
    fs-setup.sh          # BSD filesystem setup (mdconfig/vnconfig, newfs, quotaon)
    common.sh            # Shared helpers (result reporting, test user setup)
  tests/
    t-*.sh               # BSD-adapted test scripts
  guest-run-all.sh       # Runs inside BSD VM (orchestrates test scripts)
  images/                # Downloaded qcow2s, seed ISOs (gitignored)
  freebsd/
    cloud-init/          # user-data, meta-data templates
    setup.sh             # FreeBSD-specific image download + provisioning
  openbsd/
    autoinstall/         # install.conf, site tarballs
    setup.sh             # OpenBSD-specific image build
#+end_example

=test/= (existing Linux framework) is *not touched*.

* Tasks

** DONE 1. Host Dependencies & Documentation

Document what needs to be installed on the Linux host. Create
=test/bsd/DEPENDENCIES.md= and =test/bsd/check-deps.sh=.

*Tools needed*:
- =qemu-system-x86_64= (likely already present from Linux tests)
- =qemu-img= (CoW overlay creation)
- =genisoimage= or =mkisofs= (FreeBSD cloud-init seed ISO)
- =ssh=, =scp= (file transfer to/from VM)
- =curl= (downloading BSD images)
- =xz= (decompressing .qcow2.xz)

/Subtasks/:
- [ ] Create =test/bsd/DEPENDENCIES.md= with package names per distro
- [ ] Create =test/bsd/check-deps.sh= — verify all tools present
- [ ] User installs missing packages
- [ ] Verify =check-deps.sh= passes

/Verification/: =test/bsd/check-deps.sh= exits 0.

** DONE 2. FreeBSD Image Setup

Download FreeBSD 14.4 UFS cloud-init qcow2, create seed ISO,
verify VM boots and is accessible via SSH.

*Image*: =FreeBSD-14.4-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz=
*Source*: https://download.freebsd.org/releases/VM-IMAGES/14.4-RELEASE/amd64/Latest/

/Subtasks/:
- [ ] Create =test/bsd/freebsd/cloud-init/= with user-data and meta-data
      - Root SSH key (generate dedicated test key or use existing)
      - Install build deps: =pkg install -y autoconf automake gmake gcc=
      - Enable quota: fstab + rc.conf
      - Create test user/group
- [ ] Create =test/bsd/freebsd/setup.sh=
      - Download qcow2.xz (idempotent — skip if exists)
      - Decompress
      - Generate seed ISO via genisoimage
      - Create CoW overlay (=qemu-img create -b ...=)
- [ ] Boot VM, verify SSH access, verify quota support
- [ ] Snapshot provisioned image for fast subsequent boots

/Verification/: =ssh -p 2222 root@localhost 'sysctl kern.features.ufs_quota'=
returns =1=.

/Dependencies/: 1

** DONE 3. VM Boot & SSH Library

Create =test/bsd/lib/vm.sh= — start QEMU, wait for SSH, run
commands, shutdown. This is the BSD equivalent of =test/lib/boot.sh=.

/Subtasks/:
- [ ] =vm_start()= — launch QEMU with correct flags, return PID
      - KVM acceleration
      - User-mode networking with SSH port forward
      - Serial console (=-nographic=)
      - CoW overlay disk
      - Configurable memory, CPUs, SSH port
- [ ] =vm_wait_ssh()= — poll SSH until ready (with timeout)
- [ ] =vm_run()= — execute command via SSH, return exit code
- [ ] =vm_copy_to()= / =vm_copy_from()= — scp wrappers
- [ ] =vm_shutdown()= — clean shutdown via SSH + wait for QEMU exit
- [ ] =vm_cleanup()= — kill QEMU if still running (EXIT trap)

/Verification/: can boot FreeBSD, run a command, get output, shutdown cleanly.

/Dependencies/: 2

** DONE 4. Filesystem Setup Library (BSD)

Create =test/bsd/lib/fs-setup.sh= — create quota-enabled UFS
filesystem inside the BSD VM.

Key differences from Linux:
- FreeBSD: =mdconfig= (memory disk) instead of =losetup=
- OpenBSD: =vnconfig= (vnode disk) instead of =losetup=
- =newfs= instead of =mkfs.ext4=
- Mount options: =userquota,groupquota=
- =quotacheck -a && quotaon -a=
- Block size: 512 bytes (UFS) vs 1024 (ext4)

/Subtasks/:
- [ ] =fs_create_ufs()= — create loopback UFS with quotas enabled
      - FreeBSD: =truncate + mdconfig + newfs + mount + quotacheck + quotaon=
      - Parameterize for FreeBSD vs OpenBSD differences
- [ ] =fs_teardown()= — unmount, detach device, remove image
- [ ] =fs_teardown_all()= — cleanup all tracked filesystems

/Verification/: create UFS inside FreeBSD VM, =repquota -a= shows
quota tracking active.

/Dependencies/: 3

** DONE 5. First Test Script — Basic Block Limit

Port =test/tests/t-basic-block-limit.sh= to BSD. This is the
simplest quota test: set a block limit, verify it's applied.

Adaptations needed:
- =runuser= → =su -m= or equivalent
- quotatool binary built inside VM (=./configure && make=)
- =repquota= output parsing (different format)
- Block size differences (512 vs 1024)
- No XFS-specific sync workarounds

/Subtasks/:
- [ ] Create =test/bsd/tests/t-basic-block-limit.sh=
- [ ] Create =test/bsd/guest-run-all.sh= (initially runs just this one test)
- [ ] Build quotatool inside FreeBSD VM (=./configure && gmake=)
- [ ] Run test, verify PASS

/Verification/: test passes on FreeBSD 14.4.

/Dependencies/: 4

** DONE 6. BSD Test Runner

Create =test/bsd/run-tests= — the entry point. Orchestrates
image setup, VM boot, source transfer, build, test execution,
result collection.

*Modes*:
- =--setup= — download images, verify deps, provision VMs
- =--freebsd= — run tests on FreeBSD
- =--openbsd= — run tests on OpenBSD (placeholder until task 10)
- =--all= — run both
- =--interactive= — boot VM, setup filesystems, drop to SSH shell

*Flow*:
1. Check deps
2. Boot VM (from snapshot if available)
3. scp quotatool source into VM
4. Build inside VM (=./configure && gmake=)
5. Run =guest-run-all.sh= via SSH
6. Collect results (scp or parse SSH output)
7. Shutdown VM
8. Report pass/fail summary

/Subtasks/:
- [ ] Argument parsing, help text
- [ ] FreeBSD test flow (full pipeline)
- [ ] Result collection and reporting
- [ ] Interactive mode (SSH shell with quota filesystems ready)
- [ ] --setup mode (image download + provisioning)

/Verification/: =test/bsd/run-tests --setup && test/bsd/run-tests --freebsd=
completes with test results.

/Dependencies/: 5

** DONE 7. Port Remaining Test Scripts to FreeBSD

16 test scripts adapted and passing on FreeBSD 14.4 (50 subtests):

- [X] =t-basic-block-limit.sh= (5 subtests)
- [X] =t-basic-soft-limit.sh= (3 subtests)
- [X] =t-combined-limits.sh= (4 subtests)
- [X] =t-inode-limits.sh= (4 subtests)
- [X] =t-enforce-block-hard.sh= (3 subtests)
- [X] =t-enforce-block-soft.sh= (3 subtests)
- [X] =t-enforce-inode-hard.sh= (3 subtests)
- [X] =t-enforce-inode-soft.sh= (3 subtests)
- [X] =t-grace-period.sh= (2 subtests)
- [X] =t-limit-isolation.sh= (2 subtests)
- [X] =t-group-quota.sh= (3 subtests)
- [X] =t-reset-limits.sh= (3 subtests)
- [X] =t-raise-only.sh= (3 subtests)
- [X] =t-relative-adjust.sh= (7 subtests)
- [X] =t-dry-run.sh= (2 subtests)
- [X] =t-numeric-uid.sh= (3 subtests)

/Verification/: =test/bsd/run-tests --freebsd= — 16/16 passed.

/Dependencies/: 6

** DONE 8. End-to-End FreeBSD Validation

Full flow test on a clean system: git clone → setup → run tests.
This validates the "BSD maintainer experience."

/Subtasks/:
- [ ] Test from fresh clone (no cached images)
- [ ] Verify =--setup= downloads and provisions correctly
- [ ] Verify =--freebsd= runs all tests and reports results
- [ ] Verify =--interactive= mode works
- [ ] Document any manual steps required
- [ ] Review and clean up all scripts

/Verification/: another person (or fresh environment) can run the
full pipeline from the README/docs alone.

/Dependencies/: 7

** DONE 9. Checkpoint: FreeBSD Complete

Validated E2E on Fedora 43 (separate machine, fresh clone).
16/16 tests, 50 subtests pass. Ready for OpenBSD.

/Dependencies/: 8

** DONE 10. OpenBSD Image Setup

Create automated OpenBSD 7.8 installation using autoinstall(8).

More complex than FreeBSD — no pre-built cloud images. Must:
1. Download install media
2. Create autoinstall response file (=install.conf=)
3. Create =site78.tgz= with post-install setup
4. Boot installer in QEMU with HTTP-served response file
5. Wait for install to complete (~10-15 min one-time)
6. Snapshot the installed qcow2 as base image

Key differences from FreeBSD:
- No cloud-init — uses autoinstall(8)
- FFS not UFS (similar but different naming)
- =vnconfig= not =mdconfig= for loopback
- =edquota= is interactive-only (use quotatool itself)
- HTTP server needed during install (QEMU guestfwd or socat)

/Subtasks/:
- [ ] Create =test/bsd/openbsd/autoinstall/install.conf=
- [ ] Create =site78.tgz= (post-install: SSH keys, quota setup, build deps)
- [ ] Create =test/bsd/openbsd/setup.sh= (download + automated install)
- [ ] Verify: VM boots, SSH works, quotas functional
- [ ] Snapshot base image

/Verification/: =ssh -p 2222 root@localhost 'mount'= shows quota options.

/Dependencies/: 9

** DONE 11. Adapt Tests for OpenBSD

Update =test/bsd/lib/fs-setup.sh= for OpenBSD differences and
verify all test scripts pass on OpenBSD 7.8.

Key adaptations:
- =vnconfig= instead of =mdconfig= for loopback devices
- =newfs= flags may differ
- =touch quota.user quota.group= before =quotacheck=
- No =edquota -e= — rely on quotatool for setting quotas in tests
- May need OpenBSD-specific branches in =fs-setup.sh=

/Subtasks/:
- [ ] Add OpenBSD support to =fs-setup.sh= (detect OS, branch)
- [ ] Run each test script on OpenBSD, fix failures
- [ ] Verify all tests pass

/Verification/: =test/bsd/run-tests --openbsd= — all tests pass.

/Dependencies/: 10

** DONE 12. End-to-End Validation (on separate machine)

Validated on Fedora 43 (separate machine, fresh clone):
=test/bsd/run-tests --all= — FreeBSD 16/16, OpenBSD 16/16.

/Dependencies/: 11

** TODO 13. Checkpoint: BSD Test Framework Complete

Both FreeBSD and OpenBSD under test. Commit, update state files.

/Subtasks/:
- [ ] =test/bsd/run-tests --all= passes (FreeBSD + OpenBSD)
- [ ] Update MASTERPLAN.org
- [ ] Update session.md
- [ ] Clean commit history

/Dependencies/: 12

** DONE 14. Fix #36: BSD Expired Grace Display

Now that we have a working BSD test framework, fix the bug.

*File*: =src/main.c= lines 96-119
*Bug*: expired grace periods display as huge unsigned numbers
*Fix*: add => now= check before subtraction (same pattern as Linux fix in commit 5ea6afe)
*Affects*: both block grace (lines 96-102) and inode grace (lines 109-115)

/Subtasks/:
- [ ] Write/adapt test that catches the bug (must fail before fix)
- [ ] Verify test fails on FreeBSD and/or OpenBSD
- [ ] Apply fix (minimal change to =src/main.c=)
- [ ] Verify test passes on BSD
- [ ] Verify no regressions on Linux (=test/run-tests --host-only=)
- [ ] Human review

/Verification/: BSD grace test passes. Linux tests still 25/25.

/Dependencies/: 13

** DONE 15. Full BSD Code Audit

With test coverage in place, audit all of =src/bsd/quota.c= for
potential bugs, edge cases, and modernization opportunities.

/Subtasks/:
- [ ] Review =quota_new()= — path construction, error handling
- [ ] Review =quota_get()= — quotactl return values, struct field mapping
- [ ] Review =quota_set()= — same
- [ ] Review =quota_reset_grace()= — does raise/restore still work on modern BSDs?
- [ ] Review block size handling — 512-byte UFS blocks correct everywhere?
- [ ] Review NetBSD +1 workaround (lines 118-124) — still needed? still correct?
- [ ] Check for memory leaks, buffer overflows, signed/unsigned issues
- [ ] Cross-reference with Linux fixes from M3 — any BSD equivalents?
- [ ] File bugs for anything found, add tests, fix

/Verification/: audit document written, all found issues tracked.

/Dependencies/: 14

* Dependency Order

#+begin_example
1 (deps)
  └→ 2 (freebsd image)
       └→ 3 (vm library)
            └→ 4 (fs-setup)
                 └→ 5 (first test)
                      └→ 6 (runner)
                           └→ 7 (port all tests)
                                └→ 8 (e2e freebsd)
                                     └→ 9 (checkpoint)
                                          └→ 10 (openbsd image)
                                               └→ 11 (adapt tests)
                                                    └→ 12 (e2e openbsd)
                                                         └→ 13 (checkpoint)
                                                              └→ 14 (fix #36)
                                                                   └→ 15 (audit)
#+end_example

Strictly sequential. Each task builds on the previous.
FreeBSD (tasks 1-9) is the primary track. OpenBSD (10-12) follows.
Bug fixes and audit (14-15) only after framework is validated.

* Findings & Follow-ups

Issues discovered during BSD testing. To be investigated
during task 15 (BSD code audit) unless noted otherwise.

** FIXED: quotatool hung on OpenBSD due to Q_SYNC in quota_set()

*Root cause*: =quotactl(Q_SYNC)= blocks indefinitely on OpenBSD 7.8
vnd-backed FFS. =Q_GETQUOTA= and =Q_SETQUOTA= work fine.

Diagnosed using minimal C test program (=test/bsd/openbsd/test-quotactl.c=):
- Step 1: =Q_GETQUOTA= → ret=0 (OK)
- Step 2: =Q_SETQUOTA= → ret=0 (OK)
- Step 3: =Q_SYNC= → hangs indefinitely

Cause: dquot lock contention or vnd buffer cache stalls. OpenBSD
disabled softdep in 2023, making FFS metadata writes synchronous.
On vnd devices this can trigger buffer cache exhaustion.

*Fix*: removed =Q_SYNC= from =quota_set()= for all BSDs. The call
was redundant — =Q_SETQUOTA= already persists data via the kernel's
internal =dqrele()/dqsync()= path. OpenBSD's own =edquota(8)= does
not call =Q_SYNC= either.

Also discovered: =sync(1)= triggers the same hang on OpenBSD vnd.
Removed =sync= calls from test scripts.

Commit: 4c4b26d

** RESOLVED: Grace period =-t= on BSD requires quota cycle

=quotatool -t= now correctly writes grace to uid 0 via =Q_SETQUOTA=.
But BSD kernels cache grace at =quotaon= time — a =quotaoff=/=quotaon=
cycle is needed for the new value to take effect. This is identical
to how =edquota -t= works. Confirmed on FreeBSD (vnd + root) and
OpenBSD (vnd + /home). Not a bug — BSD UFS quota design.

Previously, =quotatool -t= wrote to the target user's =dqb_btime=
(ignored by kernel for non-root users). Now writes to uid 0.

Original finding below for reference:

=quotatool -u testuser -b -t "1hours"= does not change the
grace period from the default 604800s (7 days). The command
exits 0 (no error), but the value reported by =-d= after
exceeding soft limit is always 604800.

Possible causes:
- BSD UFS grace is a global per-filesystem setting, not per-user.
  quotatool may be trying to set it per-user which has no effect.
- quotatool's =-t= code path in =src/bsd/quota.c= may not be
  reaching the kernel correctly.
- The 604800 is the BSD default grace period (7 days), suggesting
  the set operation silently fails.

*Action*: investigate in task 15. Compare =src/bsd/quota.c= grace
handling vs =src/linux/quota.c=. Check FreeBSD =edquota -t= behavior.

** FreeBSD quota tools require fstab entries

Unlike Linux, FreeBSD's =quotacheck=, =quotaon=, =repquota=, and
=edquota= all require the filesystem to be listed in =/etc/fstab=
with quota options. Ad-hoc mounts with =-o userquota,groupquota=
are not recognized.

=fs-setup.sh= works around this by adding temporary fstab entries,
cleaned up on teardown.

** =edquota -e= appears non-functional on FreeBSD 14.4

=edquota -e /mnt/quota-test:1000:2000:0:0 testuser= runs without
error but does not actually set quotas. This may be a FreeBSD bug
or a misunderstanding of the =-e= syntax. Not a problem for us —
we use quotatool itself to set quotas in tests.

** Cloud-init default user is =freebsd=, not =root=

FreeBSD cloud-init images create a =freebsd= user (uid 1001, wheel
group). =ssh_authorized_keys= at top level goes to this user, not
root. Root SSH requires explicit =/etc/ssh/sshd_config= change +
key placement under root user in cloud-init config.

** Boot performance

| Path | Time |
|------|------|
| Cold boot (cloud-init + packages) | ~75s |
| Fast boot (provisioned snapshot) | ~12-18s |
| Full test run (boot + build + 17 tests + shutdown) | ~45s |

** DONE: Code audit — bugs fixed in =src/bsd/quota.c=

Full audit of =src/bsd/quota.c= found and fixed 5 bugs:

1. *=memset(&sysquota, 0, ...)= added to =quota_set()=*: =struct dqblk=
   was not zeroed before =Q_SETQUOTA=. Uninitialized padding bytes
   were passed to the kernel.

2. *=block_grace=/=inode_grace= initialized in =quota_get()=*: these
   fields were never set from kernel data, so =quota_set()= wrote
   garbage from =malloc()= to =dqb_btime=/=dqb_itime=. The kernel
   ignores these for non-root users, hiding the bug.

3. *Memory leak fixed*: =myquota= was not freed when =system_getfs()=
   failed (line 52). Also =myquota= and =fs= not freed on =qfile=
   =malloc= failure.

4. *Pointer arithmetic on =qfile= replaced with =memmove()=*:
   the double-slash skipping loop (=qfile++=) advanced the pointer
   past the =malloc= origin, breaking =free()=. Darwin has the same
   bug (=// free gives error on darwin, why?= — this is why).

5. *Removed incorrect =(int32_t)= cast on =dqb_btime=/=dqb_itime=*:
   FreeBSD uses =int64_t=, OpenBSD uses =u_int32_t=. Let the compiler
   handle the conversion.

** Audit: remaining findings for M2c cleanup

These are deferred to M2c (platform drops) or future work:

*** Dead code: PLATFORM_DARWIN references

=quota.h=, =main.c=: =#if PLATFORM_DARWIN= blocks will be dead
after macOS drop. Remove during M2c.

*** Dead code: =__NetBSD__= workarounds

=src/bsd/quota.c= lines 118-124: +1 adjustment in =quota_get()=.
=src/bsd/quota.c= line 171: +2 in =quota_reset_grace()=.
Remove when NetBSD is dropped in M2c.

*** =dqb_curblocks=/=dqb_curinodes= set but ignored by kernel

=quota_set()= writes current usage fields (lines 140, 143) but
BSD kernels explicitly overwrite them with existing values in
=setquota()=. Harmless but misleading. Consider removing.

*** =-t= grace period: #37 (filed)

=quotactl(Q_SETQUOTA)= ignores =dqb_btime=/=dqb_itime= for
non-root users. =-t= needs to target uid 0 for global grace.
=-r= grace reset could potentially use direct =dqb_btime=0=
for uid 0. See GitHub issue #37.

*** =struct dqblk= size differences

FreeBSD: 64-bit fields (=dqblk64=). OpenBSD: 32-bit fields.
Max quota on OpenBSD: ~2TB (=2^32= × 512-byte blocks).
Not a bug — just a platform limitation.

*** quotactl first argument

quotatool passes ={mountpoint}/quota.user= as the path.
Both FreeBSD and OpenBSD kernels use =namei()= to resolve it to a
mount point, so it works IF the quota file exists. System tools
(=edquota=) pass the mount point directly — more robust.
Low risk: the file always exists when quotas are enabled.

* BSD Bug Fixes (added post-audit, before M2b/M2c)

Fix known BSD issues while context is fresh, before moving to
platform drops. Test infrastructure is running, code is audited.

** DONE 16. Fix #37: =-t= Grace Period on BSD

*File*: =src/bsd/quota.c=, =src/main.c=
*Bug*: =-t= sets =dqb_btime= on the target user, but BSD kernels
ignore =dqb_btime= for non-root users. The global grace period
is stored on uid 0's =dqb_btime=.

*Fix*: when =-t= is used, call =Q_SETQUOTA= on uid 0 with the
desired grace duration in =dqb_btime=, not on the target user.
This matches what =edquota -t= does.

*Also investigate*: can =-r= (grace reset) use direct
=dqb_btime=0= on uid 0 instead of the raise/restore hack?

/Subtasks/:
- [ ] Write test that verifies =-t= actually changes grace period
- [ ] Verify test fails (current behavior: grace stays at 604800s)
- [ ] Fix =-t= to target uid 0 on BSD
- [ ] Verify test passes
- [ ] Verify no regressions (=run-tests --all=)
- [ ] Close #37

/Dependencies/: 15

** DONE 17-18. Clean Up =quota_set()= and =quota_new()=

Combined into one commit (2693fe7):

- Removed =dqb_curblocks=/=dqb_curinodes= in =quota_set()= — kernel
  ignores these in =Q_SETQUOTA=.
- Changed =quota_new()= to pass mount point directly to =quotactl()=
  instead of constructing =/quota.user= path. Kernel uses =namei()=
  to resolve any path to its mount point. Matches =edquota(8)=.
  Removed =Q_USER_FILENAME=/=Q_GROUP_FILENAME= defines, slash-dedup
  code, =strlcpy=/=strlcat= blocks. Net -33 lines.

Tested: Linux 25/25, FreeBSD 17/17, OpenBSD 17/17.
