tech debt audit and cleanup specs for milestones 15-19
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,448 @@
|
||||
# Milestone 16: contained behavioral fixes
|
||||
|
||||
Goal: fix the verified bugs and silent failures from `TECH_DEBT.md` themes 3,
|
||||
5, 6 and 7 — each one localized, each with a test that fails before and
|
||||
passes after. No refactors: the duplication work is milestone 18, and a
|
||||
refactor that must simultaneously fix behavior is how mirrored copies diverge
|
||||
further.
|
||||
|
||||
**PROVISIONAL.** Written before milestone 15 was built. m15 touches
|
||||
`build.zig`, workflows, fuzz files, `filter_integration_test.zig`, `cli.zig`,
|
||||
`docs_drift_test.zig` and `logging.zig` — re-verify line references in those
|
||||
files before starting.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. `loadSource` propagates cancellation
|
||||
|
||||
`src/filter/manager.zig:541-544` and `:547-550`: two identical catch sites
|
||||
fold `error.Canceled` into `loadFailure(...)`, recording a bogus "Canceled"
|
||||
load-failed status, consuming the one-shot cancellation, and publishing a
|
||||
snapshot with the source excluded. Ten other catch sites in the file
|
||||
propagate it correctly. Add `if (err == error.Canceled) return
|
||||
error.Canceled;` beside the existing OutOfMemory arm at both sites —
|
||||
`Manager.Error` already has the member. Unit test: a reader that fails with
|
||||
Canceled during `loadSource` makes the whole call return Canceled and writes
|
||||
no status.
|
||||
|
||||
### 2. `commitStatus` never drops an outcome silently
|
||||
|
||||
`src/filter/manager.zig:1237-1250`: the id-match loop falls through with no
|
||||
log when a source has no status entry (reachable via a startupPass /
|
||||
web-insert race; milestone-5 policy says every non-ok state is recorded and
|
||||
surfaced). Add a `log.warn` after the loop naming the source id. Unit test:
|
||||
committing a status for an unknown id emits the warning and does not crash.
|
||||
|
||||
### 3. Downloads and compiles leave the writer lock
|
||||
|
||||
`src/filter/manager.zig:609-624`: `refreshAll` holds `writer_lock` across
|
||||
every download (300 s budget each, `app.zig:85`) and every compile; every
|
||||
web mutation ends in `Manager.reload` on the same lock, so an unrelated rule
|
||||
save parks uncancelably — on a request path with no timeout, occupying one
|
||||
of 64 web slots — until the pass ends. The download-to-tmp flow already
|
||||
exists (`download` writes `<id>.raw.tmp` via `compiledName`, with
|
||||
`deleteQuietly` defers at :646-648); the lock is just taken too early.
|
||||
|
||||
Restructure:
|
||||
|
||||
- New `refresh_lock: std.Io.Mutex` on Manager, serializing refresh passes
|
||||
against each other only.
|
||||
- Split `refreshOne` (:626-729): the fetch/detect/compile stages (:650,
|
||||
:659, :668) run under `refresh_lock` alone; the publish stage (:711), the
|
||||
status commit and the final `reloadLocked` run under `writer_lock`.
|
||||
- `refreshAll` takes `refresh_lock` for the pass, and `writer_lock` only
|
||||
per-source around publish plus once for the final reload. `refreshSource`
|
||||
(:582-586) follows the same split.
|
||||
- The lock-ordering rule, documented on both mutex fields: `refresh_lock` is
|
||||
never acquired while holding `writer_lock`.
|
||||
- `refresh_lock` serializes blocklist-**directory maintenance** too, not
|
||||
just refresh passes: `pruneOrphans` (:1040, invariant comment at
|
||||
:1130-1136) today assumes every temp-file writer holds `writer_lock`;
|
||||
once downloads write `.raw.tmp`/`.list.tmp`/`.wild.tmp` under
|
||||
`refresh_lock` alone, a concurrent source deletion (blocklists.zig
|
||||
delete → reload → pruneFiles) could sweep an in-flight refresh's temp
|
||||
files. `pruneOrphans` therefore takes `refresh_lock` first (then
|
||||
`writer_lock` if it needs it — same order as everywhere), and its
|
||||
invariant comment is rewritten. Regression test: a source deletion
|
||||
during a stalled refresh must not delete the stalled refresh's temp
|
||||
files.
|
||||
|
||||
Integration test (`filter_integration_test.zig`): with a refresh pass parked
|
||||
on a deliberately stalled fixture route, a concurrent rule mutation
|
||||
completes without waiting for the pass. DNS serving was never affected and
|
||||
stays covered by the existing tests.
|
||||
|
||||
### 4. Truncated responses are never cached
|
||||
|
||||
`src/cache/dns_cache.zig:106-131`: `classify` reads `rcode` and `ancount`
|
||||
and never `flags.tc`, so a TC=1 answer from a misbehaving upstream is cached
|
||||
for up to `max_ttl_seconds` (86 400 s) and re-served — TC bit intact, even
|
||||
over TCP, which can loop retrying clients (RFC 2181 §9). Add `if
|
||||
(p.header.flags.tc) return null;` (`flags.tc` is `src/dns/header.zig:20`).
|
||||
|
||||
`transport.validateResponse` is deliberately untouched: rejecting TC there
|
||||
would change failover semantics for every exchange, and the forward client
|
||||
legitimately reads TC for its UDP-to-TCP retry (`forward_client.zig:178`).
|
||||
Record: considered, rejected. Unit test beside the existing classify tests:
|
||||
a TC=1 NOERROR response classifies as null.
|
||||
|
||||
### 5. The format sniffer stops eating hosts files with `##` banners
|
||||
|
||||
`src/filter/parsers.zig:54-73`: `hasAbpMarker` runs before `isComment`, and
|
||||
`isElementHiding` (:86-93) matches `##` (and `#@#`, `#?#`, `#$#`, `#%#`)
|
||||
unanchored with no comment guard — unlike the `$` branch two lines later,
|
||||
which is guarded. A hosts file with a `##` banner parses whole as ABP:
|
||||
`0.0.0.0` enters the domain set and hosts lines with inline URL comments are
|
||||
silently dropped (real blocking loss, reproduced with the URLhaus banner
|
||||
style). The milestone-5 as-built note (specs/milestone-5.md:1677-1678)
|
||||
already *claims* the separators are matched anchored — the code never was.
|
||||
Guarding the branch with `!isComment` is a circular no-op (`isComment`
|
||||
consults `isElementHiding`); do not attempt it.
|
||||
|
||||
Fix — a positional predicate in `isElementHiding`: a separator at offset `p`
|
||||
counts as element hiding only when
|
||||
|
||||
- `p == 0` and the character after the separator is not whitespace, `#`, or
|
||||
end of line (a generic rule `##.ad` counts; a banner `## Title`, `####`,
|
||||
or bare `##` does not), or
|
||||
- `p > 0` and `line[p - 1]` is not whitespace and not `#` (a rule
|
||||
`example.com##.ad` counts; prose `see ## below` does not).
|
||||
|
||||
Required test cases, in `parsers.zig`: the URLhaus banner style sniffs as
|
||||
hosts; `##.ad-banner` sniffs as ABP; `example.com##.ad` sniffs as ABP;
|
||||
`#@#exception` after a domain sniffs as ABP; a `#`-initial line containing
|
||||
`##` sniffs as a comment. The fuzz target (`blocklist_fuzz.zig`) already
|
||||
covers `detectFormat` for crashes.
|
||||
|
||||
### 6. VACUUM respects the disk monitor
|
||||
|
||||
`src/storage/retention.zig`: `runOnce` (:78) does prune → checkpoint →
|
||||
(every 7th pass, :94-95) VACUUM, and takes no monitor — the word does not
|
||||
appear in the file — while its two sibling tasks gate on
|
||||
`disk_monitor.writesAllowed()` (logger.zig:386 as a parameter,
|
||||
manager.zig:1058 as a field). VACUUM is the most expensive write the
|
||||
program makes and fires unconditionally on the exact filesystem the monitor
|
||||
watches, then fails SQLITE_FULL with no retry for seven daily passes.
|
||||
|
||||
`runOnce` and `run` gain `monitor: ?*disk_monitor.Monitor` (the logger's
|
||||
parameter shape). Only the VACUUM step gates: prune and checkpoint keep
|
||||
running — they free space. A skipped vacuum increments a new
|
||||
`Stats.vacuums_gated` and retries on the *next* pass (drop the modulo-only
|
||||
trigger for a `passes_since_vacuum >= vacuum_every_passes` counter that
|
||||
resets on success). `app.zig:561` passes the monitor like :560 does for the
|
||||
logger. Tests: extend the cadence tests (:223) with a gated pass — the
|
||||
vacuum is skipped, counted, and runs on the next allowed pass.
|
||||
|
||||
### 7. An oversized Cookie header degrades loudly, and the session survives
|
||||
|
||||
`src/web/server.zig:612-620` (`copyHeader`): a header value over the buffer
|
||||
(cookie buffer = `http_util.max_cookie_len` = 1024, http_util.zig:35)
|
||||
returns `""` with no log — under the documented reverse-proxy-on-shared-
|
||||
domain deployment (foreign cookies riding along), every request silently
|
||||
401s and the operator sees an unexplained login loop.
|
||||
|
||||
`copyHeader` stays generic. The cookie call site (:510) changes: when the
|
||||
raw header value exceeds the buffer, extract only the session pair by
|
||||
running `http_util.cookieValue` (:200 — it already parses pairs and returns
|
||||
a slice of the original header) with the existing session-cookie name
|
||||
constant against the full value, copy that pair into the buffer as
|
||||
`<name>=<value>`, and emit one `log.debug` with the dropped size (the
|
||||
`.web_server` scope at :55; no secret is logged). If even the pair does not
|
||||
fit, keep the empty result but still log. Integration test
|
||||
(`web_integration_test.zig`): a request with a 2 KiB cookie header whose
|
||||
session pair is valid is authenticated; the same header without the pair
|
||||
401s.
|
||||
|
||||
### 8. Live view detects fatal SSE rejections
|
||||
|
||||
`web/src/features/live/useLiveQueries.ts`: per the WHATWG spec a non-200
|
||||
response fails an EventSource permanently after one error event, so the
|
||||
3-consecutive-errors threshold (:29, :125-133) is unreachable on exactly the
|
||||
429/401 paths it was built for — the UI shows "Reconnecting…" forever and
|
||||
the capped state and session probe are dead. `FakeEventSource` has no
|
||||
readyState, so tests pass — the mocked-network class again.
|
||||
|
||||
- `EventSourceLike` (:9-13) gains `readyState: number`; export `const
|
||||
EVENT_SOURCE_CLOSED = 2`. The browser `EventSource` satisfies it
|
||||
structurally.
|
||||
- In the error handler: `readyState === EVENT_SOURCE_CLOSED` is a permanent
|
||||
failure — close, set `capped`, run the session probe immediately,
|
||||
bypassing the counter. Transient errors (browser auto-retry pending) keep
|
||||
the existing counter path.
|
||||
- `fakeEventSource.ts` gains `readyState` with the real lifecycle
|
||||
(CONNECTING → OPEN on `emit("open")`, CLOSED on `close()` and on
|
||||
`failFatal()`, a new test helper).
|
||||
- New tests: a fatal rejection (single error event, readyState CLOSED)
|
||||
reaches `capped` and calls `probeSession`; a transient error still takes
|
||||
three to trip.
|
||||
|
||||
### 9. A timed-out DoH handshake is a handshake failure
|
||||
|
||||
`src/server/doh_server.zig:311-315` counts a handshake-race timeout as
|
||||
`idle_timeouts`; DoT (:313-317) counts it as `tls_handshake_failures`,
|
||||
which is the recorded spec ruling. Since DoH requests have no other timer,
|
||||
its `idle_timeouts` metric can only ever mean handshake stalls — the same
|
||||
exported name with disjoint semantics per listener. Align DoH's
|
||||
`.timed_out` arm to `tls_handshake_failures`.
|
||||
|
||||
### 10. Idle DoH keep-alive connections are reclaimed
|
||||
|
||||
`src/server/doh_server.zig:70`: `idle_timeout` bounds only the handshake;
|
||||
its own doc comment admits requests have none. DoH stubs hold keep-alives by
|
||||
design, and with no TCP keepalive a vanished peer pins one of 64 slots until
|
||||
restart — while DoT on the same LAN reclaims after 10 s.
|
||||
|
||||
Extend the existing race to `receiveHead` (:333), the wait for the next
|
||||
request on a keep-alive connection, using the DoT out-param precedent
|
||||
(readPrefix's `out_len`): a small wrapper writes the received `Request` (or
|
||||
its error) through a pointer so the raced function stays `anyerror!void`.
|
||||
On `.timed_out`: bump `idle_timeouts` (now truthfully named again after
|
||||
ruling 9) and close the connection. Preserve the existing error triage at
|
||||
:334-346 (`HttpConnectionClosing` and `ReadFailed` return uncounted; the
|
||||
three structural errors count `bad_requests`/`connection_errors` as today).
|
||||
The body read and `handleRequest` stay untimed, like the web listener.
|
||||
|
||||
New integration-gated test mirroring the DoT idle test
|
||||
(dot_server.zig:1098): a client that completes the handshake and one request
|
||||
then goes quiet is closed after a short idle budget; `idle_timeouts == 1`,
|
||||
`tls_handshake_failures == 0`. DoH currently has no idle test at all.
|
||||
|
||||
### 11. SSE shutdown does not wait for a heartbeat
|
||||
|
||||
`src/web/sse.zig`: the Hub has no shutdown signal, so graceful drain parks
|
||||
up to 15 s (`heartbeat_interval`, web/handlers/live.zig:32) per idle
|
||||
subscriber — the web `beginShutdown` (web/server.zig:593-605) shuts down
|
||||
sockets but nothing wakes a task inside `Hub.wait`. Measured: the SSE
|
||||
integration test budgets 40 s for exactly this
|
||||
(web_integration_test.zig:74-75).
|
||||
|
||||
- `Wake` (:33) gains `.closed`. Hub gains `closing: bool` and `pub fn
|
||||
close(self: *Hub, io: std.Io) void`: under the mutex, set the flag and
|
||||
`event.set` every active slot. `wait` returns `.closed` immediately when
|
||||
the flag is set.
|
||||
- `live.zig:114`: `.closed => return`.
|
||||
- `web/server.zig` `deinit` (:349): call the hub's close (via the state's
|
||||
hub pointer) immediately before `beginShutdown` (:360).
|
||||
- Test: a subscriber parked in `wait` returns `.closed` promptly after
|
||||
`close`; the W10 integration teardown no longer stalls (tighten
|
||||
`sse_budget` only if the heartbeat assertion itself allows it).
|
||||
|
||||
### 12. The API limiter's sweep runs
|
||||
|
||||
`src/web/api_limiter.zig:210`: `sweep` exists, preallocates
|
||||
`stale_keys` so it never allocates, is tested — and has no production
|
||||
caller; only the DNS limiter got scheduled. Once 4096 distinct addresses
|
||||
have been seen, the table stays full forever and every unknown-address
|
||||
request pays an O(4096) eviction scan under the limiter mutex.
|
||||
|
||||
`runMaintenance` (app.zig:709) gains an `api_limiter: ?*ApiLimiter`
|
||||
parameter, wired at :565 from the instance built at :380-388. In the loop,
|
||||
beside the two existing tasks: `_ = api.sweep(io, Clock.awake.now(io));` —
|
||||
note it locks itself, unlike the DNS limiter. Test: the maintenance-loop
|
||||
integration coverage asserts a stale bucket disappears.
|
||||
|
||||
### 13. UDP/53 and TCP/53 stats reach /metrics
|
||||
|
||||
`src/server/udp_server.zig:38-49` and `tcp_server.zig:54-60`: both stats
|
||||
structs are written and read by nothing outside their own integration
|
||||
tests — `dropped_no_slot`/`dropped_oversize` have no metric, no API field,
|
||||
and errors log below the default level, while the DoT/DoH siblings export
|
||||
equivalent counters. The module doc sells "dropped and counted"; the count
|
||||
is unobservable.
|
||||
|
||||
- Both gain `pub const Snapshot` + `pub fn snapshotStats` in the exact DoT
|
||||
shape (dot_server.zig:61, :232). Field names stay as-is (the
|
||||
`accepted`/`connections` unification is milestone-18 work).
|
||||
- `WebState` (src/web/server.zig) gains `udp_listeners: []const
|
||||
*udp_server.UdpServer = &.{}` and `tcp_listeners: []const
|
||||
*tcp_server.TcpServer = &.{}` — S4 adds the fields with exactly these
|
||||
names and types; S2 consumes them (the app builds four listeners:
|
||||
udp6/udp4/tcp6/tcp4, app.zig:506-528).
|
||||
- `metrics.zig`: sum each family across its listeners into one
|
||||
`nxdns_udp_server_*_total` / `nxdns_tcp_server_*_total` group via the
|
||||
existing `counterGroup` derivation; extend the name-assertion test
|
||||
(:722-743).
|
||||
- `app.zig` wires the slices.
|
||||
|
||||
### 14. Forward-client counters survive the query
|
||||
|
||||
`src/local/forward_client.zig:44-57`: `Stats` (queries, udp_truncated,
|
||||
foreign_datagrams, failures) is instrumented, documented as preventing "an
|
||||
unrecorded failure mode" — and the only production caller builds a
|
||||
stack-local client per query (handler.zig:394) and drops it, so the spoofing
|
||||
signal does not exist.
|
||||
|
||||
`Handler.Stats` (handler.zig:130-154) gains `forward_udp_truncated`,
|
||||
`forward_foreign_datagrams`, `forward_failures` (atomic, like the other 17;
|
||||
queries are already counted by `forward_zone_answers`). After the exchange
|
||||
in `viaForwardZone`, fetchAdd the client's counters into them. The metrics
|
||||
reflection (`dns_stat_fields`, metrics.zig:48) picks the new fields up
|
||||
without an edit — assert the three new `nxdns_dns_*_total` names in the
|
||||
metrics test. `ForwardClient.Stats` itself stays plain and per-instance.
|
||||
|
||||
### 15. The TLS server keeps the concrete transport cause
|
||||
|
||||
`src/platform/tls_server.zig:418-438`: both BIO callbacks discard the
|
||||
stashed cause — `net_reader.err` / `net_writer.err` (which hold
|
||||
Reset/Timeout/**Canceled**, std Io/net.zig:1260/:1324) are never read
|
||||
anywhere in the file — so a routine idle-budget cancel surfaces as a warn
|
||||
"mbedtls_ssl_read failed" and peer resets are indistinguishable from
|
||||
timeouts. The client side solved exactly this (dot_client.zig
|
||||
`concreteRead`/`concreteWrite`, :303-318).
|
||||
|
||||
- `ServerStream` gains `recv_cause: ?anyerror = null`, `send_cause:
|
||||
?anyerror = null`; `bioRecv`/`bioSend` stash
|
||||
`self.net_reader.err`/`self.net_writer.err` on failure before returning
|
||||
the mbedtls code.
|
||||
- `ReadError` (:216-223) widens with `Canceled`; the read path
|
||||
(`readIntoBuffer`, :334) maps a stashed `error.Canceled` to it instead of
|
||||
`TlsFailed`. Callers' race harnesses already treat cancellation
|
||||
separately.
|
||||
- Unit tests in the file's existing stub style: a reader whose `err` is
|
||||
Canceled yields `error.Canceled`; a Reset yields `TlsFailed` with the
|
||||
cause stashed for logging (ruling 16).
|
||||
|
||||
### 16. Peer misbehavior logs at debug, like the read path already rules
|
||||
|
||||
`src/platform/tls_server.zig`: the read path deliberately logs a peer TCP
|
||||
drop at debug (:362-364, "a louder level would be a log-spam vector"), then
|
||||
`close` warns on close_notify against the same dead socket (:311) and
|
||||
`handshake` warns per probe (:287). `.tls_server` is not in the dedup scope
|
||||
set (logging.zig:103-108), so peer-driven warns can evict genuine warnings
|
||||
from the rotating log.
|
||||
|
||||
- `report` (:474-479) gains a level parameter. The close_notify site passes
|
||||
debug when `peer_closed` is set or the stashed cause (ruling 15) is a
|
||||
peer-class error; the handshake site likewise. Local/config failures keep
|
||||
warn.
|
||||
- Add `.tls_server` to `isDedupScope` (logging.zig:103-108) and to its
|
||||
membership test (:637-644).
|
||||
|
||||
### 17. The query log heals its own gaps
|
||||
|
||||
`web/src/features/queries/QueryLogPage.tsx`: the hand-rolled accumulation
|
||||
(`extra`, `cursorOverride`, `generation`, :79-98) develops a silent
|
||||
mid-table row gap when the base page refetches after 30 s staleness — the
|
||||
newest-100 boundary moves up while `extra` starts below the old cursor, and
|
||||
the stale override means load-more never heals it. On a live DNS server the
|
||||
trigger is routine.
|
||||
|
||||
Migrate to `useInfiniteQuery`: the endpoint already maps onto it
|
||||
(`getNextPageParam: (last) => last.next_before ?? undefined`; keyset
|
||||
pagination confirmed server-side, handlers/queries.zig:103-114). A
|
||||
background refetch then refetches all pages in order — consistent, no gap.
|
||||
The three behaviors the old code carried by hand: staleness discard is
|
||||
handled by the query itself; keep the `isPlaceholderData` disable on the
|
||||
button; the 401 branch is already covered by the global cache-level
|
||||
`handleUnauthorized` (queryClient.ts:29-30). Delete `extra`,
|
||||
`cursorOverride`, `generation`, `loadingMore`, `moreError`. Rewrite the five
|
||||
load-more tests; add one for the gap scenario: base refetch with new rows
|
||||
between page renders leaves no discontinuity.
|
||||
|
||||
### 18. Password hashing leaves the config lock
|
||||
|
||||
`src/web/handlers/settings.zig`: `applyPut` holds `config_lock` from :286
|
||||
to :341, and the argon2id call (t=2, m=19 MiB, :373-391) sits inside it at
|
||||
:312 — every settings GET (:407-409 takes the same lock) and every mutation
|
||||
stalls for the hash duration on the Pi 5. The login path already does it
|
||||
right: copy under lock, hash unlocked, re-check generation under lock
|
||||
(auth.zig:47-71), and the LiveHash generation check (auth.zig:73-79)
|
||||
already closes the concurrent-install race.
|
||||
|
||||
Move the password-length check and the `hashPassword` call before the lock
|
||||
acquisition; everything from `loadConfig` on stays inside. The hash input
|
||||
is the parsed patch only, so nothing under the lock is needed. Tests: the
|
||||
existing applyPut suite passes unchanged; add one that actually
|
||||
distinguishes the new behavior — "both complete" already held before the
|
||||
fix, the GET merely waited out the hash. A stall seam under
|
||||
`builtin.is_test` (the milestone-15 rotation-seam shape) parks
|
||||
`hashPassword` on an event; the test starts a password PUT, waits until
|
||||
the hash is parked, completes a settings GET **while the hash is still
|
||||
parked**, then releases the seam and joins the PUT.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1-S5 run in parallel. One cross-session interface, fixed here: S4 adds the
|
||||
`WebState.udp_listeners`/`tcp_listeners` fields exactly as ruling 13 names
|
||||
them; S2 wires and consumes them without touching `web/server.zig`.
|
||||
|
||||
### Session S1: filter
|
||||
|
||||
Owns `src/filter/manager.zig`, `src/filter/parsers.zig`,
|
||||
`src/filter/filter_integration_test.zig`. Rulings 1, 2, 3, 5.
|
||||
|
||||
### Session S2: cache, retention, counters, wiring
|
||||
|
||||
Owns `src/cache/dns_cache.zig`, `src/storage/retention.zig`, `src/app.zig`,
|
||||
`src/server/udp_server.zig`, `src/server/tcp_server.zig`,
|
||||
`src/local/forward_client.zig`, `src/server/handler.zig`,
|
||||
`src/web/metrics.zig`, and the storage/server integration tests those
|
||||
touch. Rulings 4, 6, 12, 13 (except the WebState fields), 14.
|
||||
|
||||
### Session S3: TLS listeners
|
||||
|
||||
Owns `src/platform/tls_server.zig`, `src/platform/logging.zig`,
|
||||
`src/server/doh_server.zig`. Rulings 9, 10, 15, 16.
|
||||
|
||||
### Session S4: web server
|
||||
|
||||
Owns `src/web/server.zig`, `src/web/sse.zig`, `src/web/handlers/live.zig`,
|
||||
`src/web/handlers/settings.zig`, `src/web/web_integration_test.zig`, plus
|
||||
the ruling-13 field additions. Rulings 7, 11, 18.
|
||||
|
||||
### Session S5: frontend
|
||||
|
||||
Owns `web/src/features/live/*`, `web/src/features/queries/*`. Rulings 8,
|
||||
17.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Strikes the closed findings in `TECH_DEBT.md`; updates the stale
|
||||
milestone-5 as-built line if ruling 5 changed its truth value.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] Both `loadSource` catch sites propagate Canceled; the new unit test
|
||||
passes; no status row ever reads "Canceled".
|
||||
- [ ] `commitStatus` warns on an unknown id (test).
|
||||
- [ ] With a stalled download in flight, a concurrent rule mutation
|
||||
completes (integration test); the lock-ordering comment exists on
|
||||
both mutexes; a source deletion during a stalled refresh leaves the
|
||||
refresh's temp files alone (regression test).
|
||||
- [ ] A TC=1 response classifies as null (test); `validateResponse` is
|
||||
untouched.
|
||||
- [ ] The five sniffer cases pass; the URLhaus banner file parses as hosts.
|
||||
- [ ] A gated pass skips only the vacuum, counts `vacuums_gated`, and
|
||||
vacuums on the next allowed pass (test).
|
||||
- [ ] The 2 KiB-cookie tests pass: session extracted, non-session 401s,
|
||||
debug line emitted.
|
||||
- [ ] A fatal SSE rejection reaches `capped` and probes the session
|
||||
(frontend test); a transient error still takes three.
|
||||
- [ ] DoH `.timed_out` handshake counts `tls_handshake_failures`; the
|
||||
metrics name test still passes.
|
||||
- [ ] The new DoH idle test passes: quiet keep-alive closed,
|
||||
`idle_timeouts == 1`.
|
||||
- [ ] `Hub.close` wakes a parked subscriber promptly (test); web `deinit`
|
||||
calls it before `beginShutdown`.
|
||||
- [ ] The API limiter sweep runs in maintenance (test).
|
||||
- [ ] `/metrics` carries `nxdns_udp_server_*` and `nxdns_tcp_server_*`
|
||||
families summed over four listeners (name test extended).
|
||||
- [ ] `/metrics` carries the three new `nxdns_dns_forward_*` counters.
|
||||
- [ ] The TLS-server cause tests pass: Canceled surfaces as Canceled, never
|
||||
as a warn line; close_notify after a peer drop logs debug;
|
||||
`.tls_server` is in the dedup set.
|
||||
- [ ] The query log uses `useInfiniteQuery`; the gap-scenario test passes;
|
||||
the accumulation state is gone.
|
||||
- [ ] Hashing runs before `config_lock`; the settings suite passes.
|
||||
- [ ] Full suite green: `zig build test -Dintegration`, `npm run test`,
|
||||
`npm run typecheck`.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No listener-core extraction, no shared repo helpers, no shared transport
|
||||
helpers — milestone 18.
|
||||
- No stats field renames (`accepted` vs `connections`) — milestone 18.
|
||||
- No config key renames and no trusted-proxy setting — milestone 17.
|
||||
- No `useInfiniteQuery` migration anywhere but the query log.
|
||||
- No new metrics beyond the families this spec names.
|
||||
- No timeout on DoH request bodies or handlers — only `receiveHead` races.
|
||||
@@ -0,0 +1,427 @@
|
||||
# Milestone 17: contract repairs
|
||||
|
||||
Goal: make the operator-facing contract true. Four decided items — a real
|
||||
per-query deadline, the upstream editor milestone 8 promised, an opt-in
|
||||
trusted-proxy setting, and a field-level contract guard between the server
|
||||
and the frontend — plus BADVERS, the validator holes, and the doc claims
|
||||
the code contradicts. `TECH_DEBT.md` Theme 4. All four decisions are made;
|
||||
do not re-litigate them.
|
||||
|
||||
**PROVISIONAL.** Written before milestones 15-16 were built. m15 touches
|
||||
`docs_drift_test.zig` and `cli.zig`; m16 touches `pool.zig`'s neighbors,
|
||||
the listeners and the settings handler. Re-verify line references before
|
||||
each session starts.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. `total_timeout_ms` becomes a real per-query deadline
|
||||
|
||||
Today the key feeds `Pool.attempt_timeout` (app.zig:283-288 →
|
||||
pool.zig:110) and bounds one attempt; with N upstreams down a query takes
|
||||
N × 5000 ms. The reference docs contradict each other (configuration.md
|
||||
says "per-query budget", cli.md says "per-attempt") and PLAN promises a
|
||||
total budget. Decision: the total budget becomes real.
|
||||
|
||||
- `model.Upstream` gains `attempt_timeout_ms: u32 = 2500`;
|
||||
`total_timeout_ms` keeps its name and default (5000) and becomes the
|
||||
whole-exchange deadline. `read_timeout_ms` is untouched (it feeds the
|
||||
forward-zone client only, app.zig:412).
|
||||
- `pool.zig`: `attempt` keeps its race, now budgeted by the new key. The
|
||||
two-pass failover loop is extracted into a private
|
||||
`exchangeLoopLen(self, io, query, response_buf) transport.ExchangeError!usize`
|
||||
(the `exchangeLen` precedent, pool.zig:690-693: `Io.concurrent` stores
|
||||
the future's return value, so the raced loop returns a length and
|
||||
`exchange` rebuilds the slice). A new outer race wraps that helper with
|
||||
the total budget, using the select idiom already in the file. The outer
|
||||
cancel unwinds an in-flight attempt — the `entry.busy` lock is taken
|
||||
cancelably on purpose (:169-174) and released by defer. Outer expiry
|
||||
returns `error.Timeout`, **unconditionally**: the fast-failure paths
|
||||
(all upstreams disabled → `ConnectFailed`) finish long before the
|
||||
budget, so no attempted-marker is needed, and a test pins that an
|
||||
all-disabled pool still fails immediately rather than waiting out the
|
||||
deadline.
|
||||
- `validate.zig`: `checkTimeout` both keys; the cross-check becomes
|
||||
`attempt_timeout_ms <= total_timeout_ms` (the old `total >= read` check,
|
||||
which related knobs of different subsystems, is deleted).
|
||||
- Settings plumbing for the new key (the reflective walk carries it; the
|
||||
hand-written mirrors do not): `expected_keys` (model.zig:449, sorted),
|
||||
the round-trip test with a non-default value, the `SettingsView`
|
||||
upstream struct in web_integration_test.zig:528, `types.ts`
|
||||
`Settings.upstream`, `openapi.yaml` Settings + SettingsPatch,
|
||||
SettingsPage `SECTIONS[0]`, the configuration.md row (drift-guarded),
|
||||
and the cli.md/configuration.md prose — both now say: total is the
|
||||
per-query budget, attempt bounds one upstream try.
|
||||
- Tests: a pool test with two stalling upstreams proves the exchange
|
||||
returns within the total budget, not 2 × attempt; the existing
|
||||
per-attempt tests keep passing against the new key.
|
||||
|
||||
### 2. The validator closes its two verified holes
|
||||
|
||||
- **Hostname `tls://` upstreams.** `Endpoint.parse` accepts any host
|
||||
text; the DoT client then refuses non-literals on every dial
|
||||
(`resolveAddress`, dot_client.zig:74-80), so a hostname `tls://` config
|
||||
validates clean and fails at query time as a peer fault. Milestone 3
|
||||
explicitly ordered this check carried into the validator and it was
|
||||
dropped. Correction to the audit: the function to mirror is **not**
|
||||
`parseResolver` itself (validate.zig:257 — that is the forward-zone
|
||||
resolver check); the upstream block in `checkCollections`
|
||||
(validate.zig:603-639) gains, for `.dot` endpoints,
|
||||
`net.IpAddress.parse(endpoint.host, endpoint.port) catch` → a new
|
||||
`error.UpstreamHostNotIpLiteral` diagnostic ("tls:// upstreams take an
|
||||
IP literal host"). `faults.zig` picks the variant up automatically.
|
||||
Fix the broken example at configuration.md:348 — the hostname NextDNS
|
||||
`tls://` config can never complete an exchange; show an IP-literal
|
||||
`tls://` example and move the hostname form to `https://`.
|
||||
- **Boot-allocation bounds.** `query_log_buffer_max` is floor-checked
|
||||
only (an Entry is ~420 bytes; `maxInt(u32)` asks for ~1.8 TB at boot),
|
||||
and `cache.size` is not validated at all (grep confirms). Both get the
|
||||
file's ceiling-only idiom: `1..1_000_000` with the "must be at most"
|
||||
message shape. No full memory-fit guarantee — out of reach, not needed.
|
||||
|
||||
### 3. The upstream editor is built
|
||||
|
||||
Milestone 8 ruling 9 requires upstream CRUD in the UI ("the Settings page
|
||||
must edit them"); milestone 9 dropped the obligation when the SPA was
|
||||
built. The entire data layer exists with zero importers
|
||||
(`upstreamsQuery`, three mutation factories at queries.ts:251-264, four
|
||||
api.ts wrappers). Decision: build it. Placement deviates from m8's
|
||||
literal text: a dedicated **Upstreams page** (nav entry), matching the
|
||||
house resource-page pattern — record the deviation beside m8 ruling 9.
|
||||
|
||||
- New `web/src/features/upstreams/UpstreamsPage.tsx` +
|
||||
`UpstreamForm.tsx`, modeled on the blocklists pair (the closest
|
||||
analogue; copy its named idioms: editing-state-as-row, two mutation
|
||||
instances of one factory so row-toggle state never bleeds into the
|
||||
form, `mutateAsync` submit, full-row resend on toggle because **PUT is
|
||||
a replace**, `key={editing?.id ?? "add"}` remount, `window.confirm`
|
||||
delete, split error routing).
|
||||
- Fields: url, priority (number), enabled (toggle), tls_name. 400s carry
|
||||
the real validator's text (the handler validates via
|
||||
`mutations.checkUpstream`); surface the three 409 conflicts verbatim
|
||||
("an upstream with that url already exists", "the last enabled
|
||||
upstream cannot be disabled/removed").
|
||||
- Every mutation response carries `restart_required: true` and today
|
||||
nothing raises the banner outside SettingsPage. Generalize the restart
|
||||
banner copy to "Changes saved. Restart nxdns to apply." and raise it on
|
||||
upstream create/update/delete success.
|
||||
- Wiring is three edits: `createRoute` in routes.tsx, the `routeTree`
|
||||
entry, the AppShell nav item. The health table stays on the Dashboard
|
||||
(health rows have no `id` — url is the only join key — and reflect the
|
||||
running pool, so a new upstream appears there only after restart; the
|
||||
page states this beside the restart banner).
|
||||
- Dead-layer cleanup in the same stroke (the same finding): delete the
|
||||
seven unused single-row `get*` wrappers (correction: seven, not five —
|
||||
`getGroup` and `getUpstream` are shadowed by longer names in grep),
|
||||
plus `getMetrics`, `getOpenapiYaml`, and `ruleUpdateMutation`. The
|
||||
editor reads rows from the list query per house idiom, so none gains a
|
||||
caller. Git history keeps them.
|
||||
- Page tests in the stubbed-fetch house style: list render, add, toggle
|
||||
resends the full row, delete confirms, a 409 renders inline, the
|
||||
banner raises.
|
||||
|
||||
### 4. Trusted proxies restore real client identity
|
||||
|
||||
Behind the documented same-box reverse proxy every request is loopback:
|
||||
`localhost_exempt = true` (default) then disables the API limiter — the
|
||||
only brake on argon2 brute force — and all remote users share one
|
||||
address's 3-stream SSE cap. No X-Forwarded-For handling exists anywhere
|
||||
(verified repo-wide). Decision: opt-in trusted-proxy setting.
|
||||
|
||||
- New setting `web.trusted_proxies: []const u8 = ""` — a comma-separated
|
||||
list of IP literals in one string (the settings codec supports scalars
|
||||
only; a list type is a compile error at model.zig:358). Empty means
|
||||
off: behavior today.
|
||||
- Validation: each comma-separated element must parse as an IP literal →
|
||||
new `error.BadTrustedProxy` diagnostic in the web block of
|
||||
`checkScalars`.
|
||||
- Mechanics: when the socket peer is in the trusted set, the effective
|
||||
client address is the **last** valid IP literal in the request's
|
||||
`X-Forwarded-For` (the entry our proxy appended); a **missing** header
|
||||
falls back to the socket peer. Implementation follows the
|
||||
copy-before-dispatch constraint (http_util.zig:10-14): a new
|
||||
`max_xff_len = 256` budget, a new Conn buffer, and a new `Request`
|
||||
field `client_addr: address.NetAddress` computed once in
|
||||
`handleRequest`. The copy keeps the **tail**, not the head: a new
|
||||
bounded suffix-copy helper beside `copyHeader`, because `copyHeader`
|
||||
returns empty on overflow (server.zig:608) and an empty result would
|
||||
fall back to the socket peer — loopback behind the intended same-box
|
||||
proxy, which `api_localhost_exempt = true` (model.zig:89, the default)
|
||||
then exempts. That is the exact bypass this ruling closes: a client
|
||||
ships an oversized XFF through the proxy and regains the exemption.
|
||||
The proxy appends `, <client-ip>` last, so the real entry always fits
|
||||
in the 256-byte tail. Fail closed on the residue: header present but
|
||||
no valid last IP literal in the tail means the trusted proxy violated
|
||||
its contract — respond 400, never fall back to the exemptible peer.
|
||||
Only a misconfigured proxy can trigger that arm; a spoofed prefix
|
||||
cannot, because the proxy's appended entry always terminates the
|
||||
chain. `bucketLimit` (server.zig:201-207) and the
|
||||
SSE acquire/release in `live.zig:82-87` both read `client_addr` — the
|
||||
SSE pair must use the same key or releases leak. `api_limiter.Config`
|
||||
is untouched: `WebState.web` already carries every web field
|
||||
(app.zig:472), so the trust check reads `state.web`.
|
||||
- With trust configured, a proxied remote address is not loopback, so
|
||||
the exemption no longer swallows it — the limiter and the per-address
|
||||
SSE cap bind per real client. Requests arriving at the socket from
|
||||
non-trusted, non-loopback peers are unaffected.
|
||||
- Settings plumbing checklist (same list as ruling 1, plus the
|
||||
hand-written web mirrors the fact-finding flagged): `expected_keys`,
|
||||
round-trip test, `WebView` + `view()` in handlers/settings.zig:207-248,
|
||||
`restart_required_keys` balance test, `SettingsView` web struct in the
|
||||
integration test, `types.ts` `Settings.web`, openapi web schemas
|
||||
(including the `required` array), SettingsPage web section, the
|
||||
configuration.md row, the api.md rate-limiting section, and a warning
|
||||
in the proxy/authentication how-to: when proxying without
|
||||
`trusted_proxies`, set `api_localhost_exempt = false`.
|
||||
- Tests: unit tests on the effective-address derivation (trusted peer +
|
||||
XFF chain, untrusted peer + spoofed XFF ignored, trusted peer + no
|
||||
XFF, trusted peer + an XFF over 256 bytes whose valid last entry is
|
||||
still honored from the tail, trusted peer + a present-but-invalid
|
||||
chain → 400); an integration test proving a proxied remote address is
|
||||
rate-limited while the proxy itself stays exempt.
|
||||
|
||||
### 5. A field-level contract guard between server and frontend
|
||||
|
||||
The REST contract lives in three hand-synced copies. The Zig side is
|
||||
genuinely well guarded (56-entry contract table parsing live responses
|
||||
with `.ignore_unknown_fields = false`; route-level openapi guards — which
|
||||
live in `web_integration_test.zig:1494-1590` and `openapi.zig`, not in
|
||||
`docs_drift_test.zig` as the audit said). **TypeScript is guarded by
|
||||
nothing**: every frontend test stubs fetch, and types.ts is strictly
|
||||
narrower than the wire in places (literal unions like
|
||||
`Health.status: "ok" | "degraded"`), so re-parsing into Zig structs can
|
||||
never catch an out-of-union string. Decision: one integration layer
|
||||
asserting the frontend's consumed shapes against real responses.
|
||||
|
||||
Mechanism — captured samples validated by `tsc`, no new dependencies:
|
||||
|
||||
- A new `-Dintegration` test beside the contract walk drives the real
|
||||
`Env` server through every route whose contract-table kind is
|
||||
`.json` **and** which the frontend consumes through an `api.ts` wrapper
|
||||
— the GETs **and** the JSON-returning POST/PUT wrappers (login, the
|
||||
group/client/rule/upstream/blocklist writes, refresh, pause, the
|
||||
settings PUT), driven with deterministic seed payloads so the mutating
|
||||
responses are stable too (plus one sample per shared error response
|
||||
class). GET-only would leave every write-path response contract
|
||||
unguarded. Explicitly excluded:
|
||||
the `.raw` routes (`/metrics`, `/api/openapi.yaml` — not JSON, and
|
||||
ruling 3 deletes their unused wrappers), the `.sse` route, and the
|
||||
`.none` deletes. The endpoint-to-type mapping is derived from `api.ts`,
|
||||
not invented. Captured bodies are **canonicalized**: keys sorted, every
|
||||
number replaced with 0 (type-preserving; strings and booleans kept —
|
||||
they come from the deterministic seed). Canonicalization is what makes
|
||||
the output byte-stable across runs.
|
||||
- The canonical samples render into a committed file
|
||||
`web/src/lib/contractSamples.gen.ts`: for each endpoint,
|
||||
`export const sample_<name>: <TsType> = <json>;` with the matching
|
||||
import from `types.ts`. TypeScript object literals get excess-property
|
||||
checking, so a server field missing from types.ts, a types.ts field
|
||||
missing from the wire, and an out-of-union literal all fail
|
||||
`npm run typecheck` — which already runs in CI's frontend job.
|
||||
- Embedding: an anonymous-import root must be Zig, not TypeScript, so a
|
||||
wrapper `web/src/lib/contract_samples.zig` exports
|
||||
`pub const bytes = @embedFile("contractSamples.gen.ts");` and build.zig
|
||||
registers it — the exact `docs/docs.zig` pattern. The Zig test
|
||||
byte-compares the embedded bytes against the freshly rendered content
|
||||
and fails on mismatch.
|
||||
- Regeneration is explicit, not write-beside-the-embed (embedded bytes
|
||||
carry no source-tree path): a build option `-Dcontract-samples-out`
|
||||
(absolute path, wired through `build_options`) makes the test write the
|
||||
fresh content there instead of comparing. The pinned command, stated in
|
||||
the failure message and the docs:
|
||||
`zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"`.
|
||||
Golden-file discipline: the server side keeps the samples current;
|
||||
`tsc` keeps types.ts honest against them.
|
||||
- `types.ts` gains `ErrorEnvelope` matching the shared error responses
|
||||
(verify the exact envelope shape against the handlers before writing
|
||||
it) so the error samples are typed too.
|
||||
- Recorded residual risk: the server↔openapi seam stays route-level
|
||||
only. The yaml is served verbatim and never parsed; field-level yaml
|
||||
drift remains possible and is accepted — revisit only if it bites.
|
||||
|
||||
### 6. BADVERS becomes expressible
|
||||
|
||||
RFC 6891 §6.1.3: a version-1 EDNS query must get RCODE 16 (BADVERS) with
|
||||
version 0 in the reply OPT. Nothing checks `opt.version` anywhere
|
||||
(verified), and the write path cannot express it: `addOptEcho` hardcodes
|
||||
`extended_rcode = 0` (packet.zig:354) and `synthesize` takes a 4-bit
|
||||
`types.Rcode`. Negligible operational impact; the mechanism is the
|
||||
finding.
|
||||
|
||||
- `edns.zig` gains the write-side splitter beside the read-side
|
||||
`extendedRcode` (:272): a 12-bit rcode splits into the header's four
|
||||
bits and the OPT's upper eight. `extendedRcode` stays — it is the
|
||||
read-side pair (milestone 19 assumes it survives).
|
||||
- `packet.zig` gains `addOptWithRcode(request_opt, do_bit,
|
||||
extended_rcode: u8)`; `addOptEcho` becomes a call with 0.
|
||||
- `handler.zig`: the version check sits directly after the successful
|
||||
`parseOpt` (:227-233), before the opcode check — `if (o.version != 0)`
|
||||
→ a BADVERS synthesis (header `.no_error` + OPT `extended_rcode = 1,
|
||||
version = 0`), counted in a new `Handler.Stats.badvers` (the metrics
|
||||
reflection exports it; extend the name test).
|
||||
- Tests: a version-1 query yields composite rcode 16 (assert via
|
||||
`edns.extendedRcode`) and version 0 in the reply OPT; a version-0
|
||||
query is unaffected.
|
||||
|
||||
### 7. The stale phase comments state the as-built contract
|
||||
|
||||
- `rate_limiter.zig:5-6` ("Phase 7 decides the locking"): rewrite to the
|
||||
as-built truth — the handler serializes with `Handler.limiter_mutex`
|
||||
(handler.zig:124), uncancelable on the query path, cancelable in the
|
||||
`runMaintenance` sweep (app.zig:702-731 already states the rationale
|
||||
to adopt).
|
||||
- `shutdown.zig` `trigger` doc ("what a Phase 8 restart endpoint would
|
||||
call"): no restart endpoint exists or is planned — m8 shipped
|
||||
`restart_required` echoes instead. Rewrite to "what a test uses".
|
||||
- `pool.zig:83-84`: tense only — the health endpoint exists; "Feeds
|
||||
`GET /api/upstream/health`."
|
||||
|
||||
### 8. Doc transcripts stop hardcoding the version
|
||||
|
||||
Four sites print `0.1.0-dev` (tutorial/first-run.md:108,
|
||||
install-with-docker.md:126, install-with-systemd.md:206,
|
||||
upgrade.md:142 — three how-to plus one tutorial, two different
|
||||
transcript shapes). Correct today; silently wrong at the first tag.
|
||||
Milestone 14 mandates a placeholder and a guard but names neither; the
|
||||
milestone-13 executed-transcript rule is in tension.
|
||||
|
||||
- Ruling on the tension, explicit: milestone-13 ruling 3 constrains
|
||||
**command lines**; pasted **output lines** may carry placeholders.
|
||||
The orchestrator adds one clarifying sentence to milestone-13.md
|
||||
beside ruling 3.
|
||||
- The four output lines replace the version with `<version>` (e.g.
|
||||
`nxdns <version> serving on ...`).
|
||||
- The guard: `docs/docs.zig` additionally embeds the tutorial and
|
||||
how-to pages; a new test in `docs_drift_test.zig` asserts no embedded
|
||||
doc page contains the string `0.1.0-dev` and that the four transcript
|
||||
pages contain `nxdns <version>`. (Note m15 rewrote this file's CLI
|
||||
test — rebase, don't collide.)
|
||||
|
||||
### 9. The log docs stop claiming append mode
|
||||
|
||||
`files-and-directories.md:143` claims the log file is "opened for
|
||||
append". It is not: `.mode = .write_only` plus `seekTo(state.file_pos)`
|
||||
per line, offset seeded once at open — the source comment at
|
||||
logging.zig:502 says so outright ("0.16.0 has no append mode"). Under
|
||||
external logrotate the divergence is destructive (copytruncate → sparse
|
||||
NUL-prefixed file; rename+create → unbounded writes to the renamed
|
||||
inode, `max_size_mb` unenforceable).
|
||||
|
||||
Docs only: correct the row (positional writes, offset tracked
|
||||
in-process), and add an operator warning — nxdns owns rotation for this
|
||||
file; do not point external logrotate at it. A per-line stat re-check
|
||||
was considered and rejected: a syscall per log line on the hot path to
|
||||
defend against a misconfiguration the docs now forbid. Record that
|
||||
decision here.
|
||||
|
||||
## Sessions
|
||||
|
||||
Dependency graph: S1 → S2 → S5 (they share model.zig, validate.zig,
|
||||
types.ts, openapi.yaml, SettingsPage.tsx, web_integration_test.zig — the
|
||||
later session rebases on the earlier). S3, S4, S6 run in parallel with
|
||||
the chain and each other.
|
||||
|
||||
### Session S1: deadline and validator
|
||||
|
||||
Owns `src/upstream/pool.zig`, `src/config/model.zig`,
|
||||
`src/config/validate.zig`, `src/app.zig`, plus the ruling-1 plumbing
|
||||
trail (types.ts, openapi.yaml, SettingsPage SECTIONS[0], the
|
||||
integration-test SettingsView, configuration.md, cli.md). Rulings 1, 2,
|
||||
and the pool comment from ruling 7.
|
||||
|
||||
### Session S2: trusted proxy (after S1)
|
||||
|
||||
Owns `src/web/server.zig`, `src/web/http_util.zig`,
|
||||
`src/web/handlers/live.zig`, `src/web/handlers/settings.zig`, and its
|
||||
plumbing trail through the same shared files S1 touched. Ruling 4.
|
||||
|
||||
### Session S3: upstream editor
|
||||
|
||||
Owns `web/src/features/upstreams/*` (new), `web/src/routes.tsx`,
|
||||
`web/src/shell/AppShell.tsx`, `web/src/lib/api.ts`,
|
||||
`web/src/lib/queries.ts`, `web/src/features/settings/RestartBanner.tsx`
|
||||
and `restartBanner.ts`, `specs/milestone-8.md` (the deviation note).
|
||||
Ruling 3.
|
||||
|
||||
### Session S4: BADVERS
|
||||
|
||||
Owns `src/dns/packet.zig`, `src/dns/edns.zig`, `src/server/handler.zig`,
|
||||
the metrics name test in `src/web/metrics.zig`. Ruling 6.
|
||||
|
||||
### Session S5: contract samples (after S1 and S2)
|
||||
|
||||
Owns the new generator test in `src/web/web_integration_test.zig`,
|
||||
`build.zig` (the anonymous import), `src/tests.zig` (if a new file needs
|
||||
an entry), `web/src/lib/contractSamples.gen.ts` (committed golden),
|
||||
`web/src/lib/types.ts` (`ErrorEnvelope` only). Ruling 5.
|
||||
|
||||
### Session S6: docs and comments
|
||||
|
||||
Owns `src/server/rate_limiter.zig` and `src/server/shutdown.zig`
|
||||
(headers only), `docs/docs.zig`, `src/docs_drift_test.zig`, the four
|
||||
transcript pages, `docs/reference/files-and-directories.md`,
|
||||
`specs/milestone-13.md` (the clarifying sentence). Rulings 7 (except the
|
||||
pool comment), 8, 9.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Strikes the closed findings in `TECH_DEBT.md`; confirms PLAN's
|
||||
total-budget promise now matches the code; records the m8 placement
|
||||
deviation.
|
||||
|
||||
## Module layout
|
||||
|
||||
New files: `web/src/features/upstreams/UpstreamsPage.tsx`,
|
||||
`web/src/features/upstreams/UpstreamForm.tsx`,
|
||||
`web/src/lib/contractSamples.gen.ts` (generated, committed).
|
||||
|
||||
Deleted surface: the seven unused `get*` wrappers, `getMetrics`,
|
||||
`getOpenapiYaml`, `ruleUpdateMutation`.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] Two stalled upstreams: the exchange returns within
|
||||
`total_timeout_ms` (test). `attempt_timeout_ms` exists end to end:
|
||||
settings key, validator range + cross-check, docs row, SettingsPage
|
||||
field, SettingsView, types.ts, openapi.
|
||||
- [ ] A hostname `tls://` upstream fails `nxdns check` with the new
|
||||
diagnostic; the configuration.md example connects.
|
||||
- [ ] `query_log_buffer_max = 4000000000` and `cache.size = 0` both fail
|
||||
validation with ceiling/floor messages.
|
||||
- [ ] The Upstreams page lists, adds, edits, toggles (full-row resend),
|
||||
deletes; the three 409 texts render; the restart banner raises with
|
||||
the generalized copy; nav and route exist; page tests pass.
|
||||
- [ ] The ten dead wrappers/factories are gone; `npm run typecheck` and
|
||||
`lint` pass.
|
||||
- [ ] With `trusted_proxies` set, a proxied remote address is
|
||||
rate-limited and holds its own SSE budget while the proxy stays
|
||||
exempt; a spoofed XFF from an untrusted peer is ignored; an
|
||||
oversized XFF through the trusted proxy does not regain the
|
||||
loopback exemption (tests). The how-to carries the no-trust
|
||||
warning.
|
||||
- [ ] `contractSamples.gen.ts` is committed and contains samples for the
|
||||
JSON-returning POST/PUT wrappers, not GETs only; the Zig generator
|
||||
test passes against it; deliberately removing a field from a
|
||||
types.ts type fails `npm run typecheck` (proven, then reverted).
|
||||
- [ ] A version-1 EDNS query answers composite RCODE 16 with version 0;
|
||||
`nxdns_dns_badvers_total` appears in /metrics.
|
||||
- [ ] Grep: "Phase 7" and "Phase 8" appear in no source doc comment;
|
||||
the rewritten headers state the mutex and the maintenance task.
|
||||
- [ ] Grep: `0.1.0-dev` appears in no docs/ page; the new drift test
|
||||
passes and was proven able to fail (reinsert the literal, watch it
|
||||
fail, revert).
|
||||
- [ ] files-and-directories.md describes positional writes and warns off
|
||||
external logrotate.
|
||||
- [ ] Full suite green: `zig build test -Dintegration`, `npm run test`,
|
||||
`npm run typecheck`.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No restart endpoint — the `restart_required` echo model stands.
|
||||
- No hostname resolution for `tls://` upstreams — the validator rejects;
|
||||
the client's own check stays as defense in depth.
|
||||
- No openapi codegen and no runtime schema validation library — the
|
||||
contract guard is captured samples + tsc, nothing more.
|
||||
- No proxy protocol (PROXY/haproxy) support; X-Forwarded-For only.
|
||||
- No per-line stat re-check in the logger (ruling 9 records why).
|
||||
- No upstream health join in the editor beyond the stated restart note.
|
||||
- No `Forwarded` (RFC 7239) header parsing — XFF only, recorded.
|
||||
@@ -0,0 +1,420 @@
|
||||
# Milestone 18: collapse the duplicated infrastructure
|
||||
|
||||
Goal: the copy-paste infrastructure from `TECH_DEBT.md` Theme 2 becomes
|
||||
shared code — the listener core first (the audit's only high finding, four
|
||||
hand-synced copies of the most invariant-heavy concurrency code in the
|
||||
repository, drift already live), then the repository memory-safety
|
||||
choreography, the web CRUD shells, the transport scaffolding, the name
|
||||
normalization, the line iterator, and the frontend class constants. After
|
||||
this milestone, a fix in any of these families lands once.
|
||||
|
||||
**PROVISIONAL.** Written before milestones 15-17 were built. m16 touches the
|
||||
listeners (DoH receiveHead race, stats export), the manager (refresh_lock)
|
||||
and the metrics tests; m17 touches pool.zig and the handlers. Re-verify
|
||||
every line reference and re-diff the copies before each session starts.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. One listener core
|
||||
|
||||
`tcp_server.zig`, `dot_server.zig`, `doh_server.zig` and `web/server.zig`
|
||||
each carry private copies of: the `State`/`ConnState`/`Stop`/`Claim` enums,
|
||||
`retry_delay`, `bump`, the slot pool with `claim`/`finish` (the same
|
||||
three-step close dance), `beginShutdown` (identical body, different log
|
||||
text), `serve` with the cancel-protection dance, `acceptLoop` with the same
|
||||
error mapping, `decideClaim` in two shapes plus `firstFree`, and — in the
|
||||
three DNS listeners — the byte-identical `Outcome`/`Result`/`race`/`expire`
|
||||
select harness. `readPrefix`/`readBody`/`writeReply` are byte-identical
|
||||
between tcp and dot. The predicted failure mode already fired once: the
|
||||
milestone-10 review hand-ported the `handshook` TLS-context-leak fix from
|
||||
dot to doh. Six unit tests are duplicated between tcp and dot, three more
|
||||
between doh and web.
|
||||
|
||||
New file `src/server/listener.zig`:
|
||||
|
||||
- The four enums, `retry_delay`, `bump` — plain shared declarations.
|
||||
- The race harness: `Outcome`, `Result`, `race`, `expire` — one copy,
|
||||
generic over the raced function (the existing `comptime f: anytype` shape
|
||||
is already generic; it only needs to move).
|
||||
- The framing helpers `readPrefix`, `readBody`, `writeReply` (tcp/dot
|
||||
consumers; doh speaks HTTP).
|
||||
- `pub fn Core(comptime Cfg: type) type` generating: the conns array with
|
||||
`Cfg.ConnPayload` per slot, mutex, `run_state`, `shutdown_begun`,
|
||||
`claim`/`finish`/`beginShutdown`/`decideClaim`/`firstFree`, the accept
|
||||
loop, and the `serve` skeleton with the cancel-protection dance. `Cfg`
|
||||
supplies exactly four things: the per-slot payload type (`ConnPayload` —
|
||||
the payloads genuinely differ per listener, TLS context and buffers),
|
||||
the per-connection serve function (`serveConn`), the read/write buffer
|
||||
sizes, and the at-capacity behavior (close for the DNS listeners; the
|
||||
web listener's `refuse` 503 for web).
|
||||
- The Core does **not** own the TLS lifecycle. Certificate pin and
|
||||
release, TLS-context ownership, the handshake, close_notify, and
|
||||
cleanup after a late race completion form one ordered sequence
|
||||
(doh_server.zig:294-330, dot_server.zig:288-323) and stay inside each
|
||||
listener's `serveConn`. What is shared is a helper,
|
||||
`listener.handshakeStage`, that owns the `handshook` exactly-once flag
|
||||
and its late-success cleanup contract — the exact defect the
|
||||
milestone-10 review hand-ported — with a doc comment stating the
|
||||
caller's required ordering (pin → handshake via the helper → serve →
|
||||
TLS close → release, cleanup on every early exit). Both TLS listeners
|
||||
adopt it; the leak class closes there, not in the Core.
|
||||
- The shared unit tests move here; the per-file duplicates are deleted.
|
||||
|
||||
Unifications the extraction forces, all sanctioned:
|
||||
|
||||
- Counter name: `connections` everywhere. tcp and web rename `accepted`.
|
||||
This renames the m16-added metric family
|
||||
`nxdns_tcp_server_accepted_total` → `nxdns_tcp_server_connections_total`
|
||||
— greenfield, allowed; update the metrics name test and the docs
|
||||
reference. Core stats are one shared struct; `tls_handshake_failures`,
|
||||
`bad_requests`, `requests` stay listener-specific beside it, and the
|
||||
exported snapshots stay flat so /metrics output is unchanged except the
|
||||
tcp rename.
|
||||
- `deinit` shape: all four store the allocator at `listen` (dot's
|
||||
milestone-10 deviation becomes the rule) and take `(self, io)`.
|
||||
- The dead `pub fn serve` at doh_server.zig:687-707 (documented as
|
||||
intentional in specs/milestone-10.md:286-289) is deleted; note the
|
||||
update beside that spec line.
|
||||
|
||||
The per-listener files keep what genuinely differs: the connection payload
|
||||
(TLS context and buffers), the serve-one-connection logic, DoH's HTTP
|
||||
handling, and the web listener's request router. Every existing listener
|
||||
integration test must pass unchanged apart from renamed counters.
|
||||
|
||||
### 2. The repository list choreography exists once
|
||||
|
||||
The `prepare → ArrayList → errdefer out.deinit → errdefer freeX →
|
||||
columnTextAlloc → append` shape is hand-rolled 19 times across the seven
|
||||
repository files, with the load-bearing errdefer ordering repeated 18 times
|
||||
and 26 hand-written `freeX` loops. The milestone-4 spec's own reference
|
||||
sample (specs/milestone-4.md:1263-1272) declares the two errdefers in the
|
||||
**reverse, use-after-free order** — every implementation silently corrected
|
||||
it; the next repo copied from the spec is a landmine.
|
||||
|
||||
`src/storage/repositories/crud.zig` (today: `execStrict` only) gains:
|
||||
|
||||
```zig
|
||||
pub fn listRows(
|
||||
comptime Row: type,
|
||||
database: *db.Db,
|
||||
gpa: Allocator,
|
||||
comptime sql: []const u8,
|
||||
comptime readRow: fn (*db.Stmt, Allocator) db.Error!Row,
|
||||
) db.Error!std.ArrayList(Row)
|
||||
|
||||
pub fn freeRows(comptime Row: type, gpa: Allocator, items: []const Row) void
|
||||
```
|
||||
|
||||
`listRows` owns the errdefer choreography — written once, with the ordering
|
||||
comment. `freeRows` frees every `[]const u8` **and every `?[]const u8`**
|
||||
field by comptime reflection — `SourceRow.checksum` is an allocated
|
||||
optional slice (sources_repo.zig:91-104) and its current destructor frees
|
||||
the non-null payload explicitly; a shallow slice-only reflection leaks it.
|
||||
Any other owning field shape is a `@compileError`, so a future row cannot
|
||||
silently leak. The allocation-failure tests must keep a case with a
|
||||
non-null checksum.
|
||||
The per-row `readRow` functions stay in the repos and keep their per-column
|
||||
errdefers. All 19 list functions become one-line delegations; the `freeX`
|
||||
wrappers stay as thin `pub` shims where callers use them, or are deleted
|
||||
where `freeRows` is called directly. Every `checkAllAllocationFailures`
|
||||
test stays and must pass — they now exercise the shared helper from 20
|
||||
angles.
|
||||
|
||||
Correct the milestone-4 sample in place and note the correction in that
|
||||
spec.
|
||||
|
||||
### 3. The web CRUD shells are generated; the decisions stay hand-written
|
||||
|
||||
Seven handler files repeat the 4-line `configDb` switch 40 times in three
|
||||
forms, and five of them repeat identical `list`/`get`/`remove` shells. The
|
||||
four reload flavors (plain; local.zig's publish-under-lock; blocklists'
|
||||
pruneFiles; groups' read-back-under-lock) are genuinely different and are
|
||||
**not** unified.
|
||||
|
||||
- `mutations.zig` gains `pub fn requireConfigDb(state: *server.WebState)
|
||||
error{NoConfigDb}!*db.Db`. The 40 switch sites become one-line `catch`
|
||||
arms producing the same three response forms (the Failure payload is the
|
||||
same constant text everywhere).
|
||||
- `mutations.zig` gains a comptime resource descriptor generating only the
|
||||
identical trio:
|
||||
|
||||
```zig
|
||||
// desc is an anonymous struct literal; `anytype` is not legal as a
|
||||
// struct *field* type, so the descriptor arrives as `comptime desc:
|
||||
// anytype` and is validated field-by-field at comptime (@hasField +
|
||||
// @compileError on a missing or mistyped member).
|
||||
pub fn Resource(comptime desc: anytype) type
|
||||
// desc members: Row: type; list / get / remove: the repo fns (get and
|
||||
// remove may be `null` for list-only resources); label: []const u8
|
||||
// ("an upstream", used in respondFailure contexts); envelope:
|
||||
// []const u8 ("upstreams", the JSON list key).
|
||||
```
|
||||
|
||||
yielding `list`/`get`/`remove` handlers in the exact current shape.
|
||||
Adopted by upstreams, groups, blocklists, clients, rules and the two
|
||||
local.zig sets where the shell matches; `settings.zig` (get + applyPut
|
||||
only) does not adopt. All `applyCreate`/`applyUpdate`/`applyDelete`
|
||||
bodies and the per-resource decision functions (countEnabledExcept,
|
||||
updateLocked/deleteLocked, pruneFiles, applyReplacePrefixes, publish,
|
||||
RuleView, applyPut) stay hand-written.
|
||||
|
||||
Acceptance is a grep: the 4-line switch appears zero times outside
|
||||
`mutations.zig`.
|
||||
|
||||
### 4. The transport scaffolding is shared, and DoH gets its missing unwrap
|
||||
|
||||
- The byte-identical `Outcome` + `expire` + select-race body in
|
||||
`pool.zig:253-274/310-317` and `forward_client.zig:195-215/260-267`
|
||||
moves to `transport.zig` as one generic exchange-race helper (the raced
|
||||
function, the budget and one comment word are the only differences
|
||||
today). `manager.zig`'s `fetchWithin` may adopt it if it generalizes
|
||||
without contortion; not required.
|
||||
- `mapPhase` (duplicated byte-for-byte, dot_client.zig:283 /
|
||||
forward_client.zig:293) moves to `transport.zig` as `pub`.
|
||||
- The cancel-protected close helpers (four near-copies:
|
||||
forward_client.zig:281/287, dot_client.zig:271/277) become one
|
||||
`pub fn closeBlocked(io: std.Io, target: anytype) void` in transport.zig
|
||||
(`target.close(io)` under swapped protection). The inline copies in
|
||||
logging.zig and metrics.zig are out of scope — they are not transport
|
||||
code.
|
||||
- `doh_client.zig` gains the stashed-cause unwrap the other two transports
|
||||
carry (`concreteRead`/`concreteWrite` precedent, dot_client.zig:303-318):
|
||||
today its `mapError` never reads the http client's stashed cause behind
|
||||
`ReadFailed`/`WriteFailed`, so rare mid-exchange local errors (e.g.
|
||||
ENOBUFS) are recorded as peer faults against a healthy upstream — a
|
||||
stated spec-invariant violation, fixed once for DoT and left in DoH.
|
||||
Port the unwrap and the corresponding stub-based tests.
|
||||
- `sendFailure`/`receiveFailure` stay per-file: the unwrapping genuinely
|
||||
differs by stream type.
|
||||
|
||||
### 5. `normalizeName` lives beside `fromText`
|
||||
|
||||
The copies in `records.zig:197` and `forward_zones.zig:129` are
|
||||
byte-identical (only doc comments differ). Move the function to
|
||||
`src/dns/name.zig` as `pub fn normalizeText(text: []const u8, buf:
|
||||
*[types.max_name_len]u8) error{BadName}![]const u8`, next to `fromText`
|
||||
(both callers already import the module; it stays pure — no allocation, no
|
||||
Io). Both files delete their copies and their private `NameError`.
|
||||
|
||||
The other three variants are **not** unified — their policies differ on
|
||||
purpose (rules.zig:195 rejects controls and space but skips `fromText`
|
||||
because patterns hold `*`; compiler.zig:155 adds a two-label minimum and
|
||||
counter-based reporting; dns_cache.zig:59 validates nothing because its
|
||||
input is asserted). Each of the three gains a one-line comment pointing at
|
||||
`name.normalizeText` and naming its own policy difference, so the next
|
||||
reader knows the divergence is intentional.
|
||||
|
||||
### 6. One bounded-line iterator
|
||||
|
||||
The subtle `takeDelimiter`/`StreamTooLong`/`discardDelimiterInclusive` loop
|
||||
exists twice (`compiler.zig:64-87`, `manager.zig collectSample:1423-1441`),
|
||||
both correct, both carrying the infinite-loop-hazard comment and a
|
||||
regression test. Their seven behavioral differences (termination, counting,
|
||||
trimming, filtering, exit style, error set, arm order) all live *outside*
|
||||
the hazardous core. New shared iterator in `src/filter/parsers.zig`:
|
||||
|
||||
```zig
|
||||
pub const LineEvent = union(enum) { line: []const u8, long_line };
|
||||
pub fn nextBoundedLine(r: *std.Io.Reader, max_len: usize) error{ReadFailed}!?LineEvent
|
||||
```
|
||||
|
||||
encapsulating the take/discard dance (including the
|
||||
EndOfStream-during-discard arm and the large-reader-buffer length check).
|
||||
The limit is a **parameter** — `parsers.zig` must not import `compiler.zig`
|
||||
(compiler imports parsers, so that is a cycle, and parsers.zig is a
|
||||
standalone std-only fuzz-module root); both callers pass
|
||||
`compiler.max_line_len` from their side. `compile` keeps its `long_lines` counting
|
||||
and `\r` handling on top; `collectSample` keeps its trim/filter/count-limit
|
||||
on top. Both regression tests must still pass; the m15 compiler fuzz target
|
||||
now exercises the shared core.
|
||||
|
||||
### 7. `cli.zig` stops re-spelling app policy
|
||||
|
||||
- The DoH buffer sizes: `app.zig:89-90` names
|
||||
`doh_request_buf_len = 1024` / `doh_transfer_buf_len = 4096` (private);
|
||||
`cli.zig:885-886` re-spells them as bare literals, so changing the
|
||||
constants would leave `nxdns check` probing different buffers than
|
||||
`nxdns run` uses — undermining the probe's stated purpose. The constants
|
||||
move to `doh_client.zig` as `pub const default_request_buf_len` /
|
||||
`default_transfer_buf_len`; both `app.zig` and `cli.zig` use them. No
|
||||
deeper unification of `probeUpstreams` vs `Upstreams.build` — the
|
||||
one-at-a-time probe and the slab build are intentionally different
|
||||
shapes.
|
||||
- The ZON failure channels: `check` prints the multi-line `zon_diag`
|
||||
rendering inline in a single `FAIL` line (cli.zig:721-729), embedding
|
||||
newlines mid-record and contradicting the one-line-per-problem promise
|
||||
(docs/reference/configuration.md:332); `import` routes through
|
||||
`reportParseFailure` (config/import.zig:167 — currently **private**).
|
||||
Make `reportParseFailure` `pub`; `check` builds a local
|
||||
`validate.Diagnostics`, feeds the parse failure through it, and renders
|
||||
each problem as its own `FAIL` line like its other diagnostics.
|
||||
Acceptance: a config with a multi-line ZON error yields one `FAIL` line
|
||||
per parser message from both `check` and `import`.
|
||||
|
||||
### 8. `build.zig` wires a test suite once
|
||||
|
||||
The host (lines 47-69) and aarch64 (150-179) test suites repeat twelve
|
||||
wiring lines; the aarch64 triple is re-spelled at :152 instead of read from
|
||||
`cross_targets` (:10-13). Extract `fn addTestSuite(b, target, optimize,
|
||||
options, web_assets) *Step.Compile` mirroring the existing `addExecutable`
|
||||
helper; the aarch64 call resolves its target from `cross_targets[1]` (or a
|
||||
named constant both use). The two blocks' three real differences
|
||||
(`linkage = .static`, `skip_foreign_checks`) stay at the call sites. The
|
||||
m15 fuzz-suite wiring repeats the same shape three times — fold those calls
|
||||
into the helper too if the anonymous-import needs line up; otherwise leave
|
||||
them and say so.
|
||||
|
||||
### 9. One Smith encoder
|
||||
|
||||
`sliceInput` is byte-identical in `blocklist_fuzz.zig:160` and
|
||||
`corpus.zig:123` (blocklist_fuzz cannot import corpus.zig — corpus imports
|
||||
the `dns` module its build target lacks; that is why it was copied). New
|
||||
dependency-free `tests/fuzz/smith_encode.zig` holding `sliceInput` and its
|
||||
length self-test; `corpus.zig` and `blocklist_fuzz.zig` import it by
|
||||
relative path (each fuzz module compiles its own copy — source-level dedup
|
||||
is the goal). `pairInput` stays in blocklist_fuzz, `sliceIntInput` stays in
|
||||
corpus — both build on the shared one. The m15 fuzz files
|
||||
(`compiler_fuzz.zig`, `http_util_fuzz.zig`) adopt it where they inlined
|
||||
the idiom.
|
||||
|
||||
### 10. Frontend: one class vocabulary, shared form plumbing
|
||||
|
||||
15 of 30 non-test `.tsx` files re-declare Tailwind class constants in two
|
||||
naming conventions; the primary-button literal appears at 7 sites, the
|
||||
input literal at 6, the focus-visible fragment 39 times across 17 files —
|
||||
and the two inputs that *dropped* the focus ring (PrefixesEditor.tsx:13,
|
||||
GroupsPage.tsx:17, the only unfocusable inputs in the app) silently violate
|
||||
the milestone-9 accessibility floor. The project's own precedent
|
||||
(InlineError was hoisted during milestone 9) is house style left
|
||||
unapplied.
|
||||
|
||||
- New `web/src/ui/classes.ts` exporting the named constants (camelCase, one
|
||||
convention): `inputClass`, `buttonClass`, `primaryButtonClass`,
|
||||
`thClass`, `tdClass`, `formCardClass`, `tableWrapClass`,
|
||||
`retryButtonClass` — taken verbatim from the current majority literals
|
||||
(all carrying the focus ring). All 15 declaring files adopt; the two
|
||||
ring-less inputs are fixed by adoption. Acceptance is a grep: the three
|
||||
top literals appear only in `ui/classes.ts`, and every
|
||||
input/button/select in `web/src` carries the focus-visible fragment (the
|
||||
one sanctioned variant is PauseWidget's negative offset).
|
||||
- New `web/src/ui/useCrudForm.ts`: the mutation trio + `FormState` +
|
||||
`openForm`/`onSubmit`/`onDelete` plumbing that RecordsTab and ZonesTab
|
||||
share byte-for-byte, parameterized over the entity and its three
|
||||
mutation factories. Both tabs adopt it; their field JSX and tables stay
|
||||
local. Other pages may adopt where the shape fits; none is forced.
|
||||
- The shadowing `InlineError` in DashboardPage.tsx:36: extend
|
||||
`lib/InlineError.tsx` with an optional `onRetry` prop rendering the
|
||||
retry button; DashboardPage imports it and the local copy is deleted.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1-S7 run in parallel; no two sessions write the same file. Interfaces
|
||||
fixed here: S4 makes the two DoH buffer constants `pub` in
|
||||
`doh_client.zig`; S5 consumes them from `cli.zig`/`app.zig` — wait for S4's
|
||||
commit of that one declaration or agree the exact names above and build
|
||||
against them.
|
||||
|
||||
### Session S1: listener core
|
||||
|
||||
Owns `src/server/listener.zig` (new), `src/server/tcp_server.zig`,
|
||||
`src/server/dot_server.zig`, `src/server/doh_server.zig`,
|
||||
`src/web/server.zig`, `src/web/metrics.zig`, and the listener integration
|
||||
tests (`tcp_server_integration_test.zig`,
|
||||
`udp_server_integration_test.zig`, `src/web/server_integration_test.zig`,
|
||||
`resolver_integration_test.zig`, `phase7_integration_test.zig` as needed).
|
||||
Ruling 1.
|
||||
|
||||
### Session S2: storage
|
||||
|
||||
Owns `src/storage/repositories/*` and the milestone-4 spec correction.
|
||||
Ruling 2.
|
||||
|
||||
### Session S3: web handlers
|
||||
|
||||
Owns `src/web/handlers/*`, `src/web/web_integration_test.zig`. Ruling 3.
|
||||
|
||||
### Session S4: transport
|
||||
|
||||
Owns `src/upstream/transport.zig`, `src/upstream/pool.zig`,
|
||||
`src/upstream/dot_client.zig`, `src/upstream/doh_client.zig`,
|
||||
`src/local/forward_client.zig`. Ruling 4, and the `pub` constants half of
|
||||
ruling 7.
|
||||
|
||||
### Session S5: names, lines, cli
|
||||
|
||||
Owns `src/dns/name.zig`, `src/local/records.zig`,
|
||||
`src/local/forward_zones.zig`, `src/filter/rules.zig`,
|
||||
`src/filter/compiler.zig`, `src/filter/manager.zig`,
|
||||
`src/filter/parsers.zig`, `src/cache/dns_cache.zig` (comment only),
|
||||
`src/cli.zig`, `src/config/import.zig`, `src/app.zig`. Rulings 5, 6, and
|
||||
the consumer half of 7.
|
||||
|
||||
### Session S6: build and fuzz
|
||||
|
||||
Owns `build.zig`, `tests/fuzz/*`. Rulings 8, 9.
|
||||
|
||||
### Session S7: frontend
|
||||
|
||||
Owns `web/src/**`. Ruling 10.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Strikes the closed findings in `TECH_DEBT.md`; records the tcp metric
|
||||
rename in the docs reference; updates the milestone-10 deviation note
|
||||
(ruling 1) and the milestone-4 sample (ruling 2).
|
||||
|
||||
## Module layout
|
||||
|
||||
New files: `src/server/listener.zig`, `tests/fuzz/smith_encode.zig`,
|
||||
`web/src/ui/classes.ts`, `web/src/ui/useCrudForm.ts`.
|
||||
|
||||
Deleted surface: the four per-listener copies of the shared machinery, the
|
||||
dead `doh_server.serve`, the two `normalizeName` copies and their
|
||||
`NameError`s, the duplicated listener unit tests, the DashboardPage
|
||||
`InlineError`.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All four listeners build on `listener.Core`; every listener
|
||||
integration test passes; /metrics output is byte-identical to before
|
||||
except `nxdns_tcp_server_connections_total` (name test updated).
|
||||
- [ ] The `handshook` invariant exists once, in `listener.handshakeStage`;
|
||||
grep finds no per-listener copy; both TLS listeners call it.
|
||||
- [ ] Grep: `errdefer out.deinit` appears only in `crud.zig`; all 20+
|
||||
`checkAllAllocationFailures` tests pass; the milestone-4 sample shows
|
||||
the safe order.
|
||||
- [ ] Grep: the `configDb` switch appears zero times outside
|
||||
`mutations.zig`; all web integration tests pass; the four reload
|
||||
flavors are byte-unchanged.
|
||||
- [ ] `pool.zig` and `forward_client.zig` share the race helper; `fn
|
||||
expire(` production copies are gone (test-local copies may stay).
|
||||
- [ ] DoH's mapError unwraps stashed causes; the ported stub tests pass.
|
||||
- [ ] `records.zig` and `forward_zones.zig` call `name.normalizeText`; the
|
||||
three intentional variants carry their pointer comments.
|
||||
- [ ] Both line-iterator call sites use `nextBoundedLine`; both regression
|
||||
tests pass.
|
||||
- [ ] `nxdns check` renders a multi-line ZON failure as one `FAIL` line per
|
||||
message (test); the buffer constants exist once, `pub`, in
|
||||
`doh_client.zig`.
|
||||
- [ ] `build.zig` has one `addTestSuite`; the aarch64 triple is read from
|
||||
`cross_targets`; the qemu CI job passes.
|
||||
- [ ] `sliceInput` exists once; both fuzz corpora still decode (the length
|
||||
self-test runs from `smith_encode.zig`).
|
||||
- [ ] Grep: the three top class literals live only in `ui/classes.ts`;
|
||||
every input in `web/src` carries the focus-visible fragment;
|
||||
RecordsTab and ZonesTab use `useCrudForm`; DashboardPage imports the
|
||||
shared InlineError. `npm run test`, `typecheck`, `lint` green.
|
||||
- [ ] Full suite green: `zig build test -Dintegration`.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No behavioral changes: this milestone moves code. The only sanctioned
|
||||
behavior deltas are the tcp counter rename, DoH's cause unwrap, the two
|
||||
restored focus rings, and the `check` ZON rendering — each named above.
|
||||
- No unification of the three intentionally-divergent normalize policies.
|
||||
- No unification of the four reload flavors.
|
||||
- No `web/src/ui/` component library beyond `classes.ts` and
|
||||
`useCrudForm.ts` — no button/table/dialog components in this milestone.
|
||||
- No listener behavior additions (timeouts, keepalive) — m16 finished
|
||||
those.
|
||||
- No renaming of exported metric families beyond the one tcp rename.
|
||||
@@ -0,0 +1,422 @@
|
||||
# Milestone 19: hygiene sweep
|
||||
|
||||
Goal: remove the dead surface, unify the re-hardcoded constants, close the
|
||||
small silent failures, and fix the frontend state hazards — the residue of
|
||||
`TECH_DEBT.md` Theme 8 plus the remaining lows, after the behavioral fixes
|
||||
(m16), the contract work (m17) and the duplication refactors (m18) have
|
||||
landed.
|
||||
|
||||
**PROVISIONAL.** This spec was written before milestones 16-18 were built. It
|
||||
assumes: m17 shipped BADVERS (so `edns.extendedRcode` has a production
|
||||
caller), m17 built the upstream editor (so the queries.ts upstream factories
|
||||
have consumers and are not deleted here), and m18 shipped the frontend `ui/`
|
||||
extraction and deleted doh_server's dead `serve()`. Before starting this
|
||||
milestone, re-verify every line reference and update this spec where the
|
||||
earlier milestones moved things.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
### 1. The dead ECS parse surface is deleted
|
||||
|
||||
`src/dns/edns.zig`: delete `parseEcs` (:111), `Ecs` (:98-105), `EcsError`
|
||||
(:107), `ecs_family_ipv4`/`ecs_family_ipv6` (:95-96), and their in-file tests
|
||||
(:371-:469 block, nine tests). `stripEcs` filters by option code without
|
||||
decoding, forward mode keys on raw bytes — no production path decodes ECS.
|
||||
|
||||
Trap the audit missed: `tests/fuzz/dns_fuzz.zig:86` calls `edns.parseEcs`
|
||||
inside a fuzz target that the default `test` step builds. Delete that line in
|
||||
the same change or the build breaks.
|
||||
|
||||
`extendedRcode` (:272) is **kept**: milestone 17 gave it the BADVERS caller.
|
||||
If m17 diverged and BADVERS was not built, delete it too and note the
|
||||
divergence here.
|
||||
|
||||
Precedent: `ecsPayload` was built and removed within milestone 7 when it lost
|
||||
its only caller (specs/milestone-7.md:361). Git history keeps the code.
|
||||
|
||||
### 2. `writeSettings` returns an error union
|
||||
|
||||
`src/web/handlers/settings.zig:349` returns `?db.Error`, so every exit is a
|
||||
value return and the `errdefer` at :354 never fires — it implies protection
|
||||
it cannot provide. The real safety is two explicit `tx.rollback()` calls
|
||||
(:358, :363 — the audit said three; it is two).
|
||||
|
||||
Change the return type to `db.Error!void`. The errdefer becomes live
|
||||
(`db.Tx.rollback` at storage/db.zig:814 is idempotent and documented safe in
|
||||
an errdefer), and the two manual rollback-then-return arms collapse into
|
||||
plain `try`/error returns. The one caller (`applyPut`, :327) switches from
|
||||
`if (writeSettings(...)) |err|` to `catch`; its hash-free-on-failure behavior
|
||||
is unchanged.
|
||||
|
||||
### 3. The logger's field widths become the single source
|
||||
|
||||
`src/storage/logger.zig:44-48` holds four file-private constants
|
||||
(`max_domain_len = 253`, `max_client_len = 45`, `max_reason_len = 32`,
|
||||
`max_upstream_len = 64`). Make all four `pub`. Then:
|
||||
|
||||
- `src/server/handler.zig:67` deletes its local `max_reason_len = 32`; the
|
||||
comptime guard at :69-75 checks `logger.max_reason_len`, so the guard
|
||||
finally proves what its comment claims.
|
||||
- `src/server/handler.zig:79` deletes its local `max_ip_text = 45` and uses
|
||||
`logger.max_client_len` (the :82 `max_resolver_text` derivation follows).
|
||||
- `src/server/clients.zig:32` likewise.
|
||||
- `src/web/handlers/queries.zig:27-30` — the fifth copy the audit missed —
|
||||
adopts `logger.max_domain_len` for the domain width. Its `max_client_len`
|
||||
is **64**, not 45; the query log's client column is written by the logger
|
||||
and can never exceed 45 bytes, so it adopts `logger.max_client_len` too.
|
||||
If a test proves a wider value ever reaches that field, stop and record
|
||||
the divergence instead of forcing it.
|
||||
|
||||
Acceptance is a grep: the literals 253, 45 and 32 appear in these roles only
|
||||
in logger.zig.
|
||||
|
||||
### 4. `track()` reports fullness; one mutex acquisition per query
|
||||
|
||||
`src/server/handler.zig:255-258` takes the tracker mutex twice per query:
|
||||
`tracker.track(io, from)` and then `tracker.snapshotStats(io)` — a full
|
||||
struct copy — solely to mirror `dropped_full` into the handler's atomic
|
||||
`tracker_full` (:150-153).
|
||||
|
||||
Change `Tracker.track`/`trackAt` (`src/server/clients.zig:89-97`) to return
|
||||
`u64`: the current `dropped_full`, read under the mutex they already hold.
|
||||
The handler stores that return value. `snapshotStats` stays for /metrics.
|
||||
|
||||
### 5. Log truncation gets a marker and a counter
|
||||
|
||||
`src/platform/logging.zig:285-288`: `mw.print(format, args) catch {}` on a
|
||||
fixed 2048-byte writer, then the partial buffer is used with no marker — the
|
||||
module's own never-both-discarded-and-silent invariant, violated in its own
|
||||
kitchen. On `error.WriteFailed`: overwrite the final three bytes of the
|
||||
buffered message with `...` (the safe_url.zig marker, src/safe_url.zig:11-14)
|
||||
and increment a new `state.stats.lines_truncated`, sibling to
|
||||
`lines_deduped` (:295). One test: a message over 2048 bytes ends in `...`
|
||||
and bumps the counter by one.
|
||||
|
||||
### 6. TLS errors are classified by name, not by name prefix
|
||||
|
||||
`src/filter/fetcher.zig:169-170` and `src/upstream/doh_client.zig:160-161`
|
||||
classify TLS failures with `startsWith(@errorName(err), "Tls")` /
|
||||
`"Certificate"` — a std rename silently downgrades TLS failures into generic
|
||||
buckets. Verified reachable set from `std.http.Client` (0.16.0) **today**:
|
||||
exactly `error.TlsInitializationFailed` and
|
||||
`error.CertificateBundleLoadFailure`. But this milestone lands after
|
||||
milestone 18, whose ruling 4 makes the DoH path unwrap the client's stashed
|
||||
read cause — and that cause set includes the record-layer members
|
||||
(`TlsAlert`, `TlsBadRecordMac`, `TlsDecodeError`, ...) from
|
||||
`std.crypto.tls.Client`. A two-member switch written against today's
|
||||
surface would downgrade those to generic receive failures.
|
||||
|
||||
In both files: replace the prefix match with an exact switch → `error.TlsFailed`
|
||||
naming the two top-level members **plus**, on whichever paths m18's cause
|
||||
unwrap surfaces, the `Tls*` members of the unwrapped read-cause set —
|
||||
enumerated at implementation time from the pinned std sources against the
|
||||
m18 code as landed, not from this spec's list. The `else` falls through to
|
||||
the existing per-phase mapping. Rewrite both tests (fetcher.zig:311-313,
|
||||
doh_client.zig:261-263): they currently assert on `CertificateExpired`,
|
||||
which nothing can produce, and omit `CertificateBundleLoadFailure`; the new
|
||||
tests use only names from the verified reachable set, including a stashed
|
||||
record-layer cause on the unwrapped path.
|
||||
|
||||
If m18 already extracted a shared `mapError` into transport.zig, apply this
|
||||
ruling to the shared copy instead and note it here.
|
||||
|
||||
### 7. The transcribed mbedTLS values get an init-time check
|
||||
|
||||
`src/platform/tls_server.zig:501-511` hand-transcribes four config enums and
|
||||
six error codes from the pinned Mbed TLS 3.6.7 headers with no drift guard —
|
||||
unlike the sizes and alignment, which the shim reports. A renumbered
|
||||
`close_notify` after a version bump would silently invert truncation
|
||||
detection (:286, :351).
|
||||
|
||||
`src/platform/mbedtls_shim.c` gains ten getters in the existing shape
|
||||
(`int nx_const_ssl_transport_stream(void) { return MBEDTLS_SSL_TRANSPORT_STREAM; }`
|
||||
etc. — the needed headers are already included). `tls_server.zig` verifies
|
||||
all ten against the transcribed constants next to the existing alignment
|
||||
assert at :78, and the :657 test block asserts them too. Cheap insurance,
|
||||
one-time cost.
|
||||
|
||||
### 8. The reload lock invariant is written down
|
||||
|
||||
Fourteen call sites in four handler files (groups, rules, clients,
|
||||
blocklists — the audit said five handlers; it is four files, fourteen sites)
|
||||
call `mutations.reload` *after* releasing `config_lock`. That is safe only
|
||||
because the production `reload_fn` re-reads the database under the manager's
|
||||
own writer lock — a cross-module fact stated nowhere. `local.zig` is the
|
||||
deliberate opposite (publish under the lock; the ordering rationale lives at
|
||||
local.zig:80-85).
|
||||
|
||||
Extend the doc comment on `mutations.reload`
|
||||
(src/web/handlers/mutations.zig:122-125): the `reload_fn` contract is that it
|
||||
re-reads all state from the database itself; it must never accept pre-read
|
||||
rows (contrast `swapLocalTables`, which is the pre-read shape and is why
|
||||
local.zig holds the lock). A future signature change to `reload` that adds
|
||||
row-passing breaks fourteen call sites' correctness — the comment must say
|
||||
exactly that. Documentation only; no locking change.
|
||||
|
||||
### 9. `web_dev_dir` moves into WebState
|
||||
|
||||
`src/app.zig:605-609` holds the `--web-dev` directory in a module-level
|
||||
mutable global because the fallback handler "has no closure" — but
|
||||
`serveWebDev` (:613-620) receives `*WebState` and discards it. Add
|
||||
`dev_dir: []const u8 = ""` to `WebState` (src/web/server.zig, beside
|
||||
`version`), set it at the composition root, read it in `serveWebDev`, delete
|
||||
the global and its apologia comment.
|
||||
|
||||
### 10. The private key PEM is wiped
|
||||
|
||||
`src/server/cert_store.zig:326` frees the key PEM without zeroization, and
|
||||
`readPem` (:345-362) uses `readFileAllocOptions`, whose grow-as-you-read can
|
||||
leave intermediate copies in freed pages — a wipe at the call site cannot
|
||||
reach them. Split the key path: a `readKeyPem` that reads through a single
|
||||
fixed `max_pem_bytes + 1` buffer (no reallocation), copies to an exact-size
|
||||
allocation, and `std.crypto.secureZero`s the big buffer before returning; the
|
||||
caller wipes the returned slice before `gpa.free`. The idiom precedent is
|
||||
auth.zig:228. The certificate PEM path stays as-is — it is public material.
|
||||
Runs at boot and on real renewals only (:311-312 stats gate the poll).
|
||||
|
||||
### 11. Header-array overflow asserts
|
||||
|
||||
`src/web/http_util.zig:335` maps a too-long `extra_headers` onto
|
||||
`error.OutOfMemory` — a future eighth header would surface as mysterious
|
||||
OOM-labeled drops. Every one of the 62 call sites passes a compile-time
|
||||
count, maximum 3 (static.zig:174, +1 for content-type = 4 of 8). Replace the
|
||||
check with `std.debug.assert(extra_headers.len + 1 <= headers.len);` so the
|
||||
mistake fails loudly in tests.
|
||||
|
||||
### 12. Shipped `.gz` siblings are verified; orphans are rejected
|
||||
|
||||
`tools/gen_web_assets.zig:71-77` embeds a dist-shipped `.gz` sibling with
|
||||
zero content checks, and an orphan `.gz` (no base file) is silently embedded
|
||||
as unreachable `application/octet-stream` bytes. Exposure is latent (no
|
||||
frontend compression plugin today) — close it while it is cheap:
|
||||
|
||||
- Sibling: decompress with `std.compress.flate.Decompress` (container
|
||||
`.gzip`; the tool already uses the Compress side at :133) and byte-compare
|
||||
against the base file; `std.process.fatal` on mismatch, matching the
|
||||
tool's existing error style (:73-75).
|
||||
- Orphan: `std.process.fatal` naming the path, instead of indexing it.
|
||||
|
||||
Extend the embedded-dist test (`src/web/static.zig:413-439`) to decompress
|
||||
each `.gz` entry and compare with its base — it currently checks magic bytes
|
||||
and size only.
|
||||
|
||||
### 13. The Dockerfile arch default comes from the host
|
||||
|
||||
`deploy/docker/Dockerfile:21`: `${TARGETARCH:-amd64}` — under the legacy
|
||||
builder TARGETARCH is empty, so a plain `docker build` on the Pi 5 packages
|
||||
the x86_64 binary and dies at `docker run` with exec-format, far from the
|
||||
mistake. Replace the default with a `uname -m` mapping resolved before the
|
||||
case:
|
||||
|
||||
```dockerfile
|
||||
RUN arch="${TARGETARCH:-}"; \
|
||||
if [ -z "$arch" ]; then case "$(uname -m)" in \
|
||||
x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; \
|
||||
*) echo "unsupported build host $(uname -m); use buildx" >&2; exit 1 ;; \
|
||||
esac; fi; \
|
||||
case "$arch" in ...existing arms... esac
|
||||
```
|
||||
|
||||
The existing unsupported-arch arm stays fail-fast.
|
||||
|
||||
### 14. Frontend: the settings registry is typed
|
||||
|
||||
`web/src/features/settings/SettingsPage.tsx` — `SectionDef` exists (:21) but
|
||||
`FieldDef.key` is `string` (:16), driving `as Record<string, unknown>` casts
|
||||
at :217, :226, :261. A typo'd key compiles and breaks the field. Make the
|
||||
pair generic:
|
||||
|
||||
```ts
|
||||
interface FieldDef<S extends keyof Settings> {
|
||||
key: keyof Settings[S] & string;
|
||||
kind: "number" | "text" | "boolean" | readonly string[];
|
||||
}
|
||||
interface SectionDef<S extends keyof Settings> { section: S; title: string; fields: readonly FieldDef<S>[]; }
|
||||
function defineSection<S extends keyof Settings>(def: SectionDef<S>) { return def; }
|
||||
```
|
||||
|
||||
`SECTIONS` (:35-118, 12 sections) becomes a tuple of `defineSection(...)`
|
||||
calls, `TLS_FIELDS` (:27) becomes `FieldDef<"doh_server" | "dot_server">[]`
|
||||
compatible, and the three `Record<string, unknown>` casts are deleted. The
|
||||
per-branch value casts inside `FieldRow` (:141, :160, :174, :198) may stay —
|
||||
they are narrowing on `kind`, not drift holes. Acceptance: renaming a
|
||||
Settings field makes `tsc` fail on the registry.
|
||||
|
||||
### 15. Frontend: the settings baseline is frozen
|
||||
|
||||
Same file, :220 — `buildSettingsPatch(data.settings, edited, ...)` diffs the
|
||||
mount-time clone against **live** query data, so a background refetch makes
|
||||
out-of-band changes appear as user edits and Save silently reverts them. Add
|
||||
a `baseline` state initialized with the same `structuredClone` and reset in
|
||||
the same `onSuccess` (:234-241) where `edited` resets; the patch becomes
|
||||
`buildSettingsPatch(baseline, edited, ...)`. `settingsDiff.ts` is unchanged.
|
||||
|
||||
### 16. Frontend: refresh status leaves the query cache
|
||||
|
||||
`web/src/features/blocklists/BlocklistsPage.tsx:33` reads the refresh
|
||||
snapshot with non-subscribing `getQueryData` from a cache entry that has no
|
||||
queryFn, no subscriber, and the default 5-minute gcTime — after which the
|
||||
page claims no refresh ever ran. This is client UI state. New module
|
||||
`web/src/features/blocklists/refreshStore.ts` in the exact shape of
|
||||
`settings/restartBanner.ts` (module-level value + listener Set +
|
||||
`useSyncExternalStore` hook), holding `SourceStatus[] | null`. The mutation
|
||||
(`blocklistsUpdateNowMutation`, lib/queries.ts:156) writes the store instead
|
||||
of `setQueryData`; the page subscribes via the hook; the
|
||||
`queryKeys.blocklistSources` entry and its doc comment are deleted. Note the
|
||||
prefix-invalidation side effect disappears with it (invalidateBlocklistWorld
|
||||
invalidating `["blocklists"]` used to mark the entry stale — harmless, since
|
||||
the entry could never refetch).
|
||||
|
||||
### 17. Frontend: one default-group helper
|
||||
|
||||
Four encodings of "group 1", two semantics (GroupsPage.tsx:14 named
|
||||
constant; PrefixesEditor.tsx:21 and LookupPage.tsx:133 byte-identical
|
||||
prefer-id-1 expressions; RulesPage.tsx:24 first-of-list — which, because the
|
||||
API orders by name (groups_repo.zig:148), preselects the alphabetically
|
||||
first group, not the default). New module `web/src/lib/defaultGroup.ts`:
|
||||
|
||||
```ts
|
||||
export const DEFAULT_GROUP_ID = 1;
|
||||
export function defaultGroupId(groups: readonly Group[]): number {
|
||||
return groups.find((g) => g.id === DEFAULT_GROUP_ID)?.id ?? groups[0]?.id ?? DEFAULT_GROUP_ID;
|
||||
}
|
||||
```
|
||||
|
||||
All four sites adopt it; RulesPage's preselect bug goes away with the
|
||||
adoption.
|
||||
|
||||
### 18. Frontend: the dashboard primes without throwing
|
||||
|
||||
`web/src/routes.tsx:97-102` — the dashboard loader's `Promise.all` over four
|
||||
`ensureQueryData` calls means one failing endpoint blanks the whole page with
|
||||
`RouteError` on cold navigation, defeating the page's own per-widget
|
||||
degrade (DashboardPage.tsx:68-96, which uses `useQuery` precisely to allow
|
||||
it). Change **only the dashboard loader** to `Promise.allSettled`. The five
|
||||
other `Promise.all` loaders stay: their pages use `useSuspenseQuery` and have
|
||||
no granular fallback to preserve.
|
||||
|
||||
### 19. Frontend: the query-key table is complete
|
||||
|
||||
`web/src/lib/queries.ts` — eight raw literals, all in this file: seven
|
||||
`["lookup"]` prefix-invalidations (:99, :125, :132, :159, :167, :189, :211)
|
||||
and one `["groups"]` (:133). Add `lookupAll: ["lookup"] as const` to
|
||||
`queryKeys`, use it at the seven sites, and use `queryKeys.groups` at :133.
|
||||
Acceptance: no `["lookup"]`/`["groups"]` literal outside the table.
|
||||
|
||||
### 20. Frontend: the form catch swallows only the expected class
|
||||
|
||||
`web/src/features/blocklists/BlocklistForm.tsx:21-33` — the bare `catch {}`
|
||||
wraps both the `await onSubmit` and the reset `setState` calls; anything but
|
||||
the mutation rejection dies silently with no console trace. House precedent
|
||||
is auth/store.tsx:85-89 (swallow only the 401 it expects, rethrow the rest),
|
||||
pinned by two tests. Restructure: `try { await onSubmit(...) } catch (error)
|
||||
{ if (error instanceof ApiError) return; throw error; }`, resets after the
|
||||
try. The page's `<InlineError>` keeps rendering the mutation error as today.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1-S4 run in parallel; no two sessions write the same file.
|
||||
|
||||
### Session S1: backend surface
|
||||
|
||||
Owns `src/dns/edns.zig`, `tests/fuzz/dns_fuzz.zig`,
|
||||
`src/web/handlers/settings.zig`, `src/web/handlers/mutations.zig`,
|
||||
`src/web/http_util.zig`, `src/server/cert_store.zig`, `src/app.zig`,
|
||||
`src/web/server.zig`. Rulings 1, 2, 8, 9, 10, 11.
|
||||
|
||||
### Session S2: constants, counters, classification
|
||||
|
||||
Owns `src/storage/logger.zig`, `src/server/handler.zig`,
|
||||
`src/server/clients.zig`, `src/web/handlers/queries.zig`,
|
||||
`src/platform/logging.zig`, `src/filter/fetcher.zig`,
|
||||
`src/upstream/doh_client.zig`, `src/platform/tls_server.zig`,
|
||||
`src/platform/mbedtls_shim.c`. Rulings 3, 4, 5, 6, 7.
|
||||
|
||||
### Session S3: frontend
|
||||
|
||||
Owns everything under `web/src/`. Rulings 14-20.
|
||||
|
||||
### Session S4: tools and deploy
|
||||
|
||||
Owns `tools/gen_web_assets.zig`, `src/web/static.zig`,
|
||||
`deploy/docker/Dockerfile`. Rulings 12, 13.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
Re-verifies this spec's line references against post-m18 reality before S1-S4
|
||||
start; strikes the closed findings in `TECH_DEBT.md` after.
|
||||
|
||||
## Module layout
|
||||
|
||||
New files:
|
||||
|
||||
- `web/src/features/blocklists/refreshStore.ts` — refresh snapshot store.
|
||||
- `web/src/lib/defaultGroup.ts` — default-group constant and helper.
|
||||
|
||||
Deleted surface: `parseEcs`/`Ecs`/`EcsError`/`ecs_family_*` (edns.zig), the
|
||||
`web_dev_dir` global (app.zig), the `queryKeys.blocklistSources` entry
|
||||
(queries.ts).
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] `parseEcs` and its family are gone; `zig build test` compiles and
|
||||
passes (the dns_fuzz caller was removed in the same change).
|
||||
- [ ] `writeSettings` returns `db.Error!void`; the errdefer is the only
|
||||
rollback path; the settings PUT integration tests still pass. A fault
|
||||
test forces a failure after `Tx.begin` (a fault seam under
|
||||
`builtin.is_test`, the milestone-15 rotation-seam shape) and proves
|
||||
the errdefer rolled the transaction back: a subsequent `Tx.begin` on
|
||||
the same connection succeeds.
|
||||
- [ ] Grep proves the field widths exist once: 253/45/32 in their logger
|
||||
roles appear only in logger.zig.
|
||||
- [ ] One tracker-mutex acquisition per query: `track` returns
|
||||
`dropped_full`; `/metrics` output for the tracker family is unchanged.
|
||||
- [ ] An oversized log message ends in `...` and increments
|
||||
`lines_truncated`; the new test pins both.
|
||||
- [ ] Both TLS mapError switches name exactly the two reachable errors; the
|
||||
rewritten tests use reachable names only.
|
||||
- [ ] The ten mbedTLS constants are verified at init against shim getters;
|
||||
a deliberately wrong transcription fails the :657 test block.
|
||||
- [ ] `mutations.reload`'s doc comment states the re-read contract and the
|
||||
fourteen-call-site consequence.
|
||||
- [ ] `WebState.dev_dir` replaces the global; `--web-dev` serving still
|
||||
works (manual check with a dev dir).
|
||||
- [ ] The key PEM path reads through a fixed buffer and is wiped before
|
||||
free; no `readFileAllocOptions` remains on the key path.
|
||||
- [ ] `respondBytes` asserts instead of returning OutOfMemory.
|
||||
- [ ] gen_web_assets rejects a corrupted shipped sibling and an orphan
|
||||
`.gz` (both proven with a doctored dist in a temp dir); the embedded
|
||||
test decompresses and compares.
|
||||
- [ ] `docker build` (legacy builder, no buildx) on this host produces a
|
||||
runnable image; the Dockerfile has no bare `:-amd64` default.
|
||||
- [ ] `tsc` fails when a Settings field named in SECTIONS is renamed
|
||||
(proven, then reverted).
|
||||
- [ ] The settings baseline freezes at mount: a simulated background refetch
|
||||
with out-of-band changes produces no phantom patch entries (frontend
|
||||
test).
|
||||
- [ ] The refresh snapshot survives past gcTime: a frontend test renders the
|
||||
status 6+ minutes of fake time after the mutation.
|
||||
- [ ] All four group-id sites import `defaultGroup.ts`; RulesPage preselects
|
||||
the id-1 group when present (frontend test with a group sorting before
|
||||
"default").
|
||||
- [ ] The dashboard renders per-widget errors on cold navigation with one
|
||||
endpoint failing (frontend test); the other five loaders are
|
||||
unchanged.
|
||||
- [ ] No `["lookup"]` or `["groups"]` literal outside `queryKeys`.
|
||||
- [ ] BlocklistForm rethrows a non-ApiError (frontend test, mirroring the
|
||||
logout pair).
|
||||
- [ ] Full suite green: `zig build test -Dintegration`, `npm run test`,
|
||||
`npm run typecheck`, `npm run lint`.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No deletion of `extendedRcode` (m17 gave it a caller) and no deletion of
|
||||
the upstream mutation factories (m17 built their consumer).
|
||||
- No locking change in the reload handlers — ruling 8 is documentation.
|
||||
- No new frontend dependencies; the store is hand-rolled like restartBanner.
|
||||
- No compression plugin for the frontend build; ruling 12 guards the seam,
|
||||
it does not start using it.
|
||||
- No `useInfiniteQuery` migration and no QueryLogPage work — that was
|
||||
milestone 16.
|
||||
- No renaming of settings keys or API fields — typing the registry must not
|
||||
change the wire format.
|
||||
Reference in New Issue
Block a user