390 lines
20 KiB
Markdown
390 lines
20 KiB
Markdown
# 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).
|
|
|
|
**Outcome recorded (implementation): B — upstream, and this ruling's own
|
|
diagnosis was wrong.** The label is not an abort and has nothing to do with
|
|
mbedTLS. In Zig 0.16.0, `std/Build/Step/Run.zig:1540` sets
|
|
`result_failed_command` unconditionally on every spawn and nothing clears it
|
|
on success; `compiler/build_runner.zig:1381` prints a step's diagnostics
|
|
whenever the child wrote to stderr, "no matter the result", and `:1515` then
|
|
emits the `failed command:` label because the field is non-null. Any Run
|
|
step that succeeds while writing one byte to stderr gets the label; our
|
|
warning-path tests log through the real sink. Minimal reproducer: one
|
|
passing test containing a `std.debug.print` shows the label; the same test
|
|
without the print shows nothing. The old "exits 134 under `--listen=-`"
|
|
observation was a manual-invocation artifact: the IPC runner panics with
|
|
`EndOfStream` (test_runner.zig:88) reading a closed stdin when no build
|
|
runner sits on the other end. Documented in the `b.addTest` comment in
|
|
build.zig and in AGENTS.md ("Reading `zig build test` output"). No matching
|
|
upstream issue found; the 0.16.0 source lines are the reference.
|
|
|
|
### 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:
|
|
split-before-decode and decode-only-shrinks (result length ≤ input length;
|
|
result is a prefix-aliased slice of the buffer). **Corrected during
|
|
implementation:** the original phrasing "a decoded segment never gains a `/`"
|
|
is false — `%2F` legitimately decodes to a literal `/` inside its segment
|
|
(http_util.zig's own tests assert it), and the fuzz target caught that on its
|
|
first run. The property that holds is a count: segmentation is decided by the
|
|
raw bytes, so each decoded segment pairs with its raw `/`-delimited chunk in
|
|
order, the segment counts match, and each segment is no longer than its
|
|
chunk. A decode-then-split implementation still fails this.
|
|
`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. **Proof recorded
|
|
(implementation):** with the aliasing reintroduced, the new test failed
|
|
(`expected 1500, found 1238`) while all 1378 other tests stayed green. On
|
|
this body the bug silently dropped 262 domains rather than crashing — the
|
|
count assertion does the real work, not the crash.
|
|
|
|
### 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.
|