# validateBackup Test Plan

Manual test plan, to be run by hand on real FreeBSD hardware before this code is trusted against production data. Written before any test automation, per this project's convention (see `replicate/TESTING_PLAN_PROMPT.md`: plan first, then automate). No automated harness exists yet for `validateBackup` — this document is the starting point for one, the way `sneakernet/Documentation/TESTING.md` was for `sneakernet`'s.

Every command below uses placeholder names — `the active host` / `the backup host` — instead of real hostnames, per this project's documentation standard. Substitute your actual test hosts.

## Why this matters more than usual

Every prior stage of this project was developed and smoke-tested on a Linux workstation with no ZFS, using shims for `zfs`/`zpool`/`ssh`/`mount` and a GNU-vs-BSD compatibility shim for `find`/`stat`. That caught real logic bugs (see `CHANGELOG.md`), but it **cannot** validate the actual BSD command behaviors the design depends on. This plan exists specifically to close that gap — Part 3 in particular checks assumptions that, if wrong, invalidate the whole comparison pipeline silently rather than with an error.

## Before you start

- Two FreeBSD hosts (or one host with two separate test pools standing in for "active" and "backup") with ZFS. Do not use production pools or datasets.
- Passwordless ssh from the backup host to the active host, for the user that will run `validateBackup` (root, or a user with `zfs`/`zpool` read access). Confirm with `ssh -o BatchMode=yes <activeHost> true` before starting — if this fails, everything past Part 2 will too, for an unrelated reason.
- **This tests the working copy of the code, including anything uncommitted.** Copy the actual `zfs_utils/` working-copy directory (not a fresh `svn checkout`) onto the backup test host.
- A few GB of free space on both test pools is plenty.

---

## Part 0 — One-time setup

### Step 0.1 — Install

Copy `zfs_utils/` (including `ZFS_Utils.pm` and `validateBackup/`) onto the backup host. `validateBackup` only needs to exist there; the active host needs nothing installed beyond `zfs`, `find`, `stat`, a digest command, and ssh access.

### Step 0.2 — Create test datasets

On the active host:
```
zfs create tank/vbtest
zfs create tank/vbtest/data
mkdir -p /tank/vbtest/data/subdir
echo "identical content" > /tank/vbtest/data/same.txt
printf 'AAAAAAAAAA' > /tank/vbtest/data/silent-corruption.txt
echo "only on active" > /tank/vbtest/data/missing-from-backup.txt
echo "this file has a space in its name" > "/tank/vbtest/data/a file.txt"
mkdir /tank/vbtest/data/Documentation
echo "docs index" > /tank/vbtest/data/Documentation/index.html
echo "docs md" > /tank/vbtest/data/Documentation.md
zfs snapshot tank/vbtest/data@daily-test1
```

On the backup host, replicate the dataset (via `replicate`/`sneakernet` if already set up between these two test hosts, or manually):
```
zfs create backup/vbtest       # the parent container must already exist before receiving into it
ssh <activeHost> "zfs send -R tank/vbtest/data@daily-test1" | zfs receive -v backup/vbtest/data
```
**Gotcha:** `zfs receive` with no `-d`/`-e` flag uses its argument as the *literal* name of the
top-level received dataset, not as a parent to receive into. `zfs receive backup/vbtest` (missing
the trailing `/data`) will succeed if `backup/vbtest` doesn't already exist — but it names the
received dataset `backup/vbtest` itself, silently dropping the `data` level, so
`backup/vbtest/data` never gets created. The same command fails outright with "destination
already exists" if `backup/vbtest` was pre-created as an empty container. The full path shown
above (`backup/vbtest/data`) is required. Confirm the result with `zfs list -r backup/vbtest`
before continuing — do not assume the receive did what you intended.

Once `backup/vbtest/data@daily-test1` exists with identical content, **deliberately introduce** divergence into the backup's *live* filesystem:
```
touch -r /backup/vbtest/data/silent-corruption.txt /tmp/vbtest-mtime-ref   # save its original mtime
printf 'BBBBBBBBBB' > /backup/vbtest/data/silent-corruption.txt           # same size, content differs
touch -r /tmp/vbtest-mtime-ref /backup/vbtest/data/silent-corruption.txt  # restore the mtime the printf just changed
rm /tmp/vbtest-mtime-ref
rm /backup/vbtest/data/missing-from-backup.txt                    # will show as 'missing'
echo "orphan" > /backup/vbtest/data/orphan-on-backup.txt           # will show as 'extra'
```

**Then re-snapshot — this step is not optional, and skipping it is why Part 5.1 can appear to show no findings at all even though the live files are exactly as intended.** `validateBackup` never reads the live filesystem; it only ever reads inside `.zfs/snapshot/<name>/`. The `daily-test1` snapshot received above was already taken *before* the edits just made, so it is frozen with the original, fully-matching content — none of those live-filesystem edits are visible in it, or to anything that compares against it, until they exist inside a snapshot with the same short name as the active side's:
```
zfs destroy backup/vbtest/data@daily-test1
zfs snapshot backup/vbtest/data@daily-test1
```
Snapshots are read-only, so destroying and recreating is the only way to fold the live edits in under the same name. This does not disturb any file's mtime — recreating a snapshot has no effect on the underlying files, so `same.txt` and the other untouched files still carry the mtime the original `send`/`receive` preserved from the active side, and `silent-corruption.txt` carries the mtime restored above.

### Step 0.3 — Generate and edit the config

```
cd validateBackup
./validateBackup --version        # generates validateBackup.conf.yaml from the datastructure
```
Edit `validateBackup.conf.yaml`: set `activeHost`, `activePrefix: tank`, `backupPrefix: backup`, `datasets: { vbtest/data: {} }`, a `report.email` you control (or a `report.targetDrive.mountPoint` folder for local inspection), and `randFile: 1` (digest everything, since the test tree is tiny).

**Expected result:** the file is created without error and contains all the keys documented in `USAGE.md`.

---

## Part 1 — Basic sanity

| Step | Command | Expected result |
|---|---|---|
| 1.1 | `./validateBackup --help` | Usage text listing every option in `USAGE.md`'s table; exits `0`. |
| 1.2 | `./validateBackup --version` | Prints `validateBackup vX.Y.Z`; exits `0`. |
| 1.3 | `./validateBackup --scope-changed` | Prints an error to stderr; exits `2` immediately, without touching the config or logging anything. |
| 1.4 | Temporarily comment out `activeHost` in the config, run `./validateBackup` | Fails config validation before any real work; exits `2`. Restore `activeHost` afterward. |

---

## Part 2 — Pre-flight checks

| Step | Setup | Expected result |
|---|---|---|
| 2.1 | Set `activeHost` to an unreachable name, run `./validateBackup --dry-run` | Logs "Pre-flight checks failed" naming the unreachable host; exits `2`. Restore `activeHost`. |
| 2.2 | Set `tempDir` to a path you can't write (e.g. `/root/nope` as a non-root user) | Pre-flight reports the directory problem; exits `2`. Restore `tempDir`. |
| 2.3 | Set `digestCommand` to a nonexistent command name | Pre-flight reports the missing command; exits `2`. Restore `digestCommand`. |
| 2.4 | Restore everything, run `./validateBackup --dry-run` | Pre-flight passes silently; the dry-run plan prints. |

---

## Part 3 — BSD command behavior (the critical section)

These check assumptions the design depends on that could not be verified off real FreeBSD. If any of these fail, stop and reconsider the affected code before proceeding — see `Documentation/validateBackup.md`'s "Correctness constraints".

| Step | Command (run directly, not through validateBackup) | Expected result |
|---|---|---|
| 3.1 | `stat -f "%N$(printf '\t')%z$(printf '\t')%m" /tank/vbtest/data/same.txt \| cat -et` | Output shows two literal `^I` (tab) characters and a `$` at the line end, `%m` is a bare integer (epoch seconds, no decimal or suffix). BSD `cat` has no `-A`; `-e` (end-of-line `$`) plus `-t` (tabs as `^I`) together are the BSD equivalent of GNU's `cat -A`, each implying `-v` on its own. **Do not type `\t` directly inside the single-quoted format string** (`stat -f '%N\t%z\t%m'`) — BSD `stat(1)` does not expand a backslash-`t` escape in its format argument, so that produces the two literal characters `\` and `t` in the output, not a tab (confirmed running this for real: output was `...same.txt\t18\t1789254367`, no actual tab). The `$(printf '\t')` command substitution embeds a real tab byte before `stat` ever sees the argument, which is what actually needs checking here: `buildListingCommand` in the script builds its format string the same way — a real tab byte from Perl's own `"\t"`, concatenated into the string and single-quoted by `shellQuote`, never relying on `stat` to expand an escape sequence — so this also confirms that design choice was the right one. |
| 3.2 | `zfs get snapdir tank/vbtest/data` then `ls -d /tank/vbtest/data/.zfs/snapshot/daily-test1` | The directory exists and is listable even if `snapdir` is `hidden` (the default) — confirms `validateBackup` never needs to change this property. |
| 3.3 | `zfs list -H -p -o name,creation -t snapshot tank/vbtest/data` | `creation` is a bare epoch integer. Compare the same command on the backup host for the replicated snapshot — the values should match exactly. |
| 3.4 | `find /some/large/tree -type f > /tmp/in.txt` (any real directory tree of a few hundred MB works — this exercises `sort`, not `validateBackup`'s own listing format), then `env LC_ALL=C TMPDIR=/tmp sort -S 50M -o /tmp/out.txt /tmp/in.txt` | Completes without error, and this time actually spills to `/tmp` — see the note just below the table for why plain `sort` with no `-S` may not spill at all on a well-provisioned test box, and for how to watch `/tmp` filling and draining. |
| 3.5 | `sha256 -r /tank/vbtest/data/same.txt` (and `md5 -r`, if testing that digest command) | Output is `<hex digest><space><path>`, matching the format `readChecksumOutput`'s regex expects (`digestFormatFor` in `Documentation/validateBackup.md`). |
| 3.6 | `getconf ARG_MAX` | Record the value; compare against `checksumArgsPerCall` (default 500) — should be comfortably within range for typical path lengths. |
| 3.7 | As **any non-root account that can actually log in** (root bypasses `chmod` on FreeBSD entirely — see the note below; `nobody` is *not* a safe default here, since on a stock system it typically has no usable shell and `su nobody` fails with "Account not currently available" — use your own login, another existing account, or a throwaway one, e.g. `pw useradd tester -m`): as root, `chmod 000 /tank/vbtest/data/Documentation`; then, as the non-root account, `find -x /tank/vbtest/data -type f -exec stat -f '%N' {} +; echo exit $?` (e.g. `su <nonRootUser> -c "find -x /tank/vbtest/data -type f -exec stat -f '%N' {} +; echo exit \$?"`) | Exits `1`, a "Permission denied" line goes to stderr, and every *other* readable file still appears in stdout. Restore permissions afterward (`chmod 755`). |

**Note on 3.4 — why `-S` is in the command, and why it wasn't at first:** without `-S`, `sort` only spills to disk once it exceeds its own internal memory threshold, which on a well-provisioned box can comfortably hold a "few hundred MB" input entirely in RAM — a first pass at this test used plain `sort` with no `-S`, created no files under `/tmp` at all, and drove RSS up to 1.3GB instead. That isn't a bug, but it also isn't testing anything, since it never exercises the spill path this step exists to check. The fix is `-S 50M`, an explicit cap well below the input size, which forces a spill regardless of how much RAM the box has — this is exactly what `sortListing` does in the real script: it passes `-S` from the `sortMemoryLimit` config key (`env LC_ALL=C TMPDIR=<tempDir> sort -S <sortMemoryLimit> ...`, defaulting to `500M`), for precisely this reason — a validation run's memory use on the backup host should be an operator-chosen bound, not "however much RAM happens to be free that day." `TMPDIR` itself is honored by both GNU and BSD `sort` (confirmed in `sort(1)`'s own `-T`/`--temporary-directory` description: "The default path is the value of the environment variable TMPDIR..."), so that half of the assumption was never in question — only the "will it actually spill" half needed `-S` to observe. **Watching the scratch space:** `sort` removes its own temp files on any normal or caught-signal exit (including Ctrl-C) — nothing to clean up by hand afterward. To see the space in use you have to watch *during* the run: background the sort and watch `df` from a second window — `sort ... &` then, separately, `cmdwatch -n 5 df -h /tmp` (a ports package; if it isn't installed, `while true; do df -h /tmp; sleep 1; done`, `^C` to stop, is the portable fallback). Only an uncatchable `kill -9` of `sort` would leave stray `/tmp/sort.*` files behind — remove those by hand if that happens.

**Note on 3.7 — why `chmod 000` alone doesn't work here:** root bypasses discretionary file permissions entirely on FreeBSD (`PRIV_VFS_READ`/`PRIV_VFS_LOOKUP`), so `chmod 000` followed by `find` run as root will read the directory anyway and exit `0` — this is expected, not a bug, and is exactly what was observed testing this. This also means the scenario 3.7 checks (`find` hitting a real permission denial) **cannot occur in production if `validateBackup` and its ssh session both run as root on both hosts**, which is this project's default topology. Test it as a non-root user anyway: it validates `find`'s mechanical behavior on a denial (exit code, stderr line, continuation past the failure) — the same code path is exercised if a future or alternate deployment runs the active-side ssh as a non-root, read-restricted account, or if `find` ever hits a different per-file error (e.g. a stale NFS handle) that produces the same "one file fails, the rest still list" shape.

---

## Part 4 — Dataset and snapshot resolution

Everything here is answered by resolution alone (Phase 1) — `--dry-run` (add `--debug 2` for the raw structure) covers the whole part; nothing lists, digests, or scrubs yet.

### 4.1 — Setup

Everything below lives under a throwaway `step4` container on both sides, so it can be destroyed as a unit afterward without touching the Part 0 tree the later parts still need. `tank/...` = active host, `backup/...` = backup host, per Part 0's convention.

Active host:
```
zfs create tank/vbtest/step4
zfs create tank/vbtest/step4/data
zfs snapshot tank/vbtest/step4/data@daily-test1
zfs snapshot tank/vbtest/step4/data@hourly-test2         # newer - for the excludeSnap check
zfs create tank/vbtest/step4/data/child
zfs snapshot tank/vbtest/step4/data/child@daily-test1
zfs create -V 10M tank/vbtest/step4/data/vol1            # zvol
zfs create -o mountpoint=none tank/vbtest/step4/data/container
zfs create -o mountpoint=legacy tank/vbtest/step4/data/legacyds
zfs snapshot tank/vbtest/step4/data/legacyds@daily-test1
mkdir -p /mnt/legacytest
mount -t zfs tank/vbtest/step4/data/legacyds /mnt/legacytest
zfs create tank/vbtest/step4/data/nosync                 # deliberately not replicated below
zfs snapshot tank/vbtest/step4/data/nosync@onlyOnActive
```

Backup host — replicate everything except `nosync`, plus one orphan with no active counterpart:
```
zfs create backup/vbtest/step4
ssh <activeHost> "zfs send -R tank/vbtest/step4/data@daily-test1" | zfs receive backup/vbtest/step4/data
zfs snapshot backup/vbtest/step4/data@hourly-test2
zfs snapshot backup/vbtest/step4/data/child@daily-test1
zfs create -V 10M backup/vbtest/step4/data/vol1
zfs create -o mountpoint=none backup/vbtest/step4/data/container
zfs create -o mountpoint=legacy backup/vbtest/step4/data/legacyds
zfs snapshot backup/vbtest/step4/data/legacyds@daily-test1
mkdir -p /mnt/legacytest-backup
mount -t zfs backup/vbtest/step4/data/legacyds /mnt/legacytest-backup
zfs create backup/vbtest/step4/data/orphands
```

Add to the config (leave the Part 0 `vbtest/data` entry in place): `datasets: { vbtest/step4/data: { recursive: 1 } }`, `excludeSnap: '^hourly'`.

### 4.2 — One run, everything to check

`./validateBackup --dry-run` (or `--debug 2`). From that single run, confirm:

- `vbtest/data` (Part 0) and `vbtest/step4/data`/`child` all resolve to `daily-test1` — `hourly-test2` excluded even though newer.
- `vol1` — skipped, "is a zvol", `expected`.
- `container` — skipped, "mountpoint=none", `expected`.
- `legacyds` — resolves normally via the mount table (both sides mounted).
- `nosync` — skipped, "no snapshot name exists on both sides", **not** `expected` — this is what drives exit `3` in Part 8.
- `orphands` — reported under "Backup datasets with no active counterpart".

### 4.3 — Two before/after toggles

Not observable from a single state; each needs a second run:

- **excludeSnap:** temporarily remove it and re-run — `hourly-test2` should now be chosen instead of `daily-test1` for `vbtest/step4/data`/`child`. Restore it afterward. (Proves exclusion actually happened, rather than `hourly-test2` simply never having reached the backup side.)
- **legacy mount:** `umount /mnt/legacytest` on the active side and re-run — `legacyds` should now be skipped as "not currently mounted", not `expected`.

### Cleanup

```
zfs destroy -r tank/vbtest/step4
zfs destroy -r backup/vbtest/step4
```

---

## Part 5 — Listing and compare

| Step | Setup | Expected result |
|---|---|---|
| 5.1 | The Part 0 tree, `randFile: 0` (no digesting yet) | `missing: 1 extra: 1 sizeDiff: 0 mtimeDiff: 0` (from `missing-from-backup.txt`/`orphan-on-backup.txt`); `silent-corruption.txt` counts as **matched** at this point (same size/mtime) — this is expected and is exactly what Part 6 exists to catch. |
| 5.2 | Inspect the report | `./a file.txt` (with the space) and the `Documentation.md`/`Documentation/index.html` pair both appear correctly with no phantom findings — this is the ordering trap from `Documentation/validateBackup.md`, now checked against real `find`+`sort`, not the dev-box shim. |
| 5.3 | Same root-bypasses-`chmod` problem as Part 3.7 applies here — `validateBackup` itself normally runs as root, so a `chmod 000` is invisible to it too. To actually reproduce a denial: on the **backup** side (avoids needing a second ssh identity to the active host), as root, `chmod 000` a subdirectory under `/backup/vbtest/data`; then run the **entire** `./validateBackup` invocation as the non-root account from Part 3.7, with `tempDir`/`findingsDir`/`lockFile` pointed somewhere that account can write (e.g. `/tmp`) and the config file readable to it. | Dataset status is `partial`, not `ok`; a `CAVEAT:` line names the incomplete listing. Restore permissions (and any config overrides) afterward. |
| 5.4 | `--dry-run` | Prints the exact `find`/`stat`/`sort` commands; confirm no file's mtime changed afterward (`ls -la`). |
| 5.5 | Set `newer: 1` (only files from the last day) | Report shows the age-filter `CAVEAT:` line noting the `extra` count is not a complete reconciliation. |

---

## Part 6 — Digest sampling

| Step | Setup | Expected result |
|---|---|---|
| 6.1 | `randFile: 1` (digest everything), re-run | `silent-corruption.txt` — identical size and mtime on both sides — is now reported as `checksumDiff`. **This is the headline result the whole digest phase exists to produce.** |
| 6.2 | `randFile: 0` | The `sampled:`/`validated:`/`checksumDiff:`/`checksumUnavailable:` line does **not** appear in the report at all (it's gated by `if ($config->{randFile})`, not printed as zeroes) — confirm via `--verbosity 5` that no digest command is ever invoked on either host either. |
| 6.3 | Same root-bypasses-`chmod` problem as Parts 3.7/5.3 — as root, `chmod 000` a sampled file on the backup side won't be denied to `validateBackup` itself. Reproduce it the same way as 5.3: `chmod 000` the file on the backup side, then run the entire `./validateBackup` invocation as the Part 3.7 non-root account (`tempDir`/`findingsDir`/`lockFile` pointed somewhere it can write), with `randFile: 1`. | That path is `checksumUnavailable`, not a false `checksumDiff`. Restore permissions afterward. |
| 6.4 | Set `maxSampleFileSize` below `same.txt`'s size | `same.txt` is excluded from sampling; the report's `(excluded N by maxSampleFileSize)` note appears. |
| 6.5 (optional) | `digestCommand: md5`, then `digestCommand: cksum` | Both produce the same correct `checksumDiff` result for `silent-corruption.txt` on real FreeBSD — confirms `digestFormatFor`'s parsing for each. |

---

## Part 7 — Scrub and autoscrub

**Caution:** a real scrub can run for hours on a large pool. Use small test pools for this part.

| Step | Setup | Expected result |
|---|---|---|
| 7.1 | Default (`autoscrub: 0`), a pool not scrubbed in over `scrubMaxAge` days | Report shows `stale`; `$errors` notes autoscrub is disabled; run still exits based on other findings (stale alone with autoscrub off contributes to exit `1`, not `3`). |
| 7.2 | `autoscrub: 1`, `--dry-run` | Logs "would start a scrub ... but --dry-run given"; confirm with `zpool status` that no scrub actually started. |
| 7.3 | `autoscrub: 1`, `--no-scrub` | Same as 7.2, different reason logged. |
| 7.4 | `autoscrub: 1`, live, small `scrubWaitMax`/`scrubPollInterval` for a fast test pool | Scrub starts (`zpool status` shows "scrub in progress"), polling logs appear, and once it completes the report shows `[scrub completed this run]`. |
| 7.5 (optional) | Force `scrubWaitMax` shorter than the scrub actually takes | Run logs a timeout, skips validation for that run, exits `3`. |

---

## Part 8 — Reporting and exit codes

Confirm each exit code end to end (combine with setups from earlier parts as needed):

| Exit code | How to trigger | Confirm |
|---|---|---|
| `0` | A dataset with no divergence at all | `Result: CLEAN`; no findings file is created. |
| `1` | Any of Parts 5/6's findings | `Result: FINDINGS PRESENT`; findings file created and (if email configured) attached to the report. |
| `2` | Part 1.4 or Part 2 | Exits before any dataset is processed. |
| `3` | Part 4.2's `nosync` case (no shared snapshot) or Part 7.5 (scrub timeout) | `Result: INCOMPLETE ...`. |

Also confirm: a dataset with 3+ findings under one directory shows the "top directories by finding count" line correctly attributing them.

---

## Part 9 — Lock file

| Step | Command | Expected result |
|---|---|---|
| 9.1 | Start a slow run in the background (`./validateBackup &`), then immediately run `./validateBackup` again in the foreground | The second invocation logs "another validateBackup run appears to be active" and exits `0` with **no** report sent. |
| 9.2 | Kill the backgrounded run's process, then edit the `.lock` file to contain a PID that is not running (e.g. `999999`), run again | Logs "stale lock file ... reclaiming" and proceeds normally. |

---

## Part 10 (optional, best-effort) — rsync cross-check

Only if `rsync` 3.2+ is installed on both hosts. Set `useRsync: 1` (or pass `--use-rsync`) against the Part 0 tree and confirm it runs without error. This is a secondary cross-check, not the primary validation path — a failure here does not indicate a `validateBackup` defect unless the primary (Parts 5–6) result disagrees with it.

---

## Part 11 — Session types

`--dry-run`'s command preview can't distinguish between different *nonzero* `randFile` values — the checksum command's text is identical whether `randFile` is `1` or `1000` (the sample rate only affects which paths get selected at actual runtime, never shown under `--dry-run`). Use `--debug 9` instead, which dumps the fully resolved config after CLI/`sessionType`/file/default merging, to see which value actually took effect.

| Step | Command | Expected result |
|---|---|---|
| 11.1 | `./validateBackup --sessionType monthly --debug 9` | Dumped config shows `randFile => 1000`. |
| 11.2 | `./validateBackup --sessionType quarterly --debug 9` | Dumped config shows `randFile => 1`. |
| 11.3 | `./validateBackup --sessionType monthly --full --debug 9` | Dumped config shows `randFile => 1`, not `1000` — the CLI's `--full` (sugar for `randFile => 1`) overrides the `monthly` profile's value. |

Separately, `--dry-run` (no `--debug`) is still worth a look for both session types: `--sessionType quarterly --dry-run` and `--sessionType monthly --dry-run` should each show the checksum command in the preview, since both set a nonzero `randFile` — this only confirms "digesting happens at all," which is why `--debug 9` above is needed to actually confirm the sample rate.

---

## Part 12 — Cleanup

```
zfs destroy -r tank/vbtest
zfs destroy -r backup/vbtest
rm -f validateBackup.conf.yaml validateBackup.log validateBackup.lock
rm -rf findings /var/tmp/validateBackup
```

---

## Pass/Fail summary sheet

| Part | Pass | Fail | Notes |
|---|---|---|---|
| 0 — Setup | | | |
| 1 — Basic sanity | | | |
| 2 — Pre-flight | | | |
| 3 — BSD command behavior | | | |
| 4 — Dataset/snapshot resolution | | | |
| 5 — Listing/compare | | | |
| 6 — Digest sampling | | | |
| 7 — Scrub/autoscrub | | | |
| 8 — Reporting/exit codes | | | |
| 9 — Lock file | | | |
| 10 — rsync (optional) | | | |
| 11 — Session types | | | |

## Explicitly not tested here

- `--scope-changed` beyond confirming it rejects cleanly — the feature itself is not implemented.
- `parallelListings` — not implemented; the config key is a no-op.
- Filenames containing a tab or newline — the line-based listing format cannot represent them; only malformed-record *detection* is in scope (not covered above; add a case if this matters for your data).
- Full-scale performance (multi-million-file datasets, actual wall-clock timing) — this plan uses small test trees to check correctness, not throughput.

## If something fails

Capture the log (`validateBackup.log`, or wherever `logFile` points), the exact config used, and the output of the specific command from Part 3 if the failure looks BSD-behavior-related. Note the SVN revision or working-copy state being tested (`svn status` / `svnversion`, if available) alongside the failure.
