Compare commits
10
Commits
v0.0.10
...
348e955b8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
348e955b8f | ||
|
|
ffc3ca61ba | ||
|
|
5c89acf337 | ||
|
|
3c2d0d41f0 | ||
|
|
31a6f0c5e5 | ||
|
|
51d8281abb | ||
|
|
266dde7396 | ||
|
|
32cd9b8e3e | ||
|
|
e14a29c5de | ||
|
|
da4441b24a |
@@ -123,57 +123,12 @@ jobs:
|
|||||||
|
|
||||||
# The licence inventory has to cover every package whose bytes ship, and
|
# The licence inventory has to cover every package whose bytes ship, and
|
||||||
# the lockfile does not answer that question: it lists what could be
|
# the lockfile does not answer that question: it lists what could be
|
||||||
# reached, not what rollup kept. Four packages of the non-dev closure are
|
# reached, not what rollup kept. The bundle is what this reads. The logic
|
||||||
# recorded as tree-shaken away, and if application code starts importing
|
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
|
||||||
# one of them, no lockfile, no version and no dependency set changes —
|
# exactly as it runs here (milestone-14 deviation 24).
|
||||||
# only the bundle does. So the bundle is what this reads.
|
|
||||||
#
|
|
||||||
# A second build with sourcemaps, because the shipped build has none: the
|
|
||||||
# `sources` list of each chunk names the packages whose modules went into
|
|
||||||
# it. The output goes to its own directory so the artifact npm run build
|
|
||||||
# produced is the one that gets embedded, untouched.
|
|
||||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: |
|
run: npm run assert-bundled
|
||||||
set -euo pipefail
|
|
||||||
# The binary npm ci installed, never `npx`: npx silently downloads a
|
|
||||||
# package it cannot find locally, so a wrong working directory would
|
|
||||||
# turn a licence check into an unpinned fetch from the network.
|
|
||||||
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
|
|
||||||
|
|
||||||
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
|
|
||||||
if [ -z "$maps" ]; then
|
|
||||||
echo "the sourcemap build produced no .map files; this check cannot run blind"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
bundled=$(jq -r '.sources[]' $maps \
|
|
||||||
| grep 'node_modules/' \
|
|
||||||
| sed 's|.*node_modules/||' \
|
|
||||||
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
|
|
||||||
| LC_ALL=C sort -u)
|
|
||||||
|
|
||||||
recorded=$(awk '
|
|
||||||
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
|
|
||||||
grab && /^\[/ { exit }
|
|
||||||
grab && NF { print }
|
|
||||||
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
|
|
||||||
|
|
||||||
if [ -z "$recorded" ]; then
|
|
||||||
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
|
|
||||||
echo
|
|
||||||
echo "the set of npm packages in web/dist has changed (-recorded +current)."
|
|
||||||
echo "Work out what the change means for licenses/inventory.zon first, then record"
|
|
||||||
echo "the new list in that section of licenses/dependency-identity.txt."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "web/dist bundles exactly the recorded packages:"
|
|
||||||
printf '%s\n' "$bundled"
|
|
||||||
|
|
||||||
package:
|
package:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|||||||
+175
-1140
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -10,7 +10,7 @@ subject rarely does.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.0.1] - 2026-08-07
|
## [0.0.1] - 2026-08-09
|
||||||
|
|
||||||
First release. Everything below is new.
|
First release. Everything below is new.
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,27 @@ pub fn build(b: *std.Build) void {
|
|||||||
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||||
.dependOn(&aarch64_run.step);
|
.dependOn(&aarch64_run.step);
|
||||||
|
|
||||||
|
// The release publication tool (milestone-14 deviation 24). It is a host
|
||||||
|
// tool like `dist_stage` and `verify_dist`, and it is installed rather than
|
||||||
|
// run from the build graph: the workflow invokes it once per phase with the
|
||||||
|
// secrets in its environment, and a Run step would have to carry them.
|
||||||
|
const release_tool = hostTool(b, "release");
|
||||||
|
b.step("release-tool", "Install the release publication tool into zig-out/bin")
|
||||||
|
.dependOn(&b.addInstallArtifact(release_tool, .{}).step);
|
||||||
|
|
||||||
|
// Its pure decisions — semver ordering, VALIDSIG field selection, changelog
|
||||||
|
// extraction, the releases-payload shape guard — are the reason it exists,
|
||||||
|
// so they run in the same `zig build test` as everything else.
|
||||||
|
const release_tests = b.addTest(.{
|
||||||
|
.name = "release-tool",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("tools/release.zig"),
|
||||||
|
.target = b.graph.host,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
test_step.dependOn(&b.addRunArtifact(release_tests).step);
|
||||||
|
|
||||||
addDist(b, options, web_assets, .{
|
addDist(b, options, web_assets, .{
|
||||||
.version = version_option,
|
.version = version_option,
|
||||||
.version_string = version_string,
|
.version_string = version_string,
|
||||||
|
|||||||
+60
-106
@@ -8,41 +8,12 @@ Do this before you run the binary, not after. The whole point of the checksum
|
|||||||
file is that it is signed, so a tampered mirror cannot hand you a matching
|
file is that it is signed, so a tampered mirror cannot hand you a matching
|
||||||
tarball and a matching checksum at the same time.
|
tarball and a matching checksum at the same time.
|
||||||
|
|
||||||
> Verification: no nxdns release exists yet. The repository has no tags, no
|
> Verification: every command on this page was run on 2026-08-09 against the
|
||||||
> release page and no pushed image, so nothing on this page could be run against
|
> published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
|
||||||
> a real release asset and no command here was pointed at
|
> holding only the key fetched from keys.openpgp.org. Every transcript below is
|
||||||
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
|
> that run's output. Where a block shows a failure — a `BAD signature`, a
|
||||||
> used, and every block says which one applies to it.
|
> `FAILED` hash — the failure was produced deliberately by tampering with a
|
||||||
>
|
> copy of the real file, and the surrounding text says how.
|
||||||
> The URL shapes were probed against `gitea.com`, a public instance of the same
|
|
||||||
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
|
|
||||||
> does have releases. `git.mial.net` reports `1.27.1`, and its
|
|
||||||
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
|
|
||||||
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
|
|
||||||
> `releases/download/v0.15.1/checksums.txt` and
|
|
||||||
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
|
|
||||||
> object and delivered the same 1,842-byte file under `-L`;
|
|
||||||
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
|
|
||||||
>
|
|
||||||
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
|
|
||||||
> stand-in files: two random-byte files named like the release tarballs, an
|
|
||||||
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
|
|
||||||
> computed over the three, signed by a **throwaway demonstration key generated
|
|
||||||
> for this page**. That key has the shape the real one will have — an ed25519
|
|
||||||
> primary key plus a separate ed25519 signing subkey, with the signature made by
|
|
||||||
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
|
|
||||||
> structure a subkey-signed release produces. The fingerprints printed in those
|
|
||||||
> transcripts are the throwaway key's, they are not the project's, and they will
|
|
||||||
> not match anything you download. The only edit to that run's output is the
|
|
||||||
> version in every filename, which became `<version>`.
|
|
||||||
>
|
|
||||||
> The container blocks were not run against nxdns — there is no published image.
|
|
||||||
> The two `docker buildx imagetools inspect --format` shapes were run here
|
|
||||||
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
|
|
||||||
> pins; the digest form printed
|
|
||||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
|
|
||||||
> the platform form printed a list. The `docker create`/`docker cp` comparison
|
|
||||||
> was run against an image built from this checkout rather than a pulled one.
|
|
||||||
|
|
||||||
## What a release contains
|
## What a release contains
|
||||||
|
|
||||||
@@ -94,11 +65,8 @@ want for the placeholder:
|
|||||||
VERSION=<version>
|
VERSION=<version>
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified against nxdns: there is no release to redirect to, so the first
|
> Verified: the two-command form, run against this repository, printed `0.0.1`
|
||||||
> block prints an empty line here and every URL built from it is a 404. The
|
> with `v0.0.1` published.
|
||||||
> exact two-command form was run against `gitea.com/gitea/tea`, a public
|
|
||||||
> repository on Gitea `1.27.0+dev` that does have releases, and printed
|
|
||||||
> `0.15.1`.
|
|
||||||
|
|
||||||
Pin the version in anything you script or automate. `latest` is convenient for
|
Pin the version in anything you script or automate. `latest` is convenient for
|
||||||
a person at a terminal and a liability in a machine that upgrades itself.
|
a person at a terminal and a liability in a machine that upgrades itself.
|
||||||
@@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
|
|||||||
position, as `releases/download/latest/`. The tarball filenames contain the
|
position, as `releases/download/latest/`. The tarball filenames contain the
|
||||||
version, so this alias never saves you from knowing it for those two.
|
version, so this alias never saves you from knowing it for those two.
|
||||||
|
|
||||||
> Not verified against nxdns: no release, so every URL above is a 404 today.
|
> Verified against `v0.0.1`: all five assets downloaded through the versioned
|
||||||
> Both URL forms, including the 404 for GitHub's spelling, were exercised
|
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
|
||||||
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
|
> identical, and GitHub's spelling answered 404.
|
||||||
> the `latest` alias each answered 303 to the same stored object and delivered
|
|
||||||
> the same 1,842-byte `checksums.txt` when the redirect was followed.
|
|
||||||
|
|
||||||
## 3. Check the signature over `SHA256SUMS.txt`
|
## 3. Check the signature over `SHA256SUMS.txt`
|
||||||
|
|
||||||
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
|
|||||||
gpg --import
|
gpg --import
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified: the key is not published yet. Run on this host, that URL
|
> Verified: the key is published, and that exact `curl | gpg --import` reported
|
||||||
> returned 404, and so did the `by-email` lookup for the same address. The
|
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <mokhtar@mial.net>"
|
||||||
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
|
> imported` into a clean `GNUPGHOME`.
|
||||||
> unrelated key that is on keys.openpgp.org. Until this key is published there,
|
|
||||||
> get it from a source you can check some other way.
|
|
||||||
|
|
||||||
Then verify:
|
Then verify:
|
||||||
|
|
||||||
@@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||||
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||||
gpg: WARNING: This key is not certified with a trusted signature!
|
gpg: WARNING: This key is not certified with a trusted signature!
|
||||||
gpg: There is no indication that the signature belongs to the owner.
|
gpg: There is no indication that the signature belongs to the owner.
|
||||||
Primary key fingerprint: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
|
Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||||
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
|
Subkey fingerprint: 019D 00DF 8417 EBFD A547 1E5E F731 9CC0 24FB 5A96
|
||||||
```
|
```
|
||||||
|
|
||||||
**Those two fingerprints and that user id belong to a throwaway key generated
|
The *structure* is what to read: three lines, not one. `using EDDSA key` and
|
||||||
to produce this transcript.** They are not the project's, and what you see will
|
|
||||||
carry the project's uid and the fingerprint in this page instead. The
|
|
||||||
*structure* is what to read: three lines, not one. `using EDDSA key` and
|
|
||||||
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
||||||
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
||||||
one published above.
|
one published above. The subkey fingerprint can change — a signing subkey is
|
||||||
|
revoked and replaced on its own — but the primary fingerprint is the
|
||||||
|
project's identity and stays.
|
||||||
|
|
||||||
Exit status 0, and `Good signature`. That warning is normal and is not a
|
Exit status 0, and `Good signature`. That warning is normal and is not a
|
||||||
failure: it says you have not told GnuPG you believe the key belongs to the
|
failure: it says you have not told GnuPG you believe the key belongs to the
|
||||||
@@ -210,22 +173,18 @@ including one an attacker talked you into importing.
|
|||||||
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
||||||
|
|
||||||
```
|
```
|
||||||
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
|
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
|
||||||
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
|
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
|
||||||
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
|
gpg: BAD signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [unknown]
|
||||||
```
|
```
|
||||||
|
|
||||||
> Verified on this host. A throwaway ed25519 primary key was generated into a
|
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
|
||||||
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
|
> public key — which is why the `[unknown]` trust marker and the warning are
|
||||||
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
|
> there rather than being written in by hand. The good-signature transcript is
|
||||||
> — the same construction the release workflow uses — so the transcripts above
|
> the real release's; the `BAD signature` transcript is the same command
|
||||||
> are what a subkey-signed release actually prints, rather than what a key
|
> against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
|
||||||
> signing with its primary would. The verification ran from a second
|
> 1. The `sed`/`tr` pipeline printed
|
||||||
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
|
> `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
|
||||||
> trust marker and the warning are there rather than being written in by hand.
|
|
||||||
> The second transcript is the same command after one newline was appended to
|
|
||||||
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
|
|
||||||
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
|
|
||||||
|
|
||||||
## 4. Check the hashes
|
## 4. Check the hashes
|
||||||
|
|
||||||
@@ -263,12 +222,11 @@ Check the signature before the hashes, not after. An attacker who can replace
|
|||||||
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
||||||
thing in the set they cannot forge.
|
thing in the set they cannot forge.
|
||||||
|
|
||||||
> Verified on this host against the stand-in files: all three transcripts are
|
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
|
||||||
> real `sha256sum` output over two random-byte files named like the release
|
> three `OK` lines. The three transcripts above are the same command over
|
||||||
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
|
> copies of the real assets, with the aarch64 tarball absent for the first two
|
||||||
> tarball deleted for the first two blocks and one byte appended to the other
|
> and one byte appended to the x86_64 tarball for the third. Only the version
|
||||||
> for the third. Only the version in the filenames was replaced with
|
> in the filenames was replaced with `<version>`.
|
||||||
> `<version>`.
|
|
||||||
|
|
||||||
## 5. Look inside before extracting
|
## 5. Look inside before extracting
|
||||||
|
|
||||||
@@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
|||||||
Zig version. The version has to match the tag you downloaded, and the commit
|
Zig version. The version has to match the tag you downloaded, and the commit
|
||||||
has to match the commit the tag points at.
|
has to match the commit the tag points at.
|
||||||
|
|
||||||
> Not verified on this host: there is no release tarball to list or extract.
|
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
|
||||||
|
> six files with the stated modes, no symlinks and no absolute or `..` paths,
|
||||||
|
> and the extracted binary printed `nxdns 0.0.1
|
||||||
|
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
|
||||||
|
> then `zig 0.16.0`.
|
||||||
|
|
||||||
## 6. Verify the container image
|
## 6. Verify the container image
|
||||||
|
|
||||||
@@ -343,23 +305,13 @@ docker rm nxdns-verify
|
|||||||
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
||||||
```
|
```
|
||||||
|
|
||||||
> Not verified against nxdns: no image is published, so no command here was run
|
> Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
|
||||||
> against `git.mial.net/mokhtar/nxdns`. The two
|
> the `:0.0.1` tag resolves to were the same string
|
||||||
> `docker buildx imagetools inspect --format` shapes were run on this host
|
> (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
|
||||||
> against `alpine:3.22` on Docker Hub — the digest form printed
|
> and `:latest` resolved to it too), the platform form printed exactly
|
||||||
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
|
> `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
|
||||||
> which is the digest this project's builder stage pins, and the platform form
|
> pinned reference succeeded, and the binary copied out of that pulled image
|
||||||
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
|
> hashed identical to the `nxdns` in the x86_64 tarball.
|
||||||
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
|
|
||||||
> must not contain: Alpine's index carries attestation entries, and nxdns's
|
|
||||||
> build turns them off. Nothing was checked about how nxdns's index will
|
|
||||||
> actually look.
|
|
||||||
>
|
|
||||||
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
|
|
||||||
> here against an image built from this checkout rather than a pulled one, and
|
|
||||||
> the two hashes matched: the binary copied out of the image and
|
|
||||||
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
|
|
||||||
> file.
|
|
||||||
|
|
||||||
## What the signature proves, and what it does not
|
## What the signature proves, and what it does not
|
||||||
|
|
||||||
@@ -431,14 +383,16 @@ release used. The Zig version is the second line of `nxdns version`, and both
|
|||||||
it and the Node version are pinned to exact patch releases at the top of
|
it and the Node version are pinned to exact patch releases at the top of
|
||||||
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||||
|
|
||||||
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
|
> Verified against `v0.0.1`, and the result is the caveat above in action. The
|
||||||
> were run to completion, with the version read out of `build.zig.zon`: `dist`
|
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
|
||||||
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
|
> `Good signature` under the same signing subkey as the release, and
|
||||||
> described above. `zig build verify-dist` was run on the result too and exited
|
> `zig build dist` produced both tarballs. The hashes did **not** match the
|
||||||
> 0. What could not be run is everything that needs a release: the clone, the
|
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
|
||||||
> checkout and `git verify-tag` need a tag that does not exist, and there is no
|
> version matched the pin exactly; the Node version did not (24.14.1 against
|
||||||
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
|
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
|
||||||
> this section is about has never been performed.
|
> listed above. That is a measurement of what an unpinned rebuild gives you,
|
||||||
|
> not evidence of tampering: the signature, checksum and image checks earlier
|
||||||
|
> on this page all passed against the same release.
|
||||||
|
|
||||||
## If a check fails
|
## If a check fails
|
||||||
|
|
||||||
|
|||||||
+110
-15
@@ -580,12 +580,87 @@ was reproduced before it was fixed.
|
|||||||
would have turned a licence check into an unpinned fetch. Reproduced: it
|
would have turned a licence check into an unpinned fetch. Reproduced: it
|
||||||
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
||||||
|
|
||||||
|
23. **`actions/checkout` destroys the annotated tag object.** Found by the first
|
||||||
|
live dry run, not by review: on a tag ref, checkout fetches the *commit* SHA
|
||||||
|
into `refs/tags/<tag>`, so the signed tag reads as lightweight and the guard
|
||||||
|
refuses it as unannotated. Both jobs that read the tag object — signature
|
||||||
|
verification in the guard, the tagger date in the publish job — now force-
|
||||||
|
refetch `refs/tags/$TAG` from origin first. The same run also proved the
|
||||||
|
fail-closed secret guard for real: the first dry-run attempt ran with no
|
||||||
|
secrets configured (they were on the wrong repository) and stopped in the
|
||||||
|
guard with nothing built or pushed.
|
||||||
|
|
||||||
|
24. **Publication orchestration moved out of workflow shell into
|
||||||
|
`tools/release.zig`.** Ruling 5 already moved the packaging asserts out of
|
||||||
|
CI shell for one reason — "checks that only exist inside a workflow file are
|
||||||
|
the brittleness this exists to remove" — and the release job was the larger
|
||||||
|
half of the same problem, left in place. Three live failures came out of it,
|
||||||
|
and each was found by executing the workflow, which is the most expensive
|
||||||
|
place to find anything: `actions/checkout` replacing the annotated tag object
|
||||||
|
(deviation 23), the refetch that fixed it having no credentials because
|
||||||
|
`persist-credentials` is off, and the multiline armored subkey escaping the
|
||||||
|
runner's log masker, which masks per line.
|
||||||
|
|
||||||
|
Twelve subcommands, one per step group: `guard-tag`, `guard-ancestry`,
|
||||||
|
`guard-releases`, `resolve`, `changelog`, `image`,
|
||||||
|
`verify-image-binaries`, `sign`, `draft`, `latest`, `publish`, `scrub`. Every
|
||||||
|
behaviour recorded in deviations 10 to 15 and 23 is carried over unchanged —
|
||||||
|
probe-adopt, the VALIDSIG last field, the subkey-only import and signing
|
||||||
|
probe, the array-shape guard on the releases payload, the `:latest` label
|
||||||
|
read, the publish re-read, the per-home `gpgconf --kill`, the tag refetch.
|
||||||
|
What is new is that the semver ordering, VALIDSIG field selection, challenge
|
||||||
|
parsing, changelog extraction, checksum-line parsing, colon-format parsing
|
||||||
|
and payload-shape guard are 25 unit tests in `zig build test` rather than
|
||||||
|
shell that only ever runs on a tag push. `release.yml` keeps the triggers,
|
||||||
|
the concurrency group, the job graph, the SHA pins, the two pinned
|
||||||
|
fingerprints and the fail-closed secret presence check — which stays as
|
||||||
|
shell, deliberately, so that it runs before the tool is even compiled.
|
||||||
|
|
||||||
|
The same reasoning applies to the `jq` pipeline of the bundled-package gate,
|
||||||
|
which moved to `web/scripts/bundledPackages.mjs` with its own vitest
|
||||||
|
coverage and an `npm run assert-bundled` entry point.
|
||||||
|
|
||||||
|
**Secret contract change:** `RELEASE_GPG_SUBKEY` keeps its name but now
|
||||||
|
holds `base64 -w0` of the armored
|
||||||
|
`--export-secret-subkeys` output rather than the armored text. Manual
|
||||||
|
prerequisite 1 and 3 change accordingly. The tool decodes it in memory and
|
||||||
|
writes it to a mode-600 file inside the temporary `GNUPGHOME`. A single-line
|
||||||
|
secret is one the masker can actually mask.
|
||||||
|
|
||||||
|
25. **The first signing subkey was leaked into a job log and rotated.** Dry-run
|
||||||
|
attempt 3 failed inside the credential-less refetch, and the runner printed
|
||||||
|
the failing step's env block; the multiline armored `RELEASE_GPG_SUBKEY`
|
||||||
|
escaped the per-line masker while the single-line passphrase was masked.
|
||||||
|
Exposure: the passphrase-protected secret subkey only — the passphrase and
|
||||||
|
the primary key were never on the runner. Response: both runs that ever saw
|
||||||
|
the secret were deleted (verified 404 via the API and absent from
|
||||||
|
`actions_log` on disk), subkey `B281CECC…` was revoked with the primary,
|
||||||
|
and its replacement `019D00DF…` is the pinned `RELEASE_SIGNING_FPR`. The
|
||||||
|
base64 contract in deviation 24 is the preventive half of this record.
|
||||||
|
|
||||||
|
26. **The image is named by the public registry host, never the server URL.**
|
||||||
|
Attempt 4 reached the registry and failed at `docker login gitea:3000`:
|
||||||
|
inside the cluster `GITHUB_SERVER_URL` is `http://gitea:3000`, docker
|
||||||
|
refuses plain-http registries, and an image named `gitea:3000/…` would be
|
||||||
|
unpullable from anywhere that matters — a wrong name that would have been
|
||||||
|
written into the released `IMAGE-DIGEST.txt`. `release.yml` now pins
|
||||||
|
`REGISTRY_HOST: git.mial.net`; the tool uses it for docker and image
|
||||||
|
naming, and keeps the internal URL for the manifest probe (same registry,
|
||||||
|
no TLS dependency in the tool). The old shell had the identical latent bug;
|
||||||
|
no run ever reached it.
|
||||||
|
|
||||||
### Not verified, and why
|
### Not verified, and why
|
||||||
|
|
||||||
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
|
- **The workflows' validation history.** Before any live run, `release.yml` and
|
||||||
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
|
`gates.yml` were validated by YAML parse and `bash -n`, plus two steps lifted
|
||||||
registry probe against a fake registry (five response shapes) and the whole
|
out and run directly: the registry probe against a fake registry (five
|
||||||
bundled-package check against the real `web/` build, proven able to fail.
|
response shapes) and the bundled-package check against the real `web/` build,
|
||||||
|
proven able to fail. The live dry run then superseded this: attempt 5
|
||||||
|
published `v0.0.0` end to end — guard, gates, image push to both platforms,
|
||||||
|
binary-identity assertion, signing, draft, `:latest`, publication — and the
|
||||||
|
assets verified from a clean directory (checksums OK, signature good under
|
||||||
|
the rotated subkey). The throwaway release, tag and registry versions were
|
||||||
|
deleted afterwards.
|
||||||
Everything else that talks to the registry or the Gitea API — `buildx build
|
Everything else that talks to the registry or the Gitea API — `buildx build
|
||||||
--push`, `imagetools`, draft creation, asset upload, publication, the
|
--push`, `imagetools`, draft creation, asset upload, publication, the
|
||||||
adopt-an-existing-tag path — is unexercised.
|
adopt-an-existing-tag path — is unexercised.
|
||||||
@@ -620,24 +695,44 @@ was reproduced before it was fixed.
|
|||||||
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
||||||
tarballs **in the same directory**. (Cross-directory reproducibility is
|
tarballs **in the same directory**. (Cross-directory reproducibility is
|
||||||
ruling 12 and is not claimed here.)
|
ruling 12 and is not claimed here.)
|
||||||
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
- [x] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
||||||
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
||||||
byte-identical to the tarball binaries. Verified for the native amd64
|
byte-identical to the tarball binaries. The v0.0.1 run built and pushed
|
||||||
image only; the arm64 half needs a runner with buildx.
|
both platforms on the runner; the published index lists exactly
|
||||||
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
`linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
|
||||||
on `master`; `origin/main` is gone. The first two are in the files; no
|
both binaries against the tarballs before publication.
|
||||||
workflow has run and `origin/main` still exists (manual prerequisite).
|
- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
||||||
|
on `master`; `origin/main` is gone. Proven live: pushes to `master` run
|
||||||
|
the gates through `ci.yml`, and release runs 484-493 ran them through
|
||||||
|
`release.yml`.
|
||||||
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
||||||
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
||||||
runtime closure. The dependency drift guard was proven able to fail:
|
runtime closure. The dependency drift guard was proven able to fail:
|
||||||
removing an inventory entry, staling a dependency version, staling the Zig
|
removing an inventory entry, staling a dependency version, staling the Zig
|
||||||
version, changing the base image digest, editing a pinned licence text and
|
version, changing the base image digest, editing a pinned licence text and
|
||||||
dropping a package from the recorded bundle each produce a named failure.
|
dropping a package from the recorded bundle each produce a named failure.
|
||||||
- [ ] A dry run of `release.yml` completes with publication disabled.
|
- [x] A dry run of `release.yml` completes with publication disabled. Done with
|
||||||
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
a disposable published tag instead: publication cannot be disabled without
|
||||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
|
forking the flow it is supposed to prove, so `v0.0.0` ran the real path
|
||||||
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
|
end to end — five assets, verifying checksums and signature, a
|
||||||
published release, from a clean directory, on this host.
|
multi-architecture image — and was then deleted (release, git tag, both
|
||||||
|
registry versions). Five attempts; the failures and their fixes are
|
||||||
|
deviations 23-26.
|
||||||
|
- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||||
|
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
|
||||||
|
on the first attempt after the dry-run fixes.
|
||||||
|
- [x] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||||
|
published release, from a clean directory, on this host, with a clean
|
||||||
|
`GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every
|
||||||
|
command on the page passed: the `releases/latest` redirect printed
|
||||||
|
`0.0.1`, both tarball downloads and the `latest` alias worked (and
|
||||||
|
GitHub's spelling answered 404 as documented), the signature verified
|
||||||
|
with matching primary and subkey fingerprints, `sha256sum -c` said OK for
|
||||||
|
all three files, the tarball layout and modes matched, `nxdns version`
|
||||||
|
printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the
|
||||||
|
platform list was exactly `linux/amd64 linux/arm64`, and the binary
|
||||||
|
copied out of the pulled-by-digest image hashed identical to the tarball
|
||||||
|
binary.
|
||||||
- [x] No `zig build cross` or source-only-distribution text remains on any
|
- [x] No `zig build cross` or source-only-distribution text remains on any
|
||||||
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
||||||
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
# Milestone 20: declarative configuration for IaC
|
||||||
|
|
||||||
|
Goal: a config file an operator can keep in git and deploy with Ansible, where
|
||||||
|
the file is the sole declarative source of truth, converged at every boot —
|
||||||
|
without re-downloading every blocklist on every boot, and without the UI
|
||||||
|
silently diverging from the file. Two authority modes, selected by the
|
||||||
|
presence of one flag: bare `run` serves the DB; `run --config=<path>` makes
|
||||||
|
the file authority.
|
||||||
|
|
||||||
|
Design finalized 2026-08-09 after three adversarial rounds (red team ops 1-16
|
||||||
|
and debt 1-14; Codex cross-validation F1-F12; a Codex round on the premise
|
||||||
|
revision). All findings are folded in below or declined with written reasons;
|
||||||
|
all file:line anchors were re-verified at the pre-implementation HEAD
|
||||||
|
(7039a9f). A premise revision replaced the earlier `--config-source` mode
|
||||||
|
enum with presence-of-`--config` and deleted the persisted authority marker;
|
||||||
|
rulings 1, 6 and 8 record the reasons — do not reintroduce either.
|
||||||
|
|
||||||
|
## Implementation contract (read first)
|
||||||
|
|
||||||
|
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||||
|
- Session order: R1 → R2 sequential; R3 and R4 parallel to both (Sessions,
|
||||||
|
below). File ownership is write-exclusivity; the interfaces between
|
||||||
|
sessions (`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
|
||||||
|
`RouteInfo.policy`) are fixed in this spec and are not renegotiable
|
||||||
|
mid-build.
|
||||||
|
- Gates: `zig build test` and `zig build test -Dintegration` with 0 failed,
|
||||||
|
plus the drift-guard regenerations the Tests section names. Skip counts are
|
||||||
|
reported with their reasons (plain-suite skips are integration-gated; the 4
|
||||||
|
integration skips are the live-network TLS tests excluded by milestone-1
|
||||||
|
design). One `-Dlive` run covers the real download path (Tests).
|
||||||
|
- No `std.log.err` in new code. No secrets in logs — url redaction goes
|
||||||
|
through `src/safe_url.zig` as everywhere else.
|
||||||
|
- Every fix or behaviour claim lands with a test the author watched fail
|
||||||
|
against the reverted implementation (milestone-13 ruling F-f applies).
|
||||||
|
- Doc pages touched by R4 follow milestone-13 ruling 3: every command block
|
||||||
|
in `tutorial/` and `how-to/` is executed on this host by the session that
|
||||||
|
writes it, or marked in-page as unverified with the reason.
|
||||||
|
- The final commit is GPG-signed by the user (`git commit -S`, lowercase,
|
||||||
|
single line); stage the work and hand the command over.
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
### 1. Authority is the invocation: `--config` present means the file governs
|
||||||
|
|
||||||
|
`nxdns run` — the DB is authority, today's appliance behaviour.
|
||||||
|
`nxdns run --config=/etc/nxdns/config.zon` — the file is authority. The
|
||||||
|
flag's presence selects the mode; there is no mode enum and no default path.
|
||||||
|
`check` keeps its existing surface under the same rule: bare `check` grades
|
||||||
|
the DB, `check --config <file>` grades the file. The DB-exists-wins heuristic
|
||||||
|
in `checkImpl` (cli.zig:559-588) and `CheckArgs.config_explicit` are deleted.
|
||||||
|
|
||||||
|
Two principles, separated deliberately, because conflating them is what
|
||||||
|
produced the rejected `--config-source` enum:
|
||||||
|
|
||||||
|
- **Authority must be explicit in the invocation.** An operator reads
|
||||||
|
`ExecStart` and knows which authority is live. Probing `/etc/nxdns` for a
|
||||||
|
file and switching behaviour on its existence is ambient magic — the same
|
||||||
|
class of heuristic that made seed-once bootstrap a source of doc lies (the
|
||||||
|
compose comment, the first-run tutorial) — and stays banned: a file on disk
|
||||||
|
that no flag names changes nothing.
|
||||||
|
- **A mode enum is the wrong shape for a two-state choice a path already
|
||||||
|
expresses.** `--config-source=db` names an implementation, not an operator
|
||||||
|
intent, and a mode flag beside a path flag manufactures invalid
|
||||||
|
combinations (path without mode, mode without path) that then need pairing
|
||||||
|
rules and usage errors to defend. Presence-of-path has no invalid
|
||||||
|
combinations and nothing to defend.
|
||||||
|
|
||||||
|
Breaking change, named: `run --config <file>` today means seed-once; the same
|
||||||
|
syntax now means file authority — reconcile on every boot, UI config writes
|
||||||
|
rejected. For an operator who seeded once and then configured through the UI,
|
||||||
|
the first post-upgrade restart converges the DB to that old seed file,
|
||||||
|
deleting the UI edits. The upgrade doc's breaking-changes section leads with
|
||||||
|
this and gives the two exits (ruling 9): drop the flag, or re-export to the
|
||||||
|
file path first. `check --config` keeps its meaning exactly. Greenfield rules
|
||||||
|
apply — the seed-once interface does not survive for compatibility — but the
|
||||||
|
break is loud in the docs, never silent-by-omission.
|
||||||
|
|
||||||
|
A rename (`--managed-config`) that would make the old invocation fail loudly
|
||||||
|
was considered twice and declined: it trades a worse name and a permanent
|
||||||
|
asymmetry with `check --config` against a one-time hazard whose exposed
|
||||||
|
population is the pre-change install base of a project whose first release is
|
||||||
|
days old. The hazard is real and the docs lead with it; the interface does
|
||||||
|
not carry the scar. This is a judgment, recorded so it is revisited only with
|
||||||
|
new facts (a real install base would be one).
|
||||||
|
|
||||||
|
### 2. File mode fails closed, and *declarative* failure is exit 2
|
||||||
|
|
||||||
|
File mode contract: the file is the sole declarative source; the DB stays the
|
||||||
|
runtime substrate and the effective-config read path. Startup sequence,
|
||||||
|
replacing the `seedFromFile` call at app.zig:196:
|
||||||
|
|
||||||
|
1. `DataDir.open` → open config DB → `migrate` (unchanged).
|
||||||
|
2. Read the file (existing 4 MiB cap), ZON parse (arena, never freed — keep
|
||||||
|
the import.zig discipline), `validate.validate`.
|
||||||
|
3. Reconcile into the DB in one `BEGIN IMMEDIATE` transaction, `errdefer`
|
||||||
|
rollback (ruling 3).
|
||||||
|
4. `config_export.readConfig` (app.zig:206) → serve, unchanged from there on.
|
||||||
|
|
||||||
|
A missing, unreadable, or invalid file fails startup. Never fall back to the
|
||||||
|
DB: a fallback turns a deploy typo into a silently stale config.
|
||||||
|
|
||||||
|
Exit codes keep the 2am contract — exit 2 means "your config is wrong, run
|
||||||
|
`nxdns check`"; exit 1 means "the box is wrong". `faults.isConfigFault`
|
||||||
|
deliberately excludes `FileNotFound`/`AccessDenied` (faults.zig:107-108), and
|
||||||
|
that stays true in general. The file-mode loader is the seam, and it maps
|
||||||
|
**path-class open failures only**: `FileNotFound`, `AccessDenied`,
|
||||||
|
`PermissionDenied`, `NotDir`, `IsDir`, `SymLinkLoop`, `NameTooLong`,
|
||||||
|
`BadPathName` become `error.ManagedConfigUnreadable` (message includes the
|
||||||
|
path), which joins the `ConfigFault` set beside `ParseZon`/`ConfigTooLarge`.
|
||||||
|
Every other member of `ReadFileAllocError` — `SystemResources`,
|
||||||
|
`ProcessFdQuotaExceeded`, `SystemFdQuotaExceeded`, I/O errors, `OutOfMemory` —
|
||||||
|
propagates unmapped, exit 1: those are box faults a retry can clear, and once
|
||||||
|
ruling 9 adds `RestartPreventExitStatus=2 64`, mapping them to exit 2 would
|
||||||
|
stop the unit permanently on a transient fault. The mapping is an explicit
|
||||||
|
named error set in the loader, switched exhaustively with
|
||||||
|
`else => |other| return other`, so a std error added in a Zig bump defaults to
|
||||||
|
exit 1 rather than silently to exit 2 — the same closed-set discipline
|
||||||
|
faults.zig already documents. The mapping lives in **one shared helper** used
|
||||||
|
by both `run` and `check`; two copies would let the two grade the same
|
||||||
|
unreadable file differently, exactly the divergence faults.zig:1-8 exists to
|
||||||
|
abolish.
|
||||||
|
|
||||||
|
**Scope of the check/run agreement claim**: `check --config=<file>`
|
||||||
|
grades exactly the declarative faults `run` would hit — read (path-class),
|
||||||
|
parse, size, validate — through the same shared loader helper. Reconcile-time
|
||||||
|
faults (FK violations, `SQLITE_BUSY` from a restart race, disk full) are
|
||||||
|
runtime faults, exit 1, and structurally invisible to `check`, which needs no
|
||||||
|
DB. The acceptance encodes the scoped claim, not "check passing guarantees run
|
||||||
|
converges". The one *systematic* gap the red team found — a group removal
|
||||||
|
tripping the un-cascaded `clients.group_id` FK against observed rows `check`
|
||||||
|
cannot see — is eliminated in the engine itself (ruling 3's reassign rule),
|
||||||
|
not papered over in `check`.
|
||||||
|
|
||||||
|
Reconcile-time diagnostics include the SQLite error (`SQLITE_FULL` by name
|
||||||
|
when that is the cause) so a full SD card reads as "disk", not as a bare
|
||||||
|
exit 1. Diagnostics render to `r.err` and flush immediately, keeping the
|
||||||
|
current `seedFromFile` discipline (app.zig:137-178) — `serve` never returns,
|
||||||
|
so a buffered error line is a lost error line.
|
||||||
|
|
||||||
|
Because every boot revalidates the file, a latent file error is no longer a
|
||||||
|
first-boot-only hazard: a bad push that skips its restart handler detonates at
|
||||||
|
the next power blip. Two mitigations, both in ruling 9: the shipped unit gains
|
||||||
|
`RestartPreventExitStatus=2 64` (retrying a config fault every 2 s is pure
|
||||||
|
loop; the journal holds the diagnostics), and the deployment docs mandate
|
||||||
|
`nxdns check --config=<file>` as the pre-restart gate in any Ansible
|
||||||
|
handler.
|
||||||
|
|
||||||
|
Db mode: steps 2-3 are skipped entirely; the DB is truth exactly as today.
|
||||||
|
Bare `check` on a box with no `config.db` exits 2: `no config database at <path>`
|
||||||
|
plus the ruling-6 hint line — the deleted heuristic's "nothing to check"
|
||||||
|
branch (cli.zig:582-587) is replaced, not dropped.
|
||||||
|
|
||||||
|
### 3. Reconcile engine: replace declarative state, preserve runtime state by stable identity
|
||||||
|
|
||||||
|
New module `src/config/reconcile.zig`, replacing `applyToDb`'s wipe+reinsert.
|
||||||
|
The defect it exists to fix: today's import deletes and reinserts
|
||||||
|
`blocklist_sources` including runtime columns (checksum, `last_updated`,
|
||||||
|
counters — config_schema.zig:46-56), and compiled blocklists are keyed by
|
||||||
|
source row id (`<id>.list`/`<id>.wild`). A naive re-import every boot would
|
||||||
|
force a full re-download and recompile of every blocklist on every restart.
|
||||||
|
|
||||||
|
Contract, per table: match rows by identity key ⇒ UPDATE declarative columns
|
||||||
|
in place (row id survives); present in DB but absent from the file ⇒ DELETE;
|
||||||
|
present in file but not DB ⇒ INSERT. Never wipe. One transaction. The
|
||||||
|
`clients` table refines the match rule with promotion (below): an observed row
|
||||||
|
whose IP the file declares is matched and updated, never deleted.
|
||||||
|
|
||||||
|
**Identity matching is on canonical forms.** Import already canonicalizes
|
||||||
|
client IPs and prefixes before insert (import.zig:226-237; `FD00:0:0:0:0:0:0:1`
|
||||||
|
stores as `fd00::1`) and the schema documents the columns as canonical text.
|
||||||
|
The engine canonicalizes file values *before* matching; matching the raw file
|
||||||
|
string against the canonical column would churn ids under an unchanged
|
||||||
|
non-canonical file, violating ruling 5 in a way an exported-config test
|
||||||
|
(export emits canonical forms) cannot catch. The idempotence test includes a
|
||||||
|
non-canonical file.
|
||||||
|
|
||||||
|
**Writes only on difference.** A matched row whose declarative columns already
|
||||||
|
equal the file's values gets no UPDATE. This is stronger than byte-stability:
|
||||||
|
reconciling an unchanged file performs **zero writes**, so a no-op boot needs
|
||||||
|
no WAL headroom and a querylog-full SD card cannot brick a file-mode restart
|
||||||
|
that db mode would survive. Testable: the second reconcile returns an all-zero
|
||||||
|
summary and `sqlite3_total_changes` does not move.
|
||||||
|
|
||||||
|
| Table | Identity key | Declarative columns | Runtime-owned (preserved on match) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `blocklist_sources` | `url` | `name`, `enabled`, `is_suggested` | **`id`**, `checksum`, `last_updated`, `domain_count`, `wildcard_count`, `skipped_regex_count` |
|
||||||
|
| `groups` | `name` (`default` pinned to id 1, existing assert kept) | `safe_search` | `id` |
|
||||||
|
| `clients` | canonical `ip` | `name`, `group_id`, `hand_edited=1` | `first_seen`, `last_seen`; observed rows (`hand_edited=0`) are kept wholesale; an observed row whose IP is now declared in the file is **promoted in place** (UPDATE `name`, `group_id`, `hand_edited` 0→1 — `first_seen`/`last_seen` untouched, row id survives, counts as `updated`); observed rows whose group is deleted are **reassigned to the default group** (below) |
|
||||||
|
| `rules` | the full tuple `(group_id, pattern, kind, action)` — no natural key exists; the identity is a **multiset**, exact duplicate tuples pair off by count | (the tuple) | `id`, `created_at` (unmatched inserts stamp `now`) |
|
||||||
|
| `upstreams` / `client_prefixes` / `forward_zones` / `local_records` | `url` / canonical `prefix` / `zone` / `(name, rtype, value)` | everything else | `id` — preserved as a consequence of the idempotence invariant (ruling 5), **not** for FK stability: nothing references these ids by FK and id-based REST mutations are rejected in file mode |
|
||||||
|
| `group_sources` | `(group_id, source_id)`, resolved via the name/url maps | whole row | — |
|
||||||
|
| `settings` | key | value via `settings_repo.putSetting` upsert, on difference only | delete keys not produced by `toSettings`, **except** `web.password_hash`, which the reconciler owns directly (ruling 4) |
|
||||||
|
|
||||||
|
**Pass order (binding).** `delete_order` alone under-specifies the engine:
|
||||||
|
inserts need parents before children, and a group DELETE cascades through
|
||||||
|
`rules`, `client_prefixes`, `group_sources` (config_schema.zig:35, :60, :67),
|
||||||
|
which could silently undo child-table work and corrupt the diff counts if
|
||||||
|
deletes ran first or interleaved. The order is:
|
||||||
|
|
||||||
|
- **Phase A — upserts, parents first**: `groups`, `blocklist_sources`, then
|
||||||
|
the referrers (`clients`, `client_prefixes`, `rules`, `group_sources`,
|
||||||
|
`upstreams`, `local_records`, `forward_zones`, `settings`), with the
|
||||||
|
name→id / url→id maps built after the parent passes. Client promotion
|
||||||
|
happens here, in the `clients` pass.
|
||||||
|
- **Phase B — delete-absent, child first**, in `delete_order`
|
||||||
|
(config_schema.zig:99). Because every declarative child of a dying group is
|
||||||
|
itself absent from the file (validate guarantees file rules/prefixes
|
||||||
|
reference file groups), the child passes have already deleted and counted
|
||||||
|
them by the time the parent DELETE runs; the FK cascades become a safety
|
||||||
|
net, never the accountant.
|
||||||
|
- Immediately before the `groups` delete pass: observed clients
|
||||||
|
(`hand_edited=0`) whose `group_id` belongs to a dying group are reassigned
|
||||||
|
to the default group (id 1). `clients.group_id` has **no** ON DELETE clause
|
||||||
|
(config_schema.zig:26) — without this step, removing or renaming a group
|
||||||
|
that observed devices had been assigned to trips the FK mid-transaction:
|
||||||
|
exit 1, restart loop, and a `check` that said the file was fine. The
|
||||||
|
reassignment is the semantics we want anyway: the operator un-declared the
|
||||||
|
group, not the devices.
|
||||||
|
|
||||||
|
Consequences that make the fix complete: an unchanged URL keeps id **and**
|
||||||
|
checksum + counters + `last_updated` together, so `loadSource` never sees
|
||||||
|
`.never_fetched`, `needsRefresh` does not fire spuriously (preserving
|
||||||
|
`last_updated` matters — checksum alone is not enough), `sweepOrphans` never
|
||||||
|
orphans `<id>.list`/`<id>.wild`, and a restart costs zero downloads. A changed
|
||||||
|
URL is a new identity: new row, new id, fresh download — consistent with
|
||||||
|
artifacts keyed by id. **Accepted trade, stated**: the old row's compiled
|
||||||
|
`<old-id>.list`/`<old-id>.wild` are orphaned at commit and swept before the
|
||||||
|
new source's first fetch succeeds, so a URL edit whose new host is down leaves
|
||||||
|
that list unenforced until a fetch lands. Deferring the sweep until a
|
||||||
|
successor fetch would need a cross-artifact lifecycle for an event that is
|
||||||
|
rare, operator-initiated, and bounded by the scheduler's retry — not worth the
|
||||||
|
machinery on a household box. The REST `updateSource` keeps runtime columns
|
||||||
|
across a URL edit; in file mode that route is rejected (ruling 7), so the
|
||||||
|
divergence is unreachable; in db mode the reconciler only runs via explicit
|
||||||
|
`import`.
|
||||||
|
|
||||||
|
Related clock fix, same subsystem: `needsRefresh` is wall-clock arithmetic
|
||||||
|
(`now - last >= interval`, manager.zig:1201-1202) and the Pi has no RTC. A
|
||||||
|
fetch stamped while the clock was ahead (pre-NTP boot, restored image)
|
||||||
|
suspends refresh until real time catches the future timestamp — and this
|
||||||
|
design's idempotence would faithfully preserve the poison forever, having
|
||||||
|
deleted the wipe that used to be the accidental reset lever. The engine's
|
||||||
|
sibling fix: `needsRefresh` treats `last_updated > now` as refresh-due. One
|
||||||
|
comparison, with a test.
|
||||||
|
|
||||||
|
Client promotion in place makes the `saved_clients` lift/merge/restore
|
||||||
|
scaffolding (import.zig:268-361, including `merge_observed_timestamps_sql`)
|
||||||
|
unnecessary: that machinery exists only because the wipe destroyed
|
||||||
|
`first_seen`/`last_seen` and had to smuggle them across. With no wipe, the
|
||||||
|
semantics it encodes — observed history survives declaration — are a plain
|
||||||
|
UPDATE that never touches the timestamp columns. The scaffolding dies with
|
||||||
|
the wipe (Deletions); its tests' semantics move to the promotion tests.
|
||||||
|
|
||||||
|
**Ordering invariant**: reconcile commits before `manager.reload`
|
||||||
|
(app.zig:400) runs and before the scheduler's `sweepOrphans` can fire, so
|
||||||
|
preserved ids and checksums are visible to the filter layer before any
|
||||||
|
pruning. This holds by construction — reconcile completes inside `serve()`
|
||||||
|
before the manager exists — and is enforced *behaviorally*, not by a
|
||||||
|
statement-order unit test with nothing to grip: the restart-no-redownload
|
||||||
|
integration test (Tests, below) fails if anything between reconcile and
|
||||||
|
reload re-orders or wipes. No `serve()` restructuring is chartered for this.
|
||||||
|
|
||||||
|
The engine returns a summary (ruling 8's input and the cross-session
|
||||||
|
contract):
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const TableCounts = struct { inserted: u32, updated: u32, deleted: u32 };
|
||||||
|
pub const Summary = struct {
|
||||||
|
groups: TableCounts, sources: TableCounts, clients: TableCounts,
|
||||||
|
client_prefixes: TableCounts, rules: TableCounts, group_sources: TableCounts,
|
||||||
|
upstreams: TableCounts, local_records: TableCounts, forward_zones: TableCounts,
|
||||||
|
settings: TableCounts,
|
||||||
|
auth_transition: enum { none, enabled, disabled, rotated },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`updated` counts only rows actually written (writes-on-difference);
|
||||||
|
observed-client preservation counts nothing; promotion and reassignment count
|
||||||
|
as `updated` on `clients`. `deleted` on `clients` means exactly one thing: a
|
||||||
|
formerly declared (`hand_edited=1`) row absent from the file. An unchanged
|
||||||
|
file yields an all-zero summary.
|
||||||
|
|
||||||
|
New repo verbs carry the engine (`sources_repo.upsertByUrl`,
|
||||||
|
per-table `deleteWhereNotIn`-style helpers), not new call ordering. `applyToDb`
|
||||||
|
and its wipe loop are deleted; import.zig keeps its parse/validate/diagnostics
|
||||||
|
plumbing.
|
||||||
|
|
||||||
|
### 4. Password: the file overrides when it speaks, and only then
|
||||||
|
|
||||||
|
`model.Web` changes to `password: ?[]const u8 = null` and
|
||||||
|
`password_hash: ?[]const u8 = null`. The settings bridge splits its skip
|
||||||
|
policy by direction — `isSkipped` becomes two functions, because encode and
|
||||||
|
decode need different sets:
|
||||||
|
|
||||||
|
- `isEncodeSkipped` = {`web.password`, `web.password_hash`}: `toSettings`
|
||||||
|
stops emitting `web.password_hash` (the reconciler owns that settings row
|
||||||
|
directly) and continues to never emit `web.password`.
|
||||||
|
- `isDecodeSkipped` = {`web.password`} only: `fromSettings` **still loads**
|
||||||
|
`web.password_hash` from the settings table — the reconciler owning the
|
||||||
|
write does not mean the read path stops seeing it. Skipping it on decode
|
||||||
|
would leave `cfg.web.password_hash` null on every read path
|
||||||
|
(`config_export.readConfig` at export.zig:52, `mutations.loadConfig`), turn
|
||||||
|
`authEnabled` false, and silently disable auth in both modes.
|
||||||
|
- `decodeValue` gains an `.optional => try decodeValue(child, text)` arm
|
||||||
|
(model.zig:408-421 has none today; an unskipped optional field is currently
|
||||||
|
a `@compileError`). Absent key ⇒ default `null`; present ⇒ non-null.
|
||||||
|
|
||||||
|
`auth.authEnabled` becomes `(web.password_hash orelse "").len != 0`
|
||||||
|
(auth.zig:62-63); app.zig:475's `.live_hash = .init(...)` unwraps with
|
||||||
|
`orelse ""`. The cases:
|
||||||
|
|
||||||
|
- **Both set**: existing error (`PasswordAndHashBothSet`, import.zig:250,
|
||||||
|
validate.zig:395 — the check ports to `!= null` on both fields).
|
||||||
|
- **`password` present and empty**: rejected by `validate` with a new
|
||||||
|
diagnostic naming the remedy — `password_hash = ""` is how auth is disabled
|
||||||
|
declaratively. Without this rejection, `.password = ""` would hash the
|
||||||
|
empty string into a non-empty PHC (`authEnabled` true) while auth.zig:90
|
||||||
|
refuses every empty-password login: auth on, unreachable. A declarative
|
||||||
|
fault, exit 2, so ruling 2's check/run agreement holds.
|
||||||
|
- **`password` set (non-empty plaintext)**: verify against the stored
|
||||||
|
`web.password_hash` with argon2; on match keep the stored hash, on mismatch
|
||||||
|
or absent stored hash, hash fresh. The justification is ruling 5 alone:
|
||||||
|
hashing unconditionally generates a fresh salt per apply and breaks
|
||||||
|
byte-stability. It is **not** a cost saving — argon2 verification recomputes
|
||||||
|
the full function (same t=2, m=19 MiB) with the stored salt, so
|
||||||
|
verify-and-keep costs exactly what hashing costs. Do not "optimize" the
|
||||||
|
verify away with any cached-plaintext scheme; that would be a security bug.
|
||||||
|
- **`password_hash` set**: written verbatim. An explicit `password_hash = ""`
|
||||||
|
is the declarative way to disable auth (auth.zig's documented empty-hash
|
||||||
|
state).
|
||||||
|
- **Neither set**: the stored hash is untouched. This is the deliberate
|
||||||
|
carve-out from file-as-sole-truth, because the naive alternative is a trap
|
||||||
|
the red team walked straight into: `toSettings` today always emits
|
||||||
|
`web.password_hash` with default `""` (model.zig:534, :93), so an operator
|
||||||
|
who hand-trims the ugly PHC string out of an exported file — meaning "keep
|
||||||
|
the current password" — would silently reconcile `""` over the stored hash
|
||||||
|
and open the admin UI to the LAN (`authEnabled` is `len != 0`, auth.zig:63).
|
||||||
|
Silence must mean "keep", and disabling auth must require the explicit
|
||||||
|
empty string.
|
||||||
|
|
||||||
|
**Export's canonical form**: `password = null`, `password_hash = <stored
|
||||||
|
value, including "">`. A non-null `password` is never emitted — the current
|
||||||
|
`cfg.web.password = "";` at export.zig:71 ported literally would make every
|
||||||
|
export carry a present-empty password beside a stored hash, tripping
|
||||||
|
`PasswordAndHashBothSet` on re-import: export's own output failing its own
|
||||||
|
rule, breaking ruling 9's adoption walkthrough and ruling 5's round trip. The
|
||||||
|
comment at export.zig:67-70 and the header sample change with it. With
|
||||||
|
`password = null`, the empty-plaintext rejection above does not fire and the
|
||||||
|
"hash written verbatim" branch keeps idempotence.
|
||||||
|
|
||||||
|
Any auth transition (enabled/disabled/rotated) is reported in the summary and
|
||||||
|
printed by ruling 8 — an auth change is never a silent line item in a count.
|
||||||
|
|
||||||
|
### 5. Idempotence is the invariant
|
||||||
|
|
||||||
|
Reconciling the same file twice produces a byte-identical database — ids,
|
||||||
|
checksums, timestamps, `created_at`, password hash, the whole settings
|
||||||
|
table — **and** the second pass performs zero writes (all-zero summary,
|
||||||
|
`total_changes` unmoved). The unit test asserts
|
||||||
|
byte-stability via the `dump()` helper (import.zig:469), which is rewritten to
|
||||||
|
iterate an explicit all-tables list (`config_schema.table_names`, all ten
|
||||||
|
content-bearing tables) because its current driver, `content_tables`, is
|
||||||
|
deleted (ruling 6). Anything that churns under an unchanged file — including a
|
||||||
|
*non-canonical but equivalent* file — is a bug in the engine, by definition.
|
||||||
|
(This invariant is what forces ruling 3's rules-multiset, canonical matching,
|
||||||
|
and writes-on-difference, and ruling 4's password handling — the places a
|
||||||
|
naive design silently violates it. It is also half of why ruling 8 persists
|
||||||
|
no authority state at all.)
|
||||||
|
|
||||||
|
### 6. `import` becomes a thin wrapper over reconcile; the guard becomes diff-gated
|
||||||
|
|
||||||
|
`nxdns import` is db mode's one-shot apply and the restore tool, reimplemented
|
||||||
|
on the reconcile engine. Reconcile is non-destructive of *runtime* state, but
|
||||||
|
deletion of declarative rows absent from the file is still a first-class
|
||||||
|
outcome (ruling 3) — a mistaken `nxdns import ./wrong.zon` against a
|
||||||
|
configured DB would still remove every group, rule, upstream, and source not
|
||||||
|
in that file. So the guard is **retargeted, not deleted**: emptiness-gating
|
||||||
|
(`isEmpty`) becomes diff-gating.
|
||||||
|
|
||||||
|
- After the reconcile passes, still inside the same `BEGIN IMMEDIATE` —
|
||||||
|
preserving import.zig:199-203's deliberate check-inside-the-write-lock
|
||||||
|
property, no TOCTOU — if any table's `deleted != 0` and no override flag,
|
||||||
|
roll back and fail exit 2 with the per-table delete counts in the message.
|
||||||
|
Observed-client reassignment is not declarative data and never trips the
|
||||||
|
gate (and under ruling 3's promotion rule, observed rows are never deleted
|
||||||
|
by reconcile at all).
|
||||||
|
- The flag is `--allow-delete` (`Options.allow_delete`), the renamed
|
||||||
|
`--force`; the error is `error.DestructiveImport`, the renamed
|
||||||
|
`DatabaseNotEmpty`, exit-2-mapped where cli.zig:533 maps today. This is
|
||||||
|
strictly better than the emptiness guard: additive and edit-only re-imports
|
||||||
|
stop needing a flag at all, and the flag now names what it permits.
|
||||||
|
"Edit-only" means edits to declarative columns on a matched identity; an
|
||||||
|
edit that *changes an identity column* — a group name, a source or upstream
|
||||||
|
url, a prefix, a zone, a rule tuple, a local-record identity — is a delete
|
||||||
|
plus an insert to the engine (ruling 3) and needs the flag. cli.md states
|
||||||
|
the distinction.
|
||||||
|
- `import.isEmpty` and `content_tables` still die — the diff-gate needs no
|
||||||
|
table list.
|
||||||
|
|
||||||
|
`import` does not detect a file-managed DB, because nothing records one:
|
||||||
|
authority lives in the invocation (ruling 1) and the DB carries no marker
|
||||||
|
(ruling 8). On a box whose unit runs file mode, `import` behaves like any
|
||||||
|
other import — the diff-gate guards deletion, and the next boot's reconcile
|
||||||
|
converges the DB back to the file, its summary reporting what it corrected.
|
||||||
|
The docs own this story plainly: `import` is a **stop-first operation**, on a
|
||||||
|
file-mode box doubly so — against a running instance its effect is partial
|
||||||
|
(the runtime divergence below) and lasts only until the next restart. The
|
||||||
|
file-authority contract is stated with the same precision everywhere: the
|
||||||
|
file is the sole declarative source, **converged at every boot** — not a
|
||||||
|
lock on the database between boots. A detect-and-warn variant was
|
||||||
|
designed and deleted (ruling 8) — a per-invocation warning cannot *prevent*
|
||||||
|
db-side writes anyway, the CLI user is root on their own box, and the
|
||||||
|
asymmetry with the web layer's 403 is intentional: the web UI has
|
||||||
|
anonymous-ish LAN users, the CLI has the operator.
|
||||||
|
|
||||||
|
What a mid-run import against a *running* file-mode instance actually does —
|
||||||
|
documented, because the divergence is partial, not merely deferred:
|
||||||
|
`Manager.reload` re-reads `blocklist_sources`, `groups`, `group_sources`,
|
||||||
|
`rules`, `clients`, `client_prefixes` from the DB at runtime
|
||||||
|
(manager.zig:405-489) and the scheduler runs `sweepOrphans` before every pass
|
||||||
|
(manager.zig:1095, :1115), so filtering follows the imported rows on the next
|
||||||
|
reload and can unlink the compiled `<id>.list`/`<id>.wild` of sources the
|
||||||
|
import deleted — while upstreams, listeners, and settings stay at boot
|
||||||
|
values, and the web UI shows the imported state under the file-authority
|
||||||
|
banner. The next restart's reconcile re-inserts deleted sources from the file
|
||||||
|
with new ids: a full re-download, the exact cost this design exists to
|
||||||
|
prevent. That is why the docs name the restart requirement and the
|
||||||
|
re-download cost. The settings envelope's `reconciled_at` cannot see a CLI
|
||||||
|
import — ruling 7's weakened claim covers exactly this.
|
||||||
|
|
||||||
|
The startup-vs-import race needs no code: both paths take `BEGIN IMMEDIATE`
|
||||||
|
under the 5 s busy timeout (db.zig:252), so the outcome is ordering, not
|
||||||
|
corruption — an import racing a restart may be reverted by the reconcile that
|
||||||
|
wins the lock second. Documented, not engineered around.
|
||||||
|
|
||||||
|
First-run story in db mode, after bootstrap dies: a fresh empty DB fails
|
||||||
|
validation naturally (`NoUsableUpstreams`, exit 2). The remediation hint —
|
||||||
|
one fixed line naming `nxdns import` and `run --config` — is owned by
|
||||||
|
**cli.zig's db-source fault renderer** (one arm, beside the exit-code mapping;
|
||||||
|
Zig errors carry no text and validate.zig must stay mode-blind), and the same
|
||||||
|
line serves bare `check` with no DB (ruling 2). One location, R2's charter.
|
||||||
|
|
||||||
|
`export` is unchanged in role: the diagnostic/capture tool in both modes, and
|
||||||
|
the file-mode adoption tool (its canonical password form changes per
|
||||||
|
ruling 4).
|
||||||
|
|
||||||
|
### 7. Web layer: policy as data, rejected after auth, plain 403
|
||||||
|
|
||||||
|
`WebState` gains `authority: union(enum) { database, managed_file: []const u8 }`
|
||||||
|
and `reconciled_at: ?i64`, built where `WebState` is assembled (app.zig:468).
|
||||||
|
The `managed_file` path slice is owned by `serve`'s arena, which outlives
|
||||||
|
`WebState` — R2 provides it, R3 consumes it, neither copies. `reconciled_at`
|
||||||
|
is stamped by `serve` immediately after the reconcile commits — same process,
|
||||||
|
same frame, a local — and is `null` in db mode, which never reconciles.
|
||||||
|
|
||||||
|
`RouteInfo` gains `policy: enum { read, config_write, runtime_action }` beside
|
||||||
|
`auth` and `rate_limit` — policy-as-data, matching the table's existing style.
|
||||||
|
No default value: all 56 route entries state their class explicitly, and
|
||||||
|
router.zig's `test_table` (:198) gains the field too. Classification is
|
||||||
|
per-route, not per-prefix: `POST /api/blocklists/update` is a
|
||||||
|
`runtime_action`; its CRUD siblings are `config_write`.
|
||||||
|
|
||||||
|
- Config writes, rejected in file mode: all group / blocklist / rule /
|
||||||
|
local-record / forward-zone / upstream / client-prefix mutations,
|
||||||
|
`PUT /api/settings` (password changes go through the file; its
|
||||||
|
`live_hash.installAndRevoke` side effect never fires in file mode), and
|
||||||
|
client PUT — naming or regrouping an observed client is declarative drift
|
||||||
|
(open question 1).
|
||||||
|
- Client DELETE is a **runtime action**: deleting an observed
|
||||||
|
(`hand_edited=0`) row discards runtime state the file never declared —
|
||||||
|
without this, a mis-identified or departed device's row is immortal in file
|
||||||
|
mode, since the file can only promote IPs, never remove them. Deleting a
|
||||||
|
*declared* client contradicts the file: the handler answers the same 403
|
||||||
|
envelope. This is the one policy decision that needs a row read; it lives
|
||||||
|
in the client handler, not the router. (After ruling 3's promotion, a
|
||||||
|
declared IP's row *is* declared — DELETE answers 403, the intended
|
||||||
|
reading.)
|
||||||
|
- Runtime actions, always live: pause, blocklist refresh, cert reload,
|
||||||
|
login/logout.
|
||||||
|
|
||||||
|
Enforcement lives in `router.dispatch` **after** `check_auth`, before the
|
||||||
|
handler — match → rate limit → auth → policy. Pre-auth rejection would leak
|
||||||
|
route existence; the codebase answers 401 first and this design keeps that.
|
||||||
|
|
||||||
|
Rejection is **403**, body the existing single-field envelope:
|
||||||
|
`{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}`.
|
||||||
|
Not 409 — that status already means constraint conflict four ways in
|
||||||
|
openapi.yaml — and **no `code` field**: 403 is unused today, so the status
|
||||||
|
alone is machine-readable; the UI learns authority declaratively from
|
||||||
|
`GET /api/settings`, not by probing errors; and the single-property `Error`
|
||||||
|
schema, golden contract samples, and `api.ts` stay untouched. An optional field
|
||||||
|
with no consumer is machinery, not a contract.
|
||||||
|
|
||||||
|
The envelope must survive its own path: `respondError` today builds into a
|
||||||
|
fixed 512-byte buffer and **silently downgrades to `text/plain`** on overflow
|
||||||
|
(http_util.zig:315-327) — a long managed-file path (nested bind mounts) would
|
||||||
|
demote the documented JSON envelope. Fix at the root: `respondError` gets the
|
||||||
|
arena treatment `respondJson` already uses (`Writer.Allocating` over
|
||||||
|
`request.arena`, :337-339) and the `respondPlain` silent-downgrade path is
|
||||||
|
deleted — which fixes every long error message, not just this one.
|
||||||
|
`src/web/http_util.zig` joins R3's ownership.
|
||||||
|
|
||||||
|
Authority discovery: the `GET /api/settings` envelope gains
|
||||||
|
`authority: {mode, path, reconciled_at}` — an authenticated route, so the
|
||||||
|
filesystem path never leaks through open `/api/version`/`/api/health`. All
|
||||||
|
three come from `WebState` (live truth); `path` is present in file mode only
|
||||||
|
and `reconciled_at` is nullable — `null` in db mode. Its meaning is exactly
|
||||||
|
"**this process loaded the file at T**" — restart-pending detection: an mtime
|
||||||
|
newer than `reconciled_at` means the running process has not loaded the
|
||||||
|
current file. The comparison is one-directional and non-authoritative — a
|
||||||
|
stepped clock (pre-NTP boot stamping the future, ruling 3's clock fix
|
||||||
|
territory) or a preserved mtime (`git checkout`, `rsync -a`) can make a newer
|
||||||
|
file look older, and the DB can move without either timestamp moving
|
||||||
|
(ruling 6's `import`, this ruling's `runtime_action` routes). It does not
|
||||||
|
answer "is the file what the server uses"; answering that would take content
|
||||||
|
hashing, which the anti-requirements refuse. The UI renders a read-only
|
||||||
|
banner (RestartBanner slot precedent) and disables mutation controls, with
|
||||||
|
the 403 as backstop.
|
||||||
|
|
||||||
|
### 8. The operator can see what happened
|
||||||
|
|
||||||
|
- `logStartup` (after `logging.install`) logs the authority:
|
||||||
|
`authority: database` / `authority: file (/etc/nxdns/config.zon)`.
|
||||||
|
- In file mode, the reconcile summary (ruling 3's `Summary`) prints through
|
||||||
|
the Runner at startup, matching `seedFromFile`'s existing output discipline:
|
||||||
|
per-table inserted/updated/deleted counts, the changed settings **keys**
|
||||||
|
(never values), and the auth transition when there is one. It is the answer
|
||||||
|
to "what did that restart change" without opening sqlite.
|
||||||
|
- **Authority is never persisted.** The DB carries no record of which mode
|
||||||
|
wrote it: authority lives in the invocation (ruling 1), per-process state
|
||||||
|
(`WebState.authority`, `reconciled_at`) serves the API (ruling 7), and the
|
||||||
|
journal holds the history. A persisted marker was designed twice and
|
||||||
|
deleted twice. A per-boot `reconciled_at` settings row breaks ruling 5
|
||||||
|
outright — `dump()` iterates the settings table, so any row rewritten per
|
||||||
|
reconcile forfeits byte-identity, and `putSetting` moves `total_changes`,
|
||||||
|
forfeiting zero-writes. The write-on-difference `authority.mode`/`path`
|
||||||
|
variant survived idempotence but cost a reserved key namespace, a
|
||||||
|
`fromSettings` decode filter, and a reconciler sweep exemption — three
|
||||||
|
mechanisms whose only consumer was one `import` warning (ruling 6). State
|
||||||
|
that exists to power a courtesy message is not worth a namespace. The
|
||||||
|
settings sweep's exemption list is therefore exactly one key:
|
||||||
|
`web.password_hash` (ruling 4).
|
||||||
|
|
||||||
|
No `std.log.err` in any new code, per the standing spec rule for new code
|
||||||
|
(the pre-existing calls in db.zig/tls_server.zig are out of scope).
|
||||||
|
|
||||||
|
### 9. Deployment and migration
|
||||||
|
|
||||||
|
- **systemd**: the shipped unit stays flagless (db default) and gains two
|
||||||
|
lines. `RestartPreventExitStatus=2 64` — a config fault or usage error must
|
||||||
|
not restart-loop every 2 s until StartLimitBurst; the journal holds the
|
||||||
|
diagnostics and the fix is a file edit, not a retry. (Correct in db mode
|
||||||
|
too: exit 2 means the config is wrong in either mode. And it is why
|
||||||
|
ruling 2 keeps exit 2 to *declarative* faults only — a transient box fault
|
||||||
|
mapped to exit 2 would stop the unit permanently.) And
|
||||||
|
`ReadOnlyPaths=/etc/nxdns` — `ConfigurationDirectory=nxdns` makes systemd
|
||||||
|
create the directory owned by the service user, so without this line the
|
||||||
|
source-of-truth file is writable by the very process whose mutation routes
|
||||||
|
file mode exists to disable; nxdns never writes `/etc/nxdns` in either mode,
|
||||||
|
so the base unit ships the enforcement rather than recommending it. Docs
|
||||||
|
show a drop-in for file mode: `ExecStart=` reset plus
|
||||||
|
`--config=/etc/nxdns/config.zon`.
|
||||||
|
- **Ansible / config-management**: the docs' deployment guidance mandates
|
||||||
|
`nxdns check --config=<file>` as the handler precondition — validate
|
||||||
|
the pushed file *before* restarting, so a typo is a failed deploy at noon,
|
||||||
|
not a dead resolver at the next 3am power blip.
|
||||||
|
- **docker**: compose ships file mode as its example —
|
||||||
|
`command: ["run", "--config=/etc/nxdns/config.zon"]` (resolving old open
|
||||||
|
question 4; the `:ro` mount at compose.yaml:15 already suggests it). This keeps the
|
||||||
|
fresh-install and volume-loss stories working after bootstrap dies: a
|
||||||
|
recreated `nxdns-data` volume reconciles from the mounted file on next
|
||||||
|
start, which is *better* than the old seed-once self-heal. The binary's
|
||||||
|
default stays db. The seed-once comment (compose.yaml:9-12) is rewritten.
|
||||||
|
For db-mode-in-docker, the docs show the recovery one-liner
|
||||||
|
(`docker compose run --rm nxdns import /etc/nxdns/config.zon` — the file is
|
||||||
|
positional, `ImportArgs.file` at cli.zig:61, plus `--allow-delete` when the
|
||||||
|
diff deletes) — without it, `restart: unless-stopped` plus exit 2 is an
|
||||||
|
infinite crash loop (docker has no start limit) with no documented way out.
|
||||||
|
- **Adopt file mode** on a UI-configured box: **stop the service first**, then
|
||||||
|
`nxdns export --out /etc/nxdns/config.zon` →
|
||||||
|
`nxdns check --config=/etc/nxdns/config.zon` → add the flag → start.
|
||||||
|
Stop-first is load-bearing twice: `export` opens the DB immutable and
|
||||||
|
refuses with `WalPending` against a live instance's steady-state WAL
|
||||||
|
(db.zig:222-226, cli.md:121), and any UI edit landing between a live export
|
||||||
|
and the restart would be silently reverted by the first reconcile. Stopped,
|
||||||
|
the first reconcile's summary is all-zero and writes nothing — blocklist
|
||||||
|
state, compiled files, and client history all survive. Every unchanged boot
|
||||||
|
after it writes nothing either.
|
||||||
|
- **Leave file mode**: drop the flag (remove the drop-in), restart. The DB
|
||||||
|
already holds the last reconciled state; nothing else needed.
|
||||||
|
- **Binary downgrade from file mode**: no unit edit is needed — the old
|
||||||
|
binary accepts `run --config` with seed-once semantics, and against the
|
||||||
|
already-configured DB it ignores the file and serves the last-reconciled
|
||||||
|
state. The rollback note states the consequence: file edits stop applying
|
||||||
|
until the binary is upgraded again.
|
||||||
|
- **Restore from backup**: db mode is stop → restore `config.db` → start,
|
||||||
|
with the WAL caveat db.zig itself documents: take backups only from a
|
||||||
|
stopped instance (or use `export`), and on restore delete any stale
|
||||||
|
`config.db-wal`/`config.db-shm` beside the target — a mismatched WAL is
|
||||||
|
silently discarded by SQLite, which turns "restore" into "lose the tail".
|
||||||
|
Restoring an *exported file* into a populated DB via `import` is exactly
|
||||||
|
the deleting case: back-up-and-restore.md documents `--allow-delete` there
|
||||||
|
(ruling 6) — the flag choreography is rewritten, not removed. In file mode
|
||||||
|
`config.db` is not the config backup — the file is; restore = redeploy the
|
||||||
|
file. The query-log DB restores independently in both modes.
|
||||||
|
|
||||||
|
Migration honesty, replacing the earlier blanket claim: a db-mode install
|
||||||
|
that never passed `--config` needs nothing. Anyone whose unit, wrapper, or
|
||||||
|
compose `command` carries `run --config` (the documented seed-once invocation
|
||||||
|
in first-run.md and three how-to labs) gets file authority at the first
|
||||||
|
post-upgrade start — the seed file becomes the config, and UI edits made
|
||||||
|
since seeding are deleted by the first reconcile. The upgrade doc's
|
||||||
|
breaking-changes section leads with this and gives the two exits: drop the
|
||||||
|
flag to keep the DB, or re-export to the file path first to adopt file mode
|
||||||
|
cleanly (the walkthrough above). `check --config` keeps its meaning. Docker
|
||||||
|
db-mode fresh installs must use the new compose or run `import` once. No
|
||||||
|
schema migration.
|
||||||
|
|
||||||
|
### 10. Documentation is part of the change
|
||||||
|
|
||||||
|
`explanation/configuration-model.md` is rewritten around the two-mode model
|
||||||
|
(its "why the database wins" argument becomes mode-scoped, not superseded).
|
||||||
|
Seed-once claims are corrected in: the first-run tutorial, install-with-systemd,
|
||||||
|
install-with-docker, upgrade (breaking-changes + rollback sections,
|
||||||
|
ruling 9), back-up-and-restore (new restore semantics, WAL sidecars,
|
||||||
|
`--allow-delete`), set-up-admin-authentication (the ruling 4
|
||||||
|
absence/empty-string rule), troubleshoot, cli.md
|
||||||
|
(`run`/`check`/`import`/`export`, `--allow-delete`), configuration.md
|
||||||
|
(optional password fields), files-and-directories.md, api.md (settings
|
||||||
|
envelope, 403, per-route rejectability), the compose.yaml comment,
|
||||||
|
README/INSTALL.
|
||||||
|
|
||||||
|
## Deletions (complete list)
|
||||||
|
|
||||||
|
`config/bootstrap.zig` (+2 tests, S7 cases 15-18, + app.zig import/call sites
|
||||||
|
:35/:142/:146 and tests :1124/:1237) · `import.isEmpty` (+6 tests, + the
|
||||||
|
storage_integration_test.zig call sites :763/:795/:894, + the clients_repo.zig
|
||||||
|
doc comments :6/:129) · `config_schema.content_tables` (+its test; `dump()`
|
||||||
|
re-pointed to the new `table_names` list) · `app.seedFromFile` (+its app.zig
|
||||||
|
tests) · `CheckArgs.config_explicit` · the `checkImpl` source heuristic ·
|
||||||
|
`applyToDb`'s wipe loop, including the `saved_clients` lift/merge/restore
|
||||||
|
scaffolding (import.zig:268-361 — superseded by ruling 3's
|
||||||
|
promotion-in-place) · `toSettings`'s `web.password_hash` emission (+its
|
||||||
|
key-list test row; the model.zig:665-670 "skipped in both directions" test is
|
||||||
|
rewritten for the encode/decode split) · `http_util.respondPlain`'s
|
||||||
|
silent-downgrade path (`respondError` rebuilt on the request arena).
|
||||||
|
|
||||||
|
Renamed, not deleted (ruling 6): `Options.force` → `Options.allow_delete`
|
||||||
|
(`--force` → `--allow-delete`), `error.DatabaseNotEmpty` →
|
||||||
|
`error.DestructiveImport` (cli.zig:533 arm retargeted, faults.zig exclusion
|
||||||
|
and tests follow the name).
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
R1 lands first and R2 rewires onto it and carries every deletion — they are
|
||||||
|
ordered, not parallel (the old plan had R1 deleting `bootstrap.zig` out from
|
||||||
|
under R2-owned app.zig call sites). R1 is **additive engine plus one
|
||||||
|
coordinated model change**, not purely additive: the `model.Web` optional
|
||||||
|
fields force same-session edits at every site that reads them, or the tree
|
||||||
|
stops building. R3 and R4 run parallel to both; their interfaces
|
||||||
|
(`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
|
||||||
|
`RouteInfo.policy`) are fixed here.
|
||||||
|
|
||||||
|
### Session R1: reconcile engine + the coordinated model change
|
||||||
|
|
||||||
|
Owns `src/config/reconcile.zig` (engine + `Summary` as specified), the new
|
||||||
|
repo verbs in `src/storage/repositories/`, the `needsRefresh` clock clamp in
|
||||||
|
`src/filter/manager.zig`, registration in `src/tests.zig`. Rulings 3, 4, 5.
|
||||||
|
|
||||||
|
Owns `src/config/model.zig` whole: the optional `Web.password`/`password_hash`
|
||||||
|
fields, the `isEncodeSkipped`/`isDecodeSkipped` split, `decodeValue`'s
|
||||||
|
`.optional` arm, and the bridge tests. And the sites the
|
||||||
|
optional fields break, which no session previously owned: `validate.zig`
|
||||||
|
(:395 both-set port, the new empty-password rejection, test :1966-1968),
|
||||||
|
`auth.zig` (`authEnabled` orelse), `export.zig` (canonical `password = null`,
|
||||||
|
comment and header sample), `src/web/handlers/settings.zig` (`newPassword`'s
|
||||||
|
`orelse` chain at :184-186, and `FieldType` collapsing `?T` so `Partial`'s
|
||||||
|
`?FieldType(...)` at :114 does not generate a double optional — landing this
|
||||||
|
in R1 keeps R3's `web/` work unblocked). R1 also makes the one-line
|
||||||
|
`orelse ""` edit at app.zig:475 so the tree builds; app.zig otherwise stays
|
||||||
|
R2's, and R2 absorbs that line into its startup rewire.
|
||||||
|
|
||||||
|
### Session R2: CLI + startup + deletions
|
||||||
|
|
||||||
|
Owns `src/cli.zig`, `src/app.zig`, `src/config/faults.zig`,
|
||||||
|
`src/config/import.zig` (thin-wrapper rewrite, diff-gate, `--allow-delete`),
|
||||||
|
the shared loader-fault mapping helper (ruling 2, used by `run` and `check`),
|
||||||
|
and the entire Deletions list plus the renames. Rulings 1, 2, 6, 8. Threads authority and
|
||||||
|
`reconciled_at` into `WebState` (arena-owned path) but does not touch the
|
||||||
|
router. Owns the cli.zig hint-line renderer arm.
|
||||||
|
|
||||||
|
### Session R3: web enforcement + UI
|
||||||
|
|
||||||
|
Owns `src/web/router.zig` (dispatch policy step, `test_table` gains the
|
||||||
|
policy column), `src/web/routes.zig` (all 56 entries state `policy`
|
||||||
|
explicitly — no default), `src/web/http_util.zig` (`respondError` arena
|
||||||
|
rewrite, `respondPlain` downgrade deletion), `src/web/openapi.yaml`, the
|
||||||
|
client-DELETE observed/declared branch in the clients handler, `web/`
|
||||||
|
frontend (banner, disabled controls, settings envelope with nullable
|
||||||
|
`reconciled_at`). Ruling 7. Consumes `WebState.authority`/`reconciled_at` as
|
||||||
|
fixed above.
|
||||||
|
|
||||||
|
### Session R4: deployment + docs
|
||||||
|
|
||||||
|
Owns `deploy/**`, `docs/**`, `README.md`, `INSTALL`, compose file + comment,
|
||||||
|
the systemd unit's two new lines. Rulings 9, 10.
|
||||||
|
|
||||||
|
### Orchestrator
|
||||||
|
|
||||||
|
This spec, R1→R2 sequencing, cross-session integration, the drift-guard
|
||||||
|
regenerations that span sessions (contract samples, openapi route counts,
|
||||||
|
api.md/cli.md rows), and the `-Dlive` acceptance run.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Reconcile unit tests** (R1, `:memory:` + migrate): idempotence via the
|
||||||
|
rewritten `dump()` byte-stability across all tables after applying the same
|
||||||
|
file twice — including a file carrying a plaintext password **and a
|
||||||
|
non-canonical but equivalent file** (`FD00::1`-style) — plus the zero-writes
|
||||||
|
assertion (all-zero `Summary`, `total_changes` unmoved) on the second pass;
|
||||||
|
per-table preservation via `sources_repo.SourceRow` after seeding stats with
|
||||||
|
`updateSourceStats`; URL change ⇒ new id; source removal; observed-client
|
||||||
|
survival, **promote-on-declare (asserting `first_seen`/`last_seen` survive
|
||||||
|
the promotion and the row id is stable)**, and **reassign-to-default when
|
||||||
|
their group is removed or renamed** (the un-cascaded FK case);
|
||||||
|
`safe_search` edit on an existing group converges; rules `created_at`
|
||||||
|
stability including duplicate tuples; password verify-keeps-hash,
|
||||||
|
mismatch-rehashes, absent-keeps-stored, explicit-empty-disables; rollback
|
||||||
|
on mid-tx failure; default-group id 1 pin; `needsRefresh`
|
||||||
|
future-`last_updated` clamp.
|
||||||
|
- **Model/bridge tests** (R1, model.zig test neighbourhood at :659):
|
||||||
|
`decodeValue` on an optional field (absent ⇒
|
||||||
|
null, present ⇒ value); `web.password_hash` decodes from settings but is
|
||||||
|
not encoded (the encode/decode split); `validate` rejects present-and-empty
|
||||||
|
`password` with the `password_hash = ""` remedy in the diagnostic; export ⇒
|
||||||
|
import round trip with the canonical `password = null` form.
|
||||||
|
- **Import gate tests** (R2): a file whose diff deletes rows, without
|
||||||
|
`--allow-delete` ⇒ rollback, exit 2, per-table delete counts in the
|
||||||
|
message; with the flag ⇒ applied; additive/edit-only import needs no flag.
|
||||||
|
- **Loader-fault mapping** (R2, on the shared helper directly): each
|
||||||
|
path-class open error ⇒ `ManagedConfigUnreadable`; a non-path member of the
|
||||||
|
set propagates unmapped.
|
||||||
|
- **Restart-no-redownload** in filter_integration_test.zig: reconcile, then a
|
||||||
|
`Manager` restart reuses `<id>.list`/`<id>.wild` with no refetch (fixtures
|
||||||
|
already assert reuse by id) — this is also the behavioral enforcement of
|
||||||
|
ruling 3's ordering invariant. Plus **one `-Dlive` run of the real download
|
||||||
|
path** — hermetic suites have hidden a process-killing bug on the real
|
||||||
|
network path before (MEMORY), so the acceptance includes the real path once.
|
||||||
|
- **Router**: policy classification unit tests via `test_table`/`matchPath`,
|
||||||
|
no sockets; the contract table in web_integration_test.zig gains a `policy`
|
||||||
|
column and the existing 1:1 coverage assertion widens so classification
|
||||||
|
cannot drift; one socketed test per class in file mode (config write → 403,
|
||||||
|
runtime action → 2xx, read → 200) plus observed-vs-declared client DELETE,
|
||||||
|
via `EnvOptions` authority; `respondError` with a message longer than the
|
||||||
|
old 512-byte buffer stays `application/json`. Route count stays 56 — no new
|
||||||
|
routes.
|
||||||
|
- **CLI**: `parseArgs` tests: `run` without `--config` selects db authority;
|
||||||
|
`run --config=<path>` selects file authority and the path lands in the run
|
||||||
|
args; `--allow-delete` parsing; `usage_text` test; exit-2 for a missing
|
||||||
|
managed file and for bare `check` with no DB (hint line present) — via the
|
||||||
|
`Captured` runner (in-process; remember the buffered-writer caveat — flush
|
||||||
|
through a real `File.Writer` where the test asserts delivery).
|
||||||
|
- **Drift guards knowingly tripped and regenerated**: openapi.yaml (settings
|
||||||
|
envelope, 403 responses), golden contract samples (`-Dcontract-samples-out`),
|
||||||
|
api.md rows, cli.md sections, docs_drift_test, `toSettings` key-list test.
|
||||||
|
|
||||||
|
## Acceptance (design complete when implemented)
|
||||||
|
|
||||||
|
- [ ] `nxdns run --config=<file>` on a DB with fetched blocklists (any boot
|
||||||
|
after adoption): restart performs zero downloads **and zero DB writes**;
|
||||||
|
source ids, checksums, `last_updated`, and compiled
|
||||||
|
`<id>.list`/`<id>.wild` files are identical before and after. Proven
|
||||||
|
once with `-Dlive` against a real source.
|
||||||
|
- [ ] Reconciling an unchanged exported config twice yields a byte-identical
|
||||||
|
`dump()` and an all-zero summary — including with a plaintext
|
||||||
|
`password` in the file, and with non-canonical addresses.
|
||||||
|
- [ ] Removing (and renaming) a group that observed clients were assigned to
|
||||||
|
converges: clients land in the default group, no FK error, counts
|
||||||
|
reported. Declaring an observed client's IP promotes the row in place:
|
||||||
|
`first_seen`/`last_seen` and row id survive, counted as `updated`.
|
||||||
|
- [ ] A file with neither `password` nor `password_hash` leaves the stored
|
||||||
|
hash — and auth — intact; `password_hash = ""` disables auth and the
|
||||||
|
startup summary says so; a present-but-empty `password` is refused at
|
||||||
|
validate with a diagnostic naming `password_hash = ""` as the disable
|
||||||
|
path.
|
||||||
|
- [ ] `run --config=<file>` with a missing file exits 2 with the path in
|
||||||
|
the message; with an invalid file exits 2 with diagnostics; never serves
|
||||||
|
from the DB. An open failure outside the path class (fd exhaustion, I/O
|
||||||
|
error) exits 1, not 2. `check --config=<file>` agrees with `run`
|
||||||
|
on every parse/validate/path fault via the shared helper (the scoped
|
||||||
|
claim of ruling 2).
|
||||||
|
- [ ] A config file present at `/etc/nxdns/config.zon` with no `--config`
|
||||||
|
flag changes nothing: bare `run` serves the DB and never reads the
|
||||||
|
file.
|
||||||
|
- [ ] `nxdns import` whose diff would delete rows fails exit 2 without
|
||||||
|
`--allow-delete`, printing per-table delete counts, and rolls back;
|
||||||
|
with the flag it applies; an additive import needs no flag.
|
||||||
|
- [ ] Fresh empty DB in db mode exits 2 (`NoUsableUpstreams`) with the hint
|
||||||
|
line; bare `check` with no `config.db` exits 2 with the
|
||||||
|
same hint; neither restart-loops under the shipped unit
|
||||||
|
(`RestartPreventExitStatus=2 64`).
|
||||||
|
- [ ] The shipped compose file boots a fresh container (empty volume, mounted
|
||||||
|
config.zon) into file mode successfully; the db-mode import recovery
|
||||||
|
one-liner is documented and works.
|
||||||
|
- [ ] In file mode: every `config_write` route answers 403 with the
|
||||||
|
single-field error envelope — `application/json` even when the managed
|
||||||
|
path is long; every `runtime_action` and `read` route behaves as in db
|
||||||
|
mode; DELETE of an observed client succeeds, of a declared client
|
||||||
|
answers 403; unauthenticated requests to protected routes still answer
|
||||||
|
401, not 403.
|
||||||
|
- [ ] `GET /api/settings` reports `authority` with `reconciled_at` (null in
|
||||||
|
db mode); the UI shows the read-only banner and disables mutation
|
||||||
|
controls in file mode.
|
||||||
|
- [ ] `rg -n 'import\.isEmpty|content_tables|config/bootstrap|seedFromFile|config_explicit' src/`
|
||||||
|
returns nothing (historical specs exempt; pattern chosen so
|
||||||
|
fetcher.zig's `host.isEmpty()` and validate.zig's "bootstrap problem"
|
||||||
|
prose cannot false-positive).
|
||||||
|
- [ ] Adopt-file-mode walkthrough (stop → `export` → `check --config` → add
|
||||||
|
the flag → start) run end to end on a UI-configured instance: the first
|
||||||
|
reconcile summary is all-zero and writes nothing, as does every
|
||||||
|
unchanged boot after it; leave-file-mode (drop the flag, restart)
|
||||||
|
serves identically; `export` against the *running* instance refuses
|
||||||
|
with the WalPending message, as documented.
|
||||||
|
- [ ] All existing gates pass; tripped drift guards are regenerated, not
|
||||||
|
suppressed.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No file watcher, no inotify, no SIGHUP reload — restart is the reload.
|
||||||
|
- No UI write-back to the file, no partial/merge authority, no per-table
|
||||||
|
hybrid modes, no multi-file config.
|
||||||
|
- No persisted authority state, no config hashing, no content-based change
|
||||||
|
detection — authority lives in the invocation; last-load time is
|
||||||
|
per-process state in the settings envelope, honest about what it can and
|
||||||
|
cannot answer (ruling 7).
|
||||||
|
- No deferred deletion of a replaced source's compiled artifacts — the URL-edit
|
||||||
|
gap is an accepted trade (ruling 3), bounded by the scheduler retry.
|
||||||
|
- No etag/conditional GET for blocklist fetches.
|
||||||
|
- No `code` field or richer error envelope — the status is the machine
|
||||||
|
contract.
|
||||||
|
- No rule-identity schema change (no synthetic rule key column).
|
||||||
|
- No preservation of rule `created_at` across pattern edits — an edited rule
|
||||||
|
is a new rule.
|
||||||
|
- No CLI-side blocking or detection of `import` against a file-mode box's
|
||||||
|
DB — the diff-gate guards deletion; the next boot converges and its
|
||||||
|
summary reports what it corrected.
|
||||||
|
- No fallback from file mode to db mode under any failure.
|
||||||
|
|
||||||
|
## Resolved defaults (were open questions; the user may overrule before R3/R4)
|
||||||
|
|
||||||
|
1. **UI naming and regrouping of observed (`hand_edited=0`) clients in file
|
||||||
|
mode: rejected as declarative drift** (client PUT stays `config_write`,
|
||||||
|
ruling 7). The household user adds the client to the file instead. The
|
||||||
|
alternative — carving naming/grouping out as a runtime action —
|
||||||
|
reintroduces two-way merge for one table, which the anti-requirements
|
||||||
|
refuse. Affects R3.
|
||||||
|
2. **`export` output is byte-identical in both modes** — no file-mode
|
||||||
|
annotation. An annotation would make export → file → adopt produce a
|
||||||
|
different file than the one checked, for a label the operator already has
|
||||||
|
in the unit file. Affects R4's round-trip docs.
|
||||||
|
3. **File mode ships as a documented systemd drop-in**, not a second
|
||||||
|
commented `ExecStart` in the packaged unit. The packaged unit stays
|
||||||
|
flagless and correct by itself; a commented alternative line in a unit
|
||||||
|
file is a doc pretending to be config. Affects R4.
|
||||||
|
|
||||||
|
## Cross-validation findings rejected
|
||||||
|
|
||||||
|
Round 1: none — all twelve findings (F1-F12) were confirmed against the repo
|
||||||
|
and are folded into the rulings above. The F1 remedy has since been
|
||||||
|
superseded: the premise revision deleted the persisted marker entirely
|
||||||
|
(ruling 8) instead of keeping it write-on-difference.
|
||||||
|
|
||||||
|
Round 2 (on the premise revision) returned three important findings and one
|
||||||
|
minor. Folded: the docker recovery one-liner used a `--config` flag `import`
|
||||||
|
does not have (fixed in ruling 9, file is positional); `import` is now
|
||||||
|
stated everywhere as a stop-first operation and the file-authority contract
|
||||||
|
as "converged at every boot" (ruling 6); identity-column edits are named as
|
||||||
|
delete-plus-insert needing `--allow-delete` (ruling 6). Declined: renaming
|
||||||
|
`run --config` to `--managed-config` — reasons recorded in ruling 1.
|
||||||
|
|
||||||
|
## Red-team findings rejected
|
||||||
|
|
||||||
|
None rejected outright — every finding checked out against the repo. Two
|
||||||
|
proposed remedies were declined while their findings were accepted:
|
||||||
|
|
||||||
|
- **ops 10's mitigation** (keep the old compiled artifact until the successor
|
||||||
|
URL's first successful fetch): declined — a cross-artifact lifecycle for a
|
||||||
|
rare, operator-initiated event on a household box; the trade is now stated
|
||||||
|
in ruling 3 and the anti-requirements instead.
|
||||||
|
- **ops 14's db-mode warning** ("a config file exists but authority is
|
||||||
|
database"): declined — db mode is given no file path, so warning would mean
|
||||||
|
probing a well-known location, which is precisely the ambient inference
|
||||||
|
ruling 1 bans; the accepted half (drift visibility) is served by the
|
||||||
|
settings envelope's `authority` block and per-process `reconciled_at`.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Milestone 21: ABP exceptions from lists, regex rules for operators
|
||||||
|
|
||||||
|
Goal: honor `@@||domain^` exception lines in downloaded blocklists as allow
|
||||||
|
entries scoped below operator rules and above list blocks (tier 1), and add a
|
||||||
|
`regex` rule kind for operator rules backed by a homegrown linear-time engine
|
||||||
|
(tier 2). Nothing else from the ABP syntax enters scope.
|
||||||
|
|
||||||
|
Design written 2026-08-09 against HEAD `ffc3ca6`. Every `file:line` anchor
|
||||||
|
below was read at that commit. The milestone amends PLAN §2.2, which currently
|
||||||
|
rules regex out permanently; the amendment is part of session S3, not a
|
||||||
|
side effect.
|
||||||
|
|
||||||
|
## Implementation contract (read first)
|
||||||
|
|
||||||
|
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||||
|
- Pure core stays pure: `src/filter/` files that are fuzz-module roots import
|
||||||
|
only `std` (`src/filter/parsers.zig:1-14`). The new `src/filter/regex.zig`
|
||||||
|
obeys the same constraint.
|
||||||
|
- Every new `src/**.zig` file must be listed in `src/tests.zig`
|
||||||
|
(`build.zig:495-540` fatals otherwise).
|
||||||
|
- Frozen DDL is frozen: schema changes are new migration steps
|
||||||
|
(`src/storage/config_schema.zig:1-6`, `src/storage/migrations.zig:21-22`).
|
||||||
|
- After any API shape change, regenerate the contract samples
|
||||||
|
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md).
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
### 1. Exception lines are `@@||name^` and nothing else, plus one modifier
|
||||||
|
|
||||||
|
`src/filter/parser_abp.zig:22` currently maps every `@@` line to
|
||||||
|
`.unsupported`. After this milestone, a line is an exception when it is
|
||||||
|
`@@||name^` or `@@||name` (same trailing-`^` and `rule_tokens` treatment as the
|
||||||
|
block anchor at parser_abp.zig:26-34), optionally suffixed with the literal
|
||||||
|
`$important` — that suffix is the common form in AdGuard-authored lists and
|
||||||
|
changes nothing about the meaning here, because list exceptions already sit
|
||||||
|
below every operator rule. Any other `@@` form (`@@name` without the anchor,
|
||||||
|
any other `$` modifier, a path, a scheme) stays `.unsupported`. The
|
||||||
|
parser-header policy paragraph (parser_abp.zig:5-6) is rewritten to state the
|
||||||
|
new rule and its precedence justification: a list exception can cancel only
|
||||||
|
list blocks, never an operator decision, so no downloaded list can open an
|
||||||
|
allow hole the operator did not open.
|
||||||
|
|
||||||
|
### 2. Precedence: list exceptions sit between operator rules and list blocks
|
||||||
|
|
||||||
|
`matcher.Snapshot.evaluate` (`src/filter/matcher.zig:286-338`) gains one level
|
||||||
|
between the operator wildcard-block walk (level 4) and the blocklist domain
|
||||||
|
probe (level 5): for each attached source, an exception match — full name or
|
||||||
|
parent walk, apex covered — returns `blocked = false`, reason
|
||||||
|
`.blocklist_exception`, `matched` = the matching entry, `source` = the source
|
||||||
|
index. The doc comment at matcher.zig:269-285 and PLAN §3.10 (PLAN.md:108-117)
|
||||||
|
are both updated with the new level. Regex rules (ruling 6) slot in as levels
|
||||||
|
after the wildcard rules and before list exceptions, allow before block, so
|
||||||
|
the full order is: exact allow, exact block, wildcard allow, wildcard block,
|
||||||
|
regex allow, regex block, list exception, list domain, list wildcard.
|
||||||
|
"Tie-break at same specificity: allow wins" is preserved.
|
||||||
|
|
||||||
|
### 3. The compiled-source format grows a third body, checksum-compatibly
|
||||||
|
|
||||||
|
`compiler.compile` (`src/filter/compiler.zig:50-56`) takes a third writer
|
||||||
|
(`allow_w`) and `Counts` (compiler.zig:23-35) gains `exceptions: u32 = 0`.
|
||||||
|
Exception candidates go through the existing `addCandidate` path into a third
|
||||||
|
`Entries` and emit as a sorted, deduplicated `.allow` body. The shared SHA-256
|
||||||
|
covers the bodies in order list, wild, allow — because SHA-256 of
|
||||||
|
`list ++ wild ++ ""` equals the current SHA-256 of `list ++ wild`, every
|
||||||
|
already-published checksum stays valid, and `Manager.loadSource`
|
||||||
|
(`src/filter/manager.zig:543-598`) treats a missing `<id>.allow` file as an
|
||||||
|
empty body. No refetch is forced by upgrading. `bodyChecksum`
|
||||||
|
(manager.zig:1564) follows the same order; `source_file_suffixes`
|
||||||
|
(manager.zig:1581) gains `.allow.tmp` and `.allow` with longest-suffix-first
|
||||||
|
order preserved; the on-disk header (manager.zig:222-242) gains
|
||||||
|
`# exceptions {d}` after the `# wildcards` line and the pinning test at
|
||||||
|
manager.zig:1872-1900 is extended, not weakened.
|
||||||
|
|
||||||
|
### 4. Exception counts persist and surface
|
||||||
|
|
||||||
|
Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`):
|
||||||
|
`ALTER TABLE blocklist_sources ADD COLUMN exception_count INTEGER NOT NULL
|
||||||
|
DEFAULT 0;`. `sources_repo.SourceRow` and `updateSourceStats`
|
||||||
|
(`src/storage/repositories/sources_repo.zig:91-166`) carry it;
|
||||||
|
`SourceStatus` rehydration (`manager.zig:1457-1494`) restores it alongside the
|
||||||
|
existing three counts; `StatusView` (`src/web/handlers/blocklists.zig:52-78`)
|
||||||
|
gains `exceptions: u32`; the blocklists UI shows it where `skipped_regex`
|
||||||
|
already shows (`web/src/features/blocklists/SourceStatusSection.tsx`,
|
||||||
|
`web/src/lib/types.ts:163,192`).
|
||||||
|
|
||||||
|
### 5. The regex engine is a Pike VM, linear-time by construction, `std` only
|
||||||
|
|
||||||
|
New file `src/filter/regex.zig`. Syntax: literal bytes, `.`, character
|
||||||
|
classes `[...]` with ranges and leading-`^` negation, escapes
|
||||||
|
`\. \\ \- \d \w`, repetition `* + ? {n} {n,m}`, alternation `|`,
|
||||||
|
non-capturing grouping `(...)`, anchors `^` and `$`. No backreferences, no
|
||||||
|
lookaround, no captures. Matching is unanchored unless anchors are written
|
||||||
|
(POSIX-grep convention, matching Pi-hole user expectations). Input is the
|
||||||
|
normalized lowercase name, ≤ `types.max_name_len` bytes. Hard limits, each a
|
||||||
|
distinct error: pattern ≤ 256 bytes (`PatternTooLong`), compiled program
|
||||||
|
≤ 1024 instructions (`PatternTooComplex`). Public API:
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Error = error{ OutOfMemory, BadPattern, PatternTooLong, PatternTooComplex };
|
||||||
|
pub const Program = struct { ... , pub fn deinit(self: *Program, gpa: Allocator) void };
|
||||||
|
pub fn compile(gpa: Allocator, pattern: []const u8) Error!Program;
|
||||||
|
pub fn matches(prog: *const Program, input: []const u8) bool;
|
||||||
|
```
|
||||||
|
|
||||||
|
`matches` is a Pike VM: two thread lists, each program counter admitted at
|
||||||
|
most once per input position, worst case O(program × input) with zero
|
||||||
|
allocation at match time (thread lists sized from the program at compile
|
||||||
|
time). The engine is a fuzz-module root like parsers.zig and imports only
|
||||||
|
`std`.
|
||||||
|
|
||||||
|
### 6. `regex` is a third rule kind, validated at the edge, memoized by the cache
|
||||||
|
|
||||||
|
Migration step 4 (`ddl_v4`): the 12-step rebuild of `rules` with
|
||||||
|
`CHECK(kind IN ('exact','wildcard','regex'))` — the frozen v1 DDL
|
||||||
|
(`src/storage/config_schema.zig:65-72`) cannot be edited. `model.RuleKind`
|
||||||
|
(`src/config/model.zig:253-269`) gains `.regex`; the exhaustive switches in
|
||||||
|
`config/validate.zig:1067-1098` (compile the pattern, report
|
||||||
|
`"... is not a valid regex pattern"` through the existing error path at
|
||||||
|
validate.zig:862-869) and `src/filter/rules.zig:62-68` extend. `RuleSet`
|
||||||
|
(`rules.zig:28-36`) grows `regex_allow` and `regex_block` slices holding
|
||||||
|
compiled `Program`s plus their pattern texts (for `Decision.matched`);
|
||||||
|
`bucketOf` (rules.zig:133-143) becomes a six-bucket layout;
|
||||||
|
`max_regex_per_group: usize = 256` with `TooManyRegexRules` mirroring
|
||||||
|
`max_wildcards_per_group` (rules.zig:26). A pattern that fails to compile is
|
||||||
|
`error.BadPattern` at snapshot build, never skipped (rules.zig:41-49 doc
|
||||||
|
holds). Reason tags `rule_allow_regex` and `rule_block_regex` join
|
||||||
|
`matcher.Reason` (matcher.zig:24-32); `/api/lookup` and the query log pick
|
||||||
|
them up automatically via `@tagName` (`src/web/handlers/lookup.zig:89`;
|
||||||
|
`max_reason_len = 32` in `src/storage/logger.zig` fits both at 16 chars).
|
||||||
|
Regex evaluation runs only after every hash and wildcard level missed, and
|
||||||
|
answers are memoized by the existing DNS cache like every other decision, so
|
||||||
|
the per-query cost lands on cache misses only.
|
||||||
|
|
||||||
|
### 7. The web contract names the third kind everywhere it names the first two
|
||||||
|
|
||||||
|
`src/web/handlers/rules.zig`: `toInput` accepts `"regex"`; the 400 string at
|
||||||
|
rules.zig:42 becomes `"kind must be 'exact', 'wildcard' or 'regex'"`.
|
||||||
|
`src/web/openapi.yaml:1875-1917`: all three `enum: [exact, wildcard]` become
|
||||||
|
`[exact, wildcard, regex]`. `web/src/lib/types.ts:195`:
|
||||||
|
`RuleKind = "exact" | "wildcard" | "regex"`; the rules page kind selector
|
||||||
|
gains the option. Contract samples regenerated. `nxdns export` / `import`
|
||||||
|
round-trip the new kind with no extra work once `RuleKind.toDb/fromDb` extend
|
||||||
|
— the existing round-trip test at model.zig:722-723 is extended to prove it.
|
||||||
|
|
||||||
|
### 8. PLAN amendments land with the code, in S3
|
||||||
|
|
||||||
|
PLAN.md:36 (§2.2) is rewritten: operator regex rules are in scope, backed by
|
||||||
|
the linear-time engine of ruling 5; regex lines in downloaded lists stay
|
||||||
|
counted and skipped; `$` modifiers (except the `$important` suffix of ruling
|
||||||
|
1), partial-segment wildcards, and browser-syntax honoring stay permanently
|
||||||
|
out. PLAN.md:26 (§2.1 filtering sentence), PLAN.md:106 (§3.9), PLAN.md:108-117
|
||||||
|
(§3.10 precedence) and PLAN.md:700 (decision B) are updated to match rulings
|
||||||
|
2 and 6. In-code echoes of the old §2.2 move with it:
|
||||||
|
`src/filter/wildcard.zig:6-8,22-24`, `src/filter/parsers.zig:25`,
|
||||||
|
`src/filter/parser_abp.zig:5-6`, `src/filter/compiler.zig:129-131`.
|
||||||
|
|
||||||
|
### 9. Fuzz invariants move, never lapse
|
||||||
|
|
||||||
|
`tests/fuzz/blocklist_fuzz.zig` header invariant "covers_apex only on
|
||||||
|
`.wildcard`" (its stated form at :4-28) becomes "only on `.wildcard` or
|
||||||
|
`.exception`". `tests/fuzz/compiler_fuzz.zig:43` reads `Format` from
|
||||||
|
`compile`'s parameter list by index — the new `allow_w` parameter appends
|
||||||
|
after `wild_w`, leaving index 2 valid; the session touching `compile` runs the
|
||||||
|
fuzz suite and fixes that line if the assumption fails. A new
|
||||||
|
`tests/fuzz/regex_fuzz.zig` target asserts: `compile` on arbitrary bytes
|
||||||
|
never crashes and either errors or produces a program within the ruling-5
|
||||||
|
limits; `matches` terminates and its VM step count never exceeds
|
||||||
|
program length × (input length + 1); compile-then-match is deterministic.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Three sessions. S1 and S2 run in parallel — they share no files. S3 starts
|
||||||
|
after both land.
|
||||||
|
|
||||||
|
### Session S1: list exceptions end to end (tier 1)
|
||||||
|
|
||||||
|
Owns: `src/filter/parsers.zig`, `src/filter/parser_abp.zig`,
|
||||||
|
`src/filter/compiler.zig`, `src/filter/manager.zig`, `src/filter/matcher.zig`,
|
||||||
|
`src/filter/domain_set.zig` (only if a helper is needed; expected untouched),
|
||||||
|
`src/filter/filter_integration_test.zig`, `src/storage/migrations.zig`
|
||||||
|
(step 3 only), `src/storage/repositories/sources_repo.zig`,
|
||||||
|
`src/web/handlers/blocklists.zig`, `src/web/handlers/lookup.zig` (doc
|
||||||
|
sentence only), `src/web/openapi.yaml` (StatusView shape only),
|
||||||
|
`web/src/features/blocklists/*`, `web/src/lib/types.ts` (source-stat fields
|
||||||
|
only), `web/src/lib/contractSamples.gen.ts`,
|
||||||
|
`tests/fuzz/blocklist_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig`.
|
||||||
|
|
||||||
|
- S1.1 `Kind.exception` in parsers.zig; parser_abp emits it per ruling 1;
|
||||||
|
parser_hosts and parser_domains never emit it (no change beyond the enum).
|
||||||
|
- S1.2 compiler third body per ruling 3; `Counts.exceptions`.
|
||||||
|
- S1.3 manager: suffixes, header line, `bodyChecksum`, `loadSource`
|
||||||
|
missing-file-is-empty, `Snapshot.Compiled.allow_body`,
|
||||||
|
`prepareRefresh`/`publishRefresh`/`applyLoadOutcomes` carry the count.
|
||||||
|
`rejectedWithoutEntries` (manager.zig:1559-1562) treats a compile with only
|
||||||
|
exceptions as loadable, not rejected.
|
||||||
|
- S1.4 matcher: `SourceSets.exceptions`, `Reason.blocklist_exception`,
|
||||||
|
the new evaluate level per ruling 2, `memoryBytes` includes the new sets.
|
||||||
|
- S1.5 storage + API + UI per ruling 4.
|
||||||
|
- S1.6 tests: parser cases (`@@||x^`, `@@||x`, `@@||x^$important`,
|
||||||
|
`@@||x^$third-party` → unsupported, `@@x` → unsupported); compiler
|
||||||
|
three-body + checksum-compat cases (empty allow body reproduces the old
|
||||||
|
digest byte for byte); manager header pin extended; matcher precedence
|
||||||
|
cases: operator block beats list exception, list exception beats list
|
||||||
|
domain and list wildcard, exception parent walk covers apex and
|
||||||
|
subdomains; an integration case through
|
||||||
|
`filter_integration_test.zig` with a real ABP fixture carrying `@@` lines.
|
||||||
|
|
||||||
|
Acceptance (S1):
|
||||||
|
- [ ] `zig build test` passes; fuzz targets build and run.
|
||||||
|
- [ ] A fixture list with `||ads.example^` and `@@||good.ads.example^`
|
||||||
|
compiled and loaded blocks `ads.example` and `x.ads.example`, does not
|
||||||
|
block `good.ads.example` or `y.good.ads.example`, and
|
||||||
|
`/api/lookup` reports `blocklist_exception` with the source id for the
|
||||||
|
latter two.
|
||||||
|
- [ ] A pre-milestone data directory (no `.allow` files, old checksums)
|
||||||
|
loads with zero checksum mismatches.
|
||||||
|
- [ ] `POST /api/blocklists/update` response rows carry `exceptions`.
|
||||||
|
|
||||||
|
### Session S2: the regex engine (tier 2, engine only)
|
||||||
|
|
||||||
|
Owns: `src/filter/regex.zig` (new), `tests/fuzz/regex_fuzz.zig` (new),
|
||||||
|
`src/tests.zig` (one added line), `build.zig` (fuzz-suite wiring for the new
|
||||||
|
target only).
|
||||||
|
|
||||||
|
- S2.1 the engine per ruling 5: parser → AST → NFA program → Pike VM.
|
||||||
|
- S2.2 unit tests in-file: every syntax form; anchored and unanchored
|
||||||
|
matching; negated classes; `{n,m}` bounds; each error case; the
|
||||||
|
pathological backtracker-killers (`(a+)+b` against `aaaaaaaaaaaaaaaaaaaaX`,
|
||||||
|
nested alternation) complete within the step bound.
|
||||||
|
- S2.3 the fuzz target per ruling 9.
|
||||||
|
|
||||||
|
Acceptance (S2):
|
||||||
|
- [ ] `zig build test` passes with the new file in `src/tests.zig`.
|
||||||
|
- [ ] The step-bound property holds under the fuzz corpus.
|
||||||
|
- [ ] `regex.zig` imports nothing but `std`.
|
||||||
|
|
||||||
|
### Session S3: the regex rule kind, wired through (needs S1 + S2)
|
||||||
|
|
||||||
|
Owns: `src/storage/migrations.zig` (step 4), `src/storage/config_schema.zig`
|
||||||
|
(comment only if needed), `src/config/model.zig`, `src/config/validate.zig`,
|
||||||
|
`src/filter/rules.zig`, `src/filter/matcher.zig`,
|
||||||
|
`src/storage/repositories/rules_repo.zig`, `src/web/handlers/rules.zig`,
|
||||||
|
`src/web/handlers/mutations.zig` (only if `checkRule` needs the kind),
|
||||||
|
`src/web/openapi.yaml`, `web/src/features/rules/*`, `web/src/lib/types.ts`,
|
||||||
|
`web/src/lib/contractSamples.gen.ts`, `PLAN.md`, `src/filter/wildcard.zig`
|
||||||
|
(comments), `src/filter/parsers.zig` (comment), `src/filter/parser_abp.zig`
|
||||||
|
(comment), `src/filter/compiler.zig` (comment), `tools/bench.zig`.
|
||||||
|
|
||||||
|
- S3.1 migration step 4 per ruling 6; repo and model layers.
|
||||||
|
- S3.2 validate at both edges (config import, API) per rulings 6 and 7.
|
||||||
|
- S3.3 `RuleSet` six buckets; matcher levels per ruling 2; reasons.
|
||||||
|
- S3.4 web + UI + openapi + samples per ruling 7.
|
||||||
|
- S3.5 PLAN and comment amendments per ruling 8.
|
||||||
|
- S3.6 bench: the filter suite in `tools/bench.zig` gains a variant with 32
|
||||||
|
regex rules loaded; the existing p95 < 1 ms assertion covers it.
|
||||||
|
- S3.7 tests: rule CRUD with kind `regex` through the API including the 400
|
||||||
|
for a bad pattern at insert time; precedence cases regex-allow over
|
||||||
|
regex-block, wildcard over regex, regex over list entries; export/import
|
||||||
|
round trip; a migration test upgrading a v3 database.
|
||||||
|
|
||||||
|
Acceptance (S3):
|
||||||
|
- [ ] `zig build test` and `cd web && npm test` pass.
|
||||||
|
- [ ] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}`
|
||||||
|
returns 201; with `"pattern":"("` returns 400 naming the pattern.
|
||||||
|
- [ ] A regex block rule blocks a matching name; `/api/lookup` reports
|
||||||
|
`rule_block_regex` and `matched` carries the pattern text.
|
||||||
|
- [ ] `zig build bench -Doptimize=ReleaseFast -- filter` passes its targets
|
||||||
|
with the regex variant present.
|
||||||
|
- [ ] PLAN §2.2 no longer forbids operator regex; all listed echoes updated.
|
||||||
|
|
||||||
|
### Orchestrator
|
||||||
|
|
||||||
|
Verify S1 and S2 acceptance before starting S3. After S3: run the full gate
|
||||||
|
set (`zig build test`, `test-aarch64` if qemu present, `npm test`,
|
||||||
|
`npm run assert-bundled`), then a live smoke against a scratch server: load
|
||||||
|
one real ABP list with `@@` lines, add one regex rule, verify both over dig
|
||||||
|
and `/api/lookup`. Record deviations in `## Recorded (implementation)`.
|
||||||
|
|
||||||
|
## Module layout
|
||||||
|
|
||||||
|
New files:
|
||||||
|
- `src/filter/regex.zig` — the linear-time engine (ruling 5).
|
||||||
|
- `tests/fuzz/regex_fuzz.zig` — its fuzz target (ruling 9).
|
||||||
|
|
||||||
|
Deleted surface: none.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] All session acceptance boxes above.
|
||||||
|
- [ ] Schema at version 4; a v2 database migrates cleanly with data intact.
|
||||||
|
- [ ] A pre-milestone blocklist data directory loads without refetch.
|
||||||
|
- [ ] The six-level operator precedence plus three list levels behave per
|
||||||
|
ruling 2, proven by matcher tests that enumerate adjacent-level pairs.
|
||||||
|
- [ ] No `src/filter/` fuzz-root file imports anything but `std`.
|
||||||
|
- [ ] Contract samples, openapi.yaml and `web/src/lib/types.ts` agree with
|
||||||
|
the server (the drift guards pass).
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No `$` modifier support beyond tolerating `$important` on exception lines.
|
||||||
|
`$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser
|
||||||
|
modifier stay unsupported and counted.
|
||||||
|
- No regex from downloaded lists. `.regex` lines stay counted and skipped;
|
||||||
|
`skipped_regex` keeps its meaning. The engine exists for operator rules
|
||||||
|
only.
|
||||||
|
- No partial-segment wildcards (`ads*.example.com`) — writing one as a rule
|
||||||
|
stays rejected; the regex kind covers the need.
|
||||||
|
- No backreferences, lookaround, captures, named groups or Unicode classes in
|
||||||
|
the engine, ever. A pattern needing them is rejected, not approximated.
|
||||||
|
- No PCRE2, RE2 or any external regex dependency.
|
||||||
|
- No exception-rule UI editor: exceptions come from lists; operators write
|
||||||
|
allow rules.
|
||||||
|
- No re-download forced by the upgrade; checksum compatibility (ruling 3) is
|
||||||
|
a requirement, not an optimization.
|
||||||
+2541
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"lint": "oxlint src vite.config.ts",
|
"lint": "oxlint src vite.config.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"assert-bundled": "node scripts/assert-bundled-packages.mjs"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The set of npm packages whose bytes reach web/dist must be exactly the set
|
||||||
|
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
||||||
|
//
|
||||||
|
// The shipped build carries no sourcemaps, so this makes a second build with
|
||||||
|
// them into its own directory: the `sources` list of each chunk names the
|
||||||
|
// modules that went into it, and the artifact `npm run build` produced stays
|
||||||
|
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
|
||||||
|
// on the runner.
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
|
||||||
|
|
||||||
|
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const outDir = "dist-sourcemap";
|
||||||
|
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFiles(relativeDir) {
|
||||||
|
const absolute = join(webRoot, relativeDir);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(absolute, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
|
||||||
|
}
|
||||||
|
const found = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const child = `${relativeDir}/${entry.name}`;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
found.push(...mapFiles(child));
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||||
|
found.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The binary npm ci installed, never `npx`: npx silently downloads a package it
|
||||||
|
// cannot find locally, so a wrong working directory would turn a licence check
|
||||||
|
// into an unpinned fetch from the network.
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
join(webRoot, "node_modules", ".bin", "vite"),
|
||||||
|
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
|
||||||
|
{
|
||||||
|
cwd: webRoot,
|
||||||
|
stdio: ["ignore", "ignore", "inherit"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maps = mapFiles(outDir);
|
||||||
|
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
|
||||||
|
|
||||||
|
const sourceLists = maps.map((path) => {
|
||||||
|
const raw = readFileSync(join(webRoot, path), "utf8");
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
|
||||||
|
}
|
||||||
|
return Array.isArray(parsed.sources) ? parsed.sources : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundled = bundledPackages(sourceLists);
|
||||||
|
|
||||||
|
let identity;
|
||||||
|
try {
|
||||||
|
identity = readFileSync(identityFile, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorded = recordedPackages(identity);
|
||||||
|
if (recorded === null) {
|
||||||
|
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
|
||||||
|
}
|
||||||
|
if (recorded.length === 0) {
|
||||||
|
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { added, removed } = comparePackages(recorded, bundled);
|
||||||
|
if (added.length !== 0 || removed.length !== 0) {
|
||||||
|
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||||
|
fail(
|
||||||
|
[
|
||||||
|
"the set of npm packages in web/dist has changed (-recorded +current).",
|
||||||
|
"Work out what the change means for licenses/inventory.zon first, then record",
|
||||||
|
"the new list in that section of licenses/dependency-identity.txt.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||||
|
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The decisions behind `npm run assert-bundled`, kept separate from the script
|
||||||
|
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
|
||||||
|
//
|
||||||
|
// The licence inventory has to cover every package whose bytes ship, and the
|
||||||
|
// lockfile does not answer that question: it lists what could be reached, not
|
||||||
|
// what rollup kept. Several packages of the non-dev closure are recorded as
|
||||||
|
// tree-shaken away, and if application code starts importing one of them, no
|
||||||
|
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||||
|
// the bundle is what this reads.
|
||||||
|
|
||||||
|
const sectionHeading = "[npm packages bundled into web/dist]";
|
||||||
|
|
||||||
|
// A sourcemap `sources` entry for a dependency ends in
|
||||||
|
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||||
|
// the last `node_modules/` matters: a nested dependency's path carries two.
|
||||||
|
export function packageFromSource(source) {
|
||||||
|
const marker = "node_modules/";
|
||||||
|
const at = source.lastIndexOf(marker);
|
||||||
|
if (at === -1) return null;
|
||||||
|
const rest = source.slice(at + marker.length);
|
||||||
|
const parts = rest.split("/");
|
||||||
|
if (parts.length === 0 || parts[0] === "") return null;
|
||||||
|
if (parts[0].startsWith("@")) {
|
||||||
|
if (parts.length < 2 || parts[1] === "") return null;
|
||||||
|
return `${parts[0]}/${parts[1]}`;
|
||||||
|
}
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
|
||||||
|
export function bundledPackages(sourceLists) {
|
||||||
|
const found = new Set();
|
||||||
|
for (const sources of sourceLists) {
|
||||||
|
for (const source of sources) {
|
||||||
|
const name = packageFromSource(source);
|
||||||
|
if (name !== null) found.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...found].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
|
||||||
|
/// line after the heading, up to the next `[section]`.
|
||||||
|
export function recordedPackages(text) {
|
||||||
|
const recorded = new Set();
|
||||||
|
let grabbing = false;
|
||||||
|
for (const raw of text.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!grabbing) {
|
||||||
|
if (line === sectionHeading) grabbing = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("[")) break;
|
||||||
|
if (line !== "") recorded.add(line);
|
||||||
|
}
|
||||||
|
return grabbing ? [...recorded].sort() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What changed, in the two directions that mean different things: a package
|
||||||
|
/// that started shipping needs a licence decision, and one that stopped needs
|
||||||
|
/// the record corrected.
|
||||||
|
export function comparePackages(recorded, bundled) {
|
||||||
|
const inBundle = new Set(bundled);
|
||||||
|
const inRecord = new Set(recorded);
|
||||||
|
return {
|
||||||
|
added: bundled.filter((name) => !inRecord.has(name)),
|
||||||
|
removed: recorded.filter((name) => !inBundle.has(name)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDiff(recorded, bundled) {
|
||||||
|
const { added, removed } = comparePackages(recorded, bundled);
|
||||||
|
const lines = [];
|
||||||
|
for (const name of removed) lines.push(`-${name}`);
|
||||||
|
for (const name of added) lines.push(`+${name}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bundledPackages,
|
||||||
|
comparePackages,
|
||||||
|
formatDiff,
|
||||||
|
packageFromSource,
|
||||||
|
recordedPackages,
|
||||||
|
} from "./bundledPackages.mjs";
|
||||||
|
|
||||||
|
describe("packageFromSource", () => {
|
||||||
|
it("reads a plain package name", () => {
|
||||||
|
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the scope of a scoped package", () => {
|
||||||
|
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
|
||||||
|
"@tanstack/react-query",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the last node_modules, so a nested dependency is named correctly", () => {
|
||||||
|
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores application sources", () => {
|
||||||
|
expect(packageFromSource("src/lib/api.ts")).toBeNull();
|
||||||
|
expect(packageFromSource("../src/main.tsx")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundledPackages", () => {
|
||||||
|
it("sorts and deduplicates across every map", () => {
|
||||||
|
const packages = bundledPackages([
|
||||||
|
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
|
||||||
|
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
|
||||||
|
]);
|
||||||
|
expect(packages).toEqual(["@tanstack/react-router", "react"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty set when nothing came from node_modules", () => {
|
||||||
|
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordedPackages", () => {
|
||||||
|
const identity = [
|
||||||
|
"[some earlier section]",
|
||||||
|
"ignored",
|
||||||
|
"",
|
||||||
|
"[npm packages bundled into web/dist]",
|
||||||
|
"react",
|
||||||
|
"@tanstack/react-query",
|
||||||
|
"",
|
||||||
|
"react-dom",
|
||||||
|
"",
|
||||||
|
"[a later section]",
|
||||||
|
"not-a-package",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("reads only its own section, sorted and deduplicated", () => {
|
||||||
|
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a missing section from an empty one", () => {
|
||||||
|
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||||
|
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("comparePackages", () => {
|
||||||
|
it("reports both directions", () => {
|
||||||
|
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
|
||||||
|
expect(added).toEqual(["c"]);
|
||||||
|
expect(removed).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports nothing when the sets match", () => {
|
||||||
|
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
|
||||||
|
expect(formatDiff(["a"], ["a"])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a diff the way the failure prints it", () => {
|
||||||
|
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user