tech debt audit and cleanup specs for milestones 15-19

This commit is contained in:
2026-08-07 00:57:59 +02:00
parent 8c3328562e
commit f47967ec0d
6 changed files with 2343 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
# Milestone 15: make a green run mean a real pass
Goal: repair the verification pipeline — CI that watches the branch we push to,
a test run whose output contains no false failure text, guards that make the
test list and the embedded frontend complete by construction, and coverage for
the seams the audit found unguarded. This is phase 1 of `TECH_DEBT.md`; nothing
in phases 2-5 can be validated until this lands.
Origin: `TECH_DEBT.md` Theme 1 (lines 17-52), audited at `1ff727f`, every
finding adversarially verified.
## Sequencing (binding)
This milestone lands **before** milestone 14 is implemented. Ruling 1 pulls the
`ci.yml` branch switch forward from milestone-14 §7; when milestone 14's S4
later restructures the workflows into `gates.yml`/`release.yml`, it rebases on
what this milestone leaves behind. One more overlap is binding: milestone 14's
S1 rewrites `build.zig` (deletes `cross`, adds `dist`/`verify-dist`) — it must
rebase on and **preserve** the configure-time machinery this milestone adds
there (ruling 4's import guard, ruling 5's stamp check, ruling 6b's staged
`core` module). Milestone 14's spec stands as written otherwise.
## Rulings (binding)
### 1. CI watches `master`
`.gitea/workflows/ci.yml:1-11` triggers on `push`/`pull_request` for `main`.
Day-to-day pushes go to `origin/master` (`branch.master.merge` confirms), so
milestone 13+ never ran through any gate. Change both `branches:` lists to
`[master]`. Nothing else in the file changes — the `gates.yml` restructure is
milestone-14 work.
Orchestrator, after merge: delete `origin/main`, set the Gitea default branch
to `master`, push, and confirm a run starts. (Milestone-14 §7 already rules
`master` canonical; this executes the branch part now.)
### 2. The live-network suite runs on a schedule
`.gitea/workflows/live-tls.yml` runs on `workflow_dispatch` only. Both failure
modes it exists to catch have each happened once (35f2324; the milestone-4 cert
drift). Add:
```yaml
on:
workflow_dispatch:
schedule:
- cron: "0 5 * * 1"
```
Weekly, Monday 05:00 UTC. It stays non-blocking — this does not conflict with
the milestone-1 ruling that live tests never gate a push. Everything else in
the file is untouched.
### 3. The teardown abort is root-caused, or documented where it lives
Reproduction, verified at HEAD: `zig build test` exits 0 but prints as its
last line `failed command: .../test ... --listen=-`. The same binary exits 0
in stdio mode (`1242 passed; 115 skipped; 0 failed`) and exits 134 (SIGABRT)
under `--listen=-`. The abort happens after all tests pass, in teardown, only
under zig's IPC runner. No comment anywhere in the repository records this;
the knowledge lives in one contributor's memory and `TECH_DEBT.md:24-25`.
The cost is not the abort — it is that the one string that should mean failure
prints on every success, and everyone learns to ignore failure text.
The session must attempt a root cause, in this order:
1. Reproduce: run the cached test binary directly with and without
`--listen=-`. Capture the abort backtrace (`ulimit -c` / gdb / the zig
`--debug-...` runner flags — whatever this host offers).
2. Bisect the mbedTLS linkage: `addMbedtlsThreadingMacros` (build.zig), the
shim (`src/platform/mbedtls_shim.c`), and the two `extern fn` link checks in
`src/tests.zig:121-123` are the suspects. A minimal reproducer (empty test
file + mbedTLS link + `--listen`) decides whether the abort is ours or an
upstream zig 0.16 runner defect.
3. Outcome A — the cause is ours: fix it. Acceptance is a `zig build test` run
whose output contains no `failed command` line.
4. Outcome B — the cause is upstream: do not patch around it. Document it in
two places: a comment on the test wiring in `build.zig` (at the
`b.addTest` block, lines 48-69) stating the exact mechanism and the upstream
reference, and a paragraph in `AGENTS.md` stating that `failed command` +
exit 0 after a full pass is this known artifact and that any *other*
failure text is real.
Either way: no check is loosened, no output is filtered, and the spec is
updated afterward to record which outcome held (per the spec-update rule).
### 4. The test import list gets a completeness guard
`src/tests.zig:3-119` is a hand-maintained `comptime` block of 115
`_ = @import("...");` lines. Zig collects tests only from the root module; a
forgotten import silently drops a file's tests (verified empirically). The
list stays hand-written — generation via a staged copy of `src/` would point
diagnostics at cache paths — but it becomes complete by construction:
In `build.zig`, at configure time, walk `src/` recursively. For every `*.zig`
file except `src/tests.zig` itself, require that `src/tests.zig` contains a
**line** which, after whitespace trim, is exactly `_ = @import("<path>");`
where `<path>` is the file's path relative to `src/`. A line match, not a
substring search: a commented-out import (`// _ = @import(...)`) trims to a
line that starts with `//` and does not match, and a path that is a prefix of
another (`db.zig` vs `db2.zig`) cannot false-match because the full line is
compared. Duplicate matching lines are an error too (they hide a botched
merge). On the first missing file, fail the configure with an error that
names it:
```
src/tests.zig is missing `_ = @import("filter/new_module.zig");`
```
No allowlist. A file with no tests still gets imported — the import is free,
and the rule stays exceptionless. The walk runs on the host in every
invocation, including the aarch64 configure.
### 5. `web/dist` gets a freshness stamp
A stale `web/dist` has already shipped a crashing settings page once
(`docs/explanation/performance-and-testing.md:154-163` admits the hole).
Mechanism — one implementation, in JavaScript, because the frontend CI job has
Node and no Zig:
- New file `web/scripts/stamp-dist.mjs`. Two modes:
- default (write): hash the input set, write the hex digest to
`web/dist/.src-hash`.
- `--check`: recompute, compare with `web/dist/.src-hash`, exit 1 with
`web/dist is stale: rebuild the frontend (npm run build)` on mismatch or
missing stamp.
- The script resolves every path (input files and `web/dist/.src-hash`) from
its own location via `import.meta.url`, never from `process.cwd()`: write
mode runs from `web/` (npm script) and check mode runs from the repo root
(build.zig system command), and both must hash the same set. The
acceptance run exercises both working directories.
- Input set, sorted by path, SHA-256 over `path ++ "\x00" ++ contents ++
"\x00"` per file: every file under `web/src/` and `web/public/`, plus
`web/index.html`, `web/package.json`, `web/package-lock.json`,
`web/vite.config.ts`, `web/tsconfig.json`, `web/tsconfig.app.json`,
`web/tsconfig.node.json`. Node's `crypto` module; no new dependency.
- `web/package.json`: `"build"` becomes `"vite build && node
scripts/stamp-dist.mjs"`.
- `build.zig`: when the resolved `-Dweb-dist` value is exactly `web/dist`, the
asset pipeline (`webAssetsIndex`) gains a dependency on
`b.addSystemCommand(&.{ "node", "web/scripts/stamp-dist.mjs", "--check" })`.
The default `web/dist-placeholder` path and any other explicit path skip the
check; the `web-dist` option description says so.
### 6. Three new fuzz surfaces, corpus replay only
All targets follow the existing registration shape (one `test "fuzz ..."` +
one `fn target(ctx, smith: *Smith)`), attach to `test_step`, and run as corpus
replay under plain `zig build test` — once per corpus entry plus once on empty
input. CI never passes `--fuzz` (blocked by upstream zig 0.16 defects; the
audit confirmed corpus replay is the sanctioned CI smoke).
**6a. `stripEcs`** — the only attacker-facing packet-rewriting entry point,
absent from the fuzz targets. Add a target to `tests/fuzz/dns_fuzz.zig`.
Derive input exactly as `parseTarget` (lines 57-89) already does: it ends with
a validated `Packet` and its `OptRecord`, which are `stripEcs`'s second and
third parameters. Respect the three entry assertions
(`src/dns/edns.zig:196-204`): pass the same `bytes` the packet was parsed
from, and a separate stack `out` buffer — an assertion trip from a violated
precondition is not a finding. Assert on `.rewritten` output: it re-parses,
`findOptRecord` + `parseOpt` succeed, no ECS option (code 8) remains, and
header counts survive. No build.zig change — the module exists.
**6b. `compiler.compile`** — the streaming
`takeDelimiter`/`StreamTooLong`/discard loop (`src/filter/compiler.zig:64-78`)
is never fuzzed, and its `error.EndOfStream`-during-discard arm (line 73) has
no test at all. `compiler.zig` imports `../dns/`, so a module rooted under
`src/filter/` fails with `ImportOutsideModulePath`. Use the staged-copy
aggregator pattern from `bench_core` (`build.zig:112-135`) — `bench_core`
already exposes `compiler` at line 124; reusing the same staged tree is
allowed. New file `tests/fuzz/compiler_fuzz.zig`, module import name `core`.
The target feeds `compile()` via `std.Io.Reader.fixed` over smith bytes with a
deliberately small reader buffer, into discarding writers, and asserts it
returns without panic and that `counts` are internally consistent. Corpus
(inline, `sliceInput` idiom): a line longer than `max_line_len` (4096) with
and without a trailing `\n`, so both the discard path and the
EndOfStream-during-discard arm replay. Add a plain unit test for the line-73
arm alongside the existing compiler tests.
**6c. `http_util`** — the third untrusted-byte family, unfuzzed.
`src/web/http_util.zig` imports only `std`, so the fuzz module roots at a new
file `tests/fuzz/http_util_fuzz.zig` with `addImport("http_util", <module
rooted at src/web/http_util.zig>)` — no aggregator. Targets: `parsePath`,
`decodeInPlace` (both `PlusRule` values), `queryValue`. Invariants to assert,
both already stated in the file: split-before-decode (a decoded segment never
gains a `/`), and decode-only-shrinks (result length ≤ input length; result is
a prefix-aliased slice of the buffer). `dnsParam`/`decodeDnsValue` stay
private in `doh_server.zig` and stay unfuzzed — recorded, out of scope.
### 7. The multi-read fetch path gets a real fixture route
The exact seam of 35k-killer 35f2324 is still never driven end to end: every
fixture reply is one `request.respond` of a ~130-byte body
(`src/filter/filter_integration_test.zig:414`), and the post-fix unit tests
pre-buffer readers. Two thresholds matter and they are different: a body over
16 KiB (`fetcher.min_transfer_buf`, fetcher.zig:24) forces the fetcher's
`pumpBody` loop to iterate; a body over the fixture's 8192-byte write buffer
forces the fixture to flush in parts.
- Add a route `chunked` to the `Route` enum (line 354). Its `respond` arm uses
`respondStreaming`, writes a well-formed blocklist body of at least 24 KiB
in at least three writes with an explicit `flush()` between each, then ends
the stream. `keep_alive = false`, like every other arm (the comment at lines
409-411 is load-bearing).
- Add one `-Dintegration` test that drives the full `refreshOnce` path through
this route and asserts the parsed domain counts.
- Prove it can fail: during development, reintroduce the
`readSliceShort(self.transfer_buf)` aliasing pattern locally and confirm the
new test dies where the old suite stayed green. Record the proof in the
session notes; do not commit the revert.
### 8. The rotation failure paths get tests
`src/platform/logging.zig` codifies an exactly-one-sink-error contract across
four functions (stated at lines 455-460) and its own test section admits the
rotation failure paths are uncovered (lines 604-612). Injection seam:
```zig
var rotate_fault: enum { none, fail_delete, fail_rename } = .none;
```
file-private, read by `deleteLocked` (line 590) and `renameLocked` (line 597)
as their first statement (`if (rotate_fault == .fail_delete) return
error.RotateFailed;`), compiled only under `@import("builtin").is_test`. Two
new tests, each following the established shape of the failed-open test at
lines 883-909 (save and restore `state.*`, hold `std.debug.lockStderr`):
- `fail_delete`: `prepareFileLocked` returns false, `sink_errors` rose by
exactly one, `rotate_pending` stays set, the file stays closed.
- `fail_rename`: same assertions through the rename step.
### 9. The CLI drift guard derives its needles
`src/docs_drift_test.zig:51` hardcodes the subcommand list; its two sibling
guards derive theirs (`routes.table`, `model.toSettings`). The source of truth
today is three parallel copies: the `if (eql(...))` parse chain
(`cli.zig:91-102`), the `main.zig:69-78` dispatch switch, and `usage_text`
(cli.zig:334). Note the spelling trap: tags are `export_`/`import_`, argv
strings are `export`/`import`.
- Add to `cli.zig`:
```zig
pub const CommandName = struct { name: []const u8, tag: std.meta.Tag(Command) };
pub const command_names = [_]CommandName{
.{ .name = "run", .tag = .run },
.{ .name = "check", .tag = .check },
.{ .name = "export", .tag = .export_ },
.{ .name = "import", .tag = .import_ },
.{ .name = "version", .tag = .version },
.{ .name = "help", .tag = .help },
};
```
plus a comptime assert that `command_names.len ==
@typeInfo(Command).@"union".fields.len`, so a new tag without a table entry
fails the compile.
- `parseArgs` matches the command word by iterating `command_names` (the
per-command argument parsing that follows stays as-is).
- `docs_drift_test.zig` iterates `cli.command_names` for its `## `{s}``
needles; the hardcoded list is deleted.
- New test in `cli.zig`: every `command_names[i].name` appears in
`usage_text`.
- `main.zig`'s dispatch switch stays — it is exhaustive over the union and the
compiler already guards it.
## Sessions
S1-S4 run in parallel; no two sessions write the same file. The S1/S3
interface is fixed here so neither blocks: S3 writes the fuzz source files
named in ruling 6; S1 wires them in `build.zig` with the module shapes named
there (6b gets `core` via the staged-copy pattern; 6c gets `http_util` rooted
at `src/web/http_util.zig`); both artifacts attach to `test_step` exactly like
`fuzz_tests` at build.zig:92.
### Session S1: build system and stamp
Owns `build.zig`, `AGENTS.md`, `web/scripts/stamp-dist.mjs`,
`web/package.json`. Rulings 3, 4, 5, and the wiring half of 6.
### Session S2: workflows
Owns `.gitea/workflows/ci.yml`, `.gitea/workflows/live-tls.yml`. Rulings 1
(file edit only), 2.
### Session S3: fuzz targets and fixture
Owns `tests/fuzz/dns_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig` (new),
`tests/fuzz/http_util_fuzz.zig` (new),
`src/filter/filter_integration_test.zig`, and the compiler unit-test addition
in `src/filter/compiler.zig`. Rulings 6 (target half), 7.
### Session S4: guards and seams
Owns `src/cli.zig`, `src/docs_drift_test.zig`, `src/platform/logging.zig`.
Rulings 8, 9.
### Orchestrator
Ruling 1's remote operations (delete `origin/main`, flip the Gitea default,
confirm a run starts), the ruling-3 outcome recorded back into this spec, and
striking the closed Theme-1 findings in `TECH_DEBT.md`.
## Module layout
New files:
- `web/scripts/stamp-dist.mjs` — dist freshness stamp, write and `--check`
modes.
- `tests/fuzz/compiler_fuzz.zig` — compiler streaming-loop target.
- `tests/fuzz/http_util_fuzz.zig` — HTTP parser targets.
## Acceptance (milestone complete)
- [ ] `ci.yml` triggers on `master` for push and pull_request; a push to
`origin/master` starts a CI run; `origin/main` is deleted; the Gitea
default branch is `master`.
- [ ] `live-tls.yml` keeps `workflow_dispatch` and gains the weekly cron.
- [ ] Ruling 3 resolved: either `zig build test` prints no `failed command`
line, or the build.zig comment and the AGENTS.md paragraph exist and
name the upstream cause. The outcome is recorded in this spec.
- [ ] The import guard was proven able to fail: a temporary
`src/guard_probe.zig` containing one test, not imported, makes
`zig build test` fail at configure naming the file; after adding the
import it runs; the probe is then deleted.
- [ ] `npm run build` writes `web/dist/.src-hash`. Appending a byte to a
file under `web/src/` (restored after the probe — the digest hashes
paths and contents, so a bare `touch` cannot make it stale) then
running `zig build -Dweb-dist=web/dist` from the repo
root fails with the stale message; rebuilding the frontend clears it.
(The probe is the default install step, not `cross` — milestone 14
deletes `cross`, and this check must stay valid across that rebase.)
Plain `zig build test` (placeholder path) is unaffected.
- [ ] The three fuzz surfaces run under `zig build test` as corpus replay:
the stripEcs target with its re-parse/no-ECS assertions, the compiler
target with the long-line corpus entries, the http_util targets with
both invariants. The line-73 EndOfStream arm has a unit test.
- [ ] The `chunked` fixture route writes ≥ 24 KiB in ≥ 3 flushed parts; the
new integration test passes; the session notes record the
proven-able-to-fail run against the 35f2324 aliasing pattern.
- [ ] The two rotation-failure tests pass, each asserting exactly one
`sink_error` per the lines-455-460 contract.
- [ ] `docs_drift_test.zig` contains no hardcoded subcommand list;
`cli.command_names` exists with the comptime length assert; the
usage-text test passes.
- [ ] Full suite green (`-Dintegration`), test count strictly above 1242, and
the aarch64 suite still builds and runs under qemu.
## Anti-requirements
- No `gates.yml`, no `release.yml`, no `dist`/`verify-dist` steps — that is
milestone 14, which rebases on this milestone.
- No `--fuzz` anywhere in CI.
- No fuzzing or relocation of `dnsParam`/`decodeDnsValue`.
- No generated `tests.zig`; the hand list stays, the guard makes it complete.
- No frontend changes beyond `web/scripts/stamp-dist.mjs` and the one-line
`build` script edit.
- No filtering, wrapping, or suppression of test-runner output to hide the
`failed command` line.
- No new dependencies, Zig or npm.