Files
nxdns/specs/milestone-15.md
T
mokhtar 9f8a5cd753
CI / test (push) Failing after 1m12s
CI / test-aarch64 (push) Failing after 2m27s
CI / frontend (push) Successful in 1m28s
CI / cross (push) Failing after 27s
CI / docker (push) Failing after 24s
milestone 15: make a green run mean a real pass
2026-08-07 01:28:13 +02:00

20 KiB

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:

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:

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:

    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.