12 Commits
Author SHA1 Message Date
mokhtar aa4a8f1573 spec milestone 21: re-verify anchors against m20, record authority-mode interaction
Gates / test (push) Successful in 2m27s
Gates / test-aarch64 (push) Successful in 8m57s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 7m25s
Gates / container (push) Failing after 2m22s
CI / gates (push) Failing after 22m0s
2026-08-11 23:37:20 +02:00
mokhtar a8e0fe4617 milestone 20: declarative configuration for iac
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
2026-08-11 23:31:40 +02:00
mokhtar 348e955b8f spec milestone 21: abp list exceptions and operator regex rules
Gates / test (push) Successful in 1m16s
Gates / test-aarch64 (push) Successful in 4m24s
Gates / package (push) Successful in 5m25s
Gates / container (push) Successful in 2m24s
CI / gates (push) Successful in 14m22s
Gates / frontend (push) Successful in 52s
2026-08-11 19:03:58 +02:00
mokhtar ffc3ca61ba spec milestone 20: declarative configuration for iac 2026-08-11 18:49:03 +02:00
mokhtar 5c89acf337 milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed
Gates / test (push) Successful in 1m19s
Gates / package (push) Successful in 5m10s
CI / gates (push) Successful in 14m28s
Gates / test-aarch64 (push) Successful in 4m55s
Gates / frontend (push) Successful in 42s
Gates / container (push) Successful in 2m20s
2026-08-09 01:35:43 +02:00
mokhtar 3c2d0d41f0 changelog: 0.0.1 releases today
Release / guard (push) Successful in 19s
Gates / test (push) Successful in 1m13s
Gates / frontend (push) Successful in 48s
Gates / test-aarch64 (push) Successful in 4m26s
Gates / container (push) Successful in 2m22s
Gates / package (push) Successful in 3m51s
CI / gates (push) Successful in 12m40s
Gates / test (push) Successful in 1m13s
Gates / test-aarch64 (push) Successful in 4m29s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 37s
Gates / container (push) Successful in 37s
Release / gates (push) Successful in 7m44s
Release / publish (push) Successful in 4m4s
2026-08-09 01:01:55 +02:00
mokhtar 31a6f0c5e5 changelog and spec: dry run complete, restore 0.0.1, record deviations 25 and 26
Gates / test (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 5m4s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 5m24s
Gates / container (push) Successful in 2m18s
CI / gates (push) Successful in 14m54s
2026-08-08 21:43:36 +02:00
mokhtar 51d8281abb release: name the image by the public registry host, not the internal server url
Gates / frontend (push) Successful in 45s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m18s
CI / gates (push) Successful in 12m35s
Gates / test-aarch64 (push) Successful in 4m26s
Gates / frontend (push) Successful in 47s
Release / guard (push) Successful in 1m16s
Gates / test (push) Successful in 1m9s
Gates / package (push) Successful in 31s
Gates / container (push) Successful in 34s
Release / gates (push) Successful in 7m29s
Release / publish (push) Successful in 5m15s
Gates / test (push) Successful in 1m11s
Gates / test-aarch64 (push) Successful in 4m28s
2026-08-08 21:14:37 +02:00
mokhtar 266dde7396 release: move publication orchestration into tools/release.zig, pin rotated subkey
Gates / package (push) Successful in 8m51s
Release / guard (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 4m24s
Gates / frontend (push) Successful in 44s
Gates / package (push) Successful in 33s
Gates / container (push) Successful in 35s
Release / gates (push) Successful in 8m24s
Gates / test-aarch64 (push) Successful in 7m56s
Gates / frontend (push) Successful in 1m31s
Gates / test (push) Failing after 16m8s
Gates / container (push) Successful in 5m11s
CI / gates (push) Failing after 30m3s
Gates / test (push) Successful in 2m7s
Release / publish (push) Failing after 10m20s
2026-08-08 20:22:04 +02:00
mokhtar 32cd9b8e3e release: refetch the annotated tag object that checkout replaces
Gates / test (push) Successful in 1m16s
Gates / test-aarch64 (push) Successful in 4m33s
Gates / frontend (push) Successful in 48s
Gates / package (push) Successful in 3m50s
Gates / container (push) Successful in 2m22s
CI / gates (push) Successful in 12m52s
Release / guard (push) Failing after 7s
Release / gates (push) Skipped
Release / publish (push) Skipped
2026-08-08 16:03:04 +02:00
mokhtar e14a29c5de changelog: temporary 0.0.0 section for the release dry run
Gates / test (push) Successful in 1m23s
Gates / test-aarch64 (push) Successful in 5m6s
Gates / package (push) Successful in 3m46s
CI / gates (push) Successful in 13m23s
Gates / frontend (push) Successful in 44s
Gates / container (push) Successful in 2m22s
Release / guard (push) Failing after 6s
Release / gates (push) Skipped
Release / publish (push) Skipped
2026-08-08 15:35:40 +02:00
mokhtar da4441b24a release: pin the artifact-signing subkey fingerprint
Gates / test (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 4m31s
Gates / frontend (push) Successful in 45s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m17s
CI / gates (push) Successful in 12m44s
2026-08-08 14:44:07 +02:00
119 changed files with 1943 additions and 11521 deletions
-6
View File
@@ -9,12 +9,6 @@ on:
branches: [master]
pull_request:
branches: [master]
# A runner pod replaced mid-job kills the job it is running: the step's log
# stops dead, no epilogue is written, and Gitea marks the orphan failed.
# Nothing in the tree is wrong when that happens and nothing in the tree can
# fix it, so the gate set must be re-runnable without an empty commit. This
# adds a trigger, not a check — the job list below stays the gate set alone.
workflow_dispatch:
jobs:
gates:
+55 -121
View File
@@ -14,28 +14,9 @@ name: Gates
# token. A moved tag upstream would execute there. ci.yml itself still uses no
# third-party action; its only `uses:` is this workflow.
#
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` — the
# three originals on 2026-08-07, the two artifact actions on 2026-08-12. Every
# tag pinned here is lightweight — no `^{}` peel row — so the listed SHA is the
# commit. download-artifact's `v3-node20` is the exception that proves the
# rule: it is annotated, and its peel row names a different commit than the tag
# object. This file pins `v3.1.0-node20`, which is lightweight.
#
# The artifact actions are pinned to v3, and to the node20 rebuilds of v3, for
# two separate reasons.
#
# v4 and v5 abort in the *client*, not the server. @actions/artifact asks
# isGhes(), Gitea answers the way GitHub Enterprise Server does, and the action
# refuses before it uploads anything. The server side is present: Gitea 1.27.1
# routes the whole v4 twirp service — CreateArtifact, FinalizeArtifact,
# ListArtifacts, GetSignedArtifactURL and DeleteArtifact all answer 401, where
# an unrouted path answers 404. The forks that patch isGhes() out are
# third-party code, which is what the pinning rule above exists to keep away
# from the job that holds the signing subkey.
#
# The stock v3 tags — upload ff15f030 (v3.2.1), download 9bc31d5c (v3.0.2) —
# declare `using: node16`, and a runner is not required to still ship a node16.
# The `-node20` rebuilds are that same action code on a runtime that exists.
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` on
# 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the
# listed SHA is the commit.
on:
workflow_call:
@@ -104,11 +85,7 @@ jobs:
run: zig build test-aarch64 -fqemu
frontend:
# The light runner, a second act_runner at capacity 1 that advertises only
# this label. This job peaks around 355 MB (tsc), well inside that runner's
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
# heavy runner's zig and image work instead of queueing behind it.
runs-on: ubuntu-24.04-light
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -153,30 +130,7 @@ jobs:
working-directory: web
run: npm run assert-bundled
# The package and container jobs consume this bundle instead of building
# their own. The upload is last on purpose: a bundle that failed the
# licence inventory or any check above must not reach the jobs that
# package it into a tarball and an image.
#
# Artifact names are scoped to the run, so the ci.yml and release.yml
# runs that share this runner cannot read each other's.
#
# A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file
# web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# dotfiles by default. It is inactive today — v3 keeps them, and the
# package job's target path skips the stamp check regardless — but a move
# to v4 that also pointed the download back at web/dist would fail with
# "web/dist is stale".
- name: Upload the built web UI
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: web-dist
path: web/dist
if-no-files-found: error
package:
needs: [frontend]
runs-on: ubuntu-24.04
steps:
@@ -195,22 +149,20 @@ jobs:
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
# exist before the packaging gate runs. It arrives from the frontend job,
# already formatted, linted, typechecked, tested and licence-checked.
#
# The target is deliberately not `web/dist`: build.zig runs the freshness
# stamp check for that exact path and no other (milestone-15 ruling 5),
# and the check shells out to `node`. Here it would buy nothing — the
# stamp hashes the web/ sources, not the bundle, so against a checkout of
# the same commit that built the bundle it can only agree. An explicit
# path is the case build.zig documents for a bundle built elsewhere, and
# taking it keeps node out of this job entirely.
- name: Download the web UI built by the frontend job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
name: web-dist
path: web-dist-ci
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
# `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has
# to exist before the packaging gate runs.
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
# Ruling 2: build.zig.zon is the only place besides the tag that carries
# the version, and ruling 5 makes verify-dist assert the two agree. The
@@ -228,7 +180,7 @@ jobs:
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static
@@ -244,51 +196,42 @@ jobs:
zig build verify-dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else
# out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
# /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
# directory, which the container job then hashes against the image. The
# tarballs and SHA256SUMS stay behind — no later job reads them, and
# verify-dist has already checked them here.
#
# The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive
# modes are asserted by verify-dist, above, on the originals.
- name: Upload the staged payload for the container job
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: dist-payload
path: |
zig-out/dist/bin
zig-out/dist/stage
if-no-files-found: error
container:
needs: [package]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Restored under zig-out/dist because that is where the Dockerfile's
# COPY lines look, with the repository root as the build context. This
# job compiles nothing and bundles nothing: the payload is the one the
# package job already built and verify-dist already checked, which is
# also the point — an image built from a second, independent `dist` run
# would prove nothing about the artifacts the release publishes.
- name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
- name: Set up Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
name: dist-payload
path: zig-out/dist
version: ${{ env.ZIG_VERSION }}
# Same single source of truth as the package job (rulings 2 and 5). This
# job still needs the version for the stage directory name it hashes
# against the image and for the VERSION build arg.
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
# setup-zig's restored cache never contains tmp/. Without this, every
# dependency fetch dies with "failed to create temporary zip file:
# FileNotFound" before any network I/O.
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
# Same single source of truth as the package job (rulings 2 and 5).
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
@@ -296,6 +239,15 @@ jobs:
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
- name: Build the release artifacts
run: |
set -euo pipefail
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
# Image tags, container names and published host ports are all
# daemon-global. This workflow is called by both ci.yml and release.yml
# and the self-hosted runners share one docker daemon, so the fixed
@@ -395,36 +347,18 @@ jobs:
# `-p 127.0.0.1::8080` takes an ephemeral host port instead of a
# fixed 18080, which two concurrent runs on this daemon cannot both
# bind. The actual port is read back with `docker port`.
#
# The command and the sysctl mirror deploy/docker/compose.yaml,
# because that is the invocation this gate exists to prove. The
# invocation is the sole configuration authority (milestone-20 ruling
# 1): the image's bare `run` grades the database, and a fresh
# /var/lib/nxdns volume holds no upstream, so it exits 2 with
# NoUsableUpstreams before it ever binds a port.
net=$(docker inspect "$(hostname)" \
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
2>/dev/null || true)
cid=$(docker create --name "$SMOKE_NAME" \
${net:+--network "$net"} \
-p 127.0.0.1::8080 \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
"$CI_IMAGE" run --config=/etc/nxdns/config.zon)
"$CI_IMAGE")
trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT
docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon"
docker start "$SMOKE_NAME"
# Before anything that assumes a live container. `docker port` fails
# on one that already exited, and under `set -e` that failure is the
# whole diagnosis the log gets — the container's own stderr never
# reaches CI.
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
echo "container exited during startup"
docker logs "$cid" || true
exit 1
fi
hostport=$(docker port "$SMOKE_NAME" 8080/tcp | head -1 | awk -F: '{ print $NF }')
hostport=$(docker port "$SMOKE_NAME" 8080/tcp 2>/dev/null | head -1 | awk -F: '{ print $NF }')
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
echo "published host port: ${hostport:-none}, container ip: ${ip:-none}"
healthy=""
-1
View File
@@ -4,4 +4,3 @@ zig-pkg/
web/node_modules/
web/dist/
web/dist-sourcemap/
web-dist-ci/
-50
View File
@@ -10,13 +10,6 @@ subject rarely does.
## [Unreleased]
## [0.0.2] - 2026-08-14
Configuration can now be a file that every boot converges to, filtering gains
regex rules and honors blocklist exception lines, and two refresh bugs that
silently kept stale state are fixed. Note the three breaking changes below if
you script against `nxdns import` or run with `--config`.
### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
@@ -31,26 +24,6 @@ you script against `nxdns import` or run with `--config`.
configuration rows, names the tables and counts, and applies it only with
the new `--allow-delete` flag. Additive and edit-in-place imports need no
flag.
- **Regex rules.** Rules gain a third kind, `regex`, beside `exact` and
`wildcard`, for per-group allow and block patterns such as `^ad[0-9]+-`. The
engine is homegrown and linear-time by construction, so no pattern can make
matching blow up; backreferences and lookaround do not exist, and a bad
pattern is refused at insert time with the limit it hit. Matches appear in
`/api/lookup` and the query log as `rule_allow_regex` / `rule_block_regex`.
Regex still comes only from you: regex lines in downloaded lists stay
counted and skipped.
- **Blocklist exception lines are honored.** An Adblock-Plus `@@||name^` line
in a downloaded list now lifts that name — and its subdomains — out of what
the attached lists block. Exceptions sit below every rule you wrote: a
downloaded list can reopen only a hole another downloaded list dug, never
override an operator decision. Each source reports how many it carried.
- **Browser-only lines are counted where you can see them.** Every source now
reports how many of its lines nxdns skipped as syntax with no DNS meaning —
cosmetic filters, `$`-modifier rules — beside the existing skipped-regex
count. Both blocklist tables show the number and the UI explains the
difference: a list whose skipped-unsupported count dwarfs its domain count
is written for browser extensions, and its DNS or hosts variant will block
more. Previously such a list compiled to almost nothing and looked clean.
### Changed
@@ -76,29 +49,6 @@ you script against `nxdns import` or run with `--config`.
- `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
Use `nxdns import` once, or run in file mode with `--config`.
- The admin UI's internals moved to TypeScript 7 and replaced Tailwind with
StyleX and React Aria. The visible change is small: selects are real
widgets with working keyboard focus; everything else renders as before.
- The `config.db` schema is a single baseline definition again; numbered
migration steps start accumulating at v0.1.
### Fixed
- **A list switching a name between its exact and wildcard forms never took
effect.** The compiled-list checksum hashed the exact and wildcard bodies as
one unseparated byte stream, so a list carrying `a.example` and the same
list carrying `*.a.example` produced the same digest, and the refresh kept
the old compiled files. The checksum now separates the bodies. Every source
recompiles once on its first refresh after the upgrade; no re-download of
unchanged content is forced beyond the refresh's normal fetch.
- **A refresh could store stale skip counts.** When a refresh found the list
content unchanged, it wrote the previously stored skip counters back to the
database while showing the fresh ones in the UI, and the next restart
reverted the numbers to the stale copy. All counters now persist from the
fresh compile.
- An Adblock-Plus entry with embedded whitespace
(`||good.example bad.example^`) compiled into an entry no query could ever
match. Such lines are now counted as unsupported instead.
## [0.0.1] - 2026-08-09
+3 -6
View File
@@ -61,12 +61,9 @@ chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
```
0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and
systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good.
Under `run --config` the service reads this file on **every** start, not once,
so tightening the mode after the first boot breaks the next restart. Under
database authority it is `nxdns import` that reads the file, as whoever runs
that command, and a bare `nxdns run` never reads it at all.
0640 with group `nxdns` rather than 0600: the service runs as `nxdns` and has to
read this file on the first start, and systemd leaves `/etc/nxdns` owned by
root.
Check it before starting the service:
+73 -78
View File
@@ -23,19 +23,17 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
- DNS server for LAN clients: UDP/53, TCP/53, DoH server, DoT server.
- Upstream resolution: DoH (HTTP/1.1), DoT.
- Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed).
- Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- Domain filtering: blocklists (hosts/domains/ABP), custom rules (allow/block; exact, parent-walk, wildcard), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
- Ops: DB-as-truth config, `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
### 2.2 Out of Scope (permanent scope decisions, not deferrals)
- Regex from downloaded blocklists. A list is other people's code running on the household's DNS, and the engine exists for rules the operator wrote. Regex lines in blocklists stay counted and skipped, and the skip count stays surfaced in the UI. (Operator regex rules themselves are **in** scope as of milestone 21: the ReDoS objection that once ruled them out is answered by `filter/regex.zig`, a homegrown Pike VM whose running time is bounded by program length × name length by construction, with no dependency and no backtracking. It is reached only after every hash and wildcard level has missed, and only on a cache miss.)
- ABP syntax beyond domain anchors and `@@||name^` exceptions. `$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser modifier stay unsupported and counted; the one tolerated modifier is a `$important` suffix on an exception line, which changes nothing about where that exception lands.
- Partial-segment wildcards (`ads*.example.com`) as a rule kind. The regex kind covers the need without a second globbing dialect.
- Regex rules. Wildcards + parent-walk cover the real use cases; regex on the DNS hot path means ReDoS exposure plus an immature dependency or a homegrown engine. Regex lines in blocklists are counted, skipped, and the skip count is surfaced in the UI.
- DHCP server.
- DNSSEC validation (DO bit passthrough only).
- DoQ (QUIC), HTTP/2 upstream transport.
@@ -76,8 +74,8 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
### 3.5 Config Format + Truth Model (Decision F)
- **The invocation picks truth (see the next point). Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical round-trip path, bought for syntax familiarity.
- The invocation picks the authority, and nothing else does (m20): `nxdns run` makes the database the configuration, `nxdns run --config FILE` makes the file the sole source and refuses the API routes that would edit configuration. There is no seeding and no first-start special case.
- **DB is truth. Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical bootstrap/round-trip path, bought for syntax familiarity.
- First start: if DB empty and `/etc/nxdns/config.zon` exists, validate → seed DB. Subsequent starts ignore the file.
- `nxdns export [--out file.zon]` dumps DB state as canonical ZON. `nxdns import <file.zon>` validates + replaces DB contents (`--force` if the DB holds configuration; client rows materialised from traffic do not count, and survive the replacement. First-seen/last-seen are runtime state, not configuration: they follow the address, so an import never restamps a device the DB already knew). Export/import = backup + host migration, **not** upgrades (§3.7).
- No file watcher, no auto-regeneration.
@@ -91,21 +89,21 @@ Two SQLite files with opposite write profiles, isolated from each other:
### 3.7 Upgrades: Auto-Migration (Decision J)
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart.
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
### 3.8 Blocklist Storage (Decision A)
Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state:
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines and browser-syntax lines nxdns cannot translate into a DNS decision are counted + skipped, both counts in metadata → UI. The checksum covers the three bodies in that order, each followed by a separator byte so that moving a name between bodies — an upstream switching `a.example` to `*.a.example` — changes the digest and forces a republish.
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, count, checksum). Wildcard/regex-flavored lines: wildcards go to `<source_id>.wild`; regex lines are counted + skipped (count in metadata → UI).
- `config.db` keeps source **metadata only** (`blocklist_sources`).
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5).
- Corruption recovery is per-file: checksum mismatch → re-download one list.
### 3.9 Rule Model (Decision B)
Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`), `regex` (the linear-time engine of `filter/regex.zig`, matched unanchored against the whole normalized name). Actions: `allow` | `block`. Kind is an explicit column, so a fourth kind widens one `CHECK` and touches no other table. A regex pattern is stored exactly as written — it is not a name, so it is never lowercased or dot-stripped — and is compiled at both edges: `config/validate.zig` refuses a bad one with the limit it hit, and `filter/rules.zig` compiles it once per snapshot.
Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`). Actions: `allow` | `block`. Kind is an explicit column — the model is extensible without breakage, but regex stays out of scope (§2.2).
### 3.10 Filtering Precedence
@@ -113,25 +111,11 @@ Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*`
2. Exact/parent **block** rules
3. Wildcard allow rules
4. Wildcard block rules
5. Regex allow rules
6. Regex block rules
7. Blocklist exceptions (`@@||name^`)
8. Blocklist domains
9. Blocklist wildcards
5. Blocklist domains
6. Blocklist wildcards
Tie-break at same specificity: **allow wins**.
Each level is checked against the whole candidate chain before the next level is
checked against any, which is what makes an allow rule on a parent beat a block
rule on the child.
Two positions carry an argument rather than a preference. The regex levels come
last among the operator rules because they are the only ones that are not a set
lookup or a label walk: a regex runs only once every cheaper level has missed.
Blocklist exceptions come below **every** operator level because a downloaded
list may cancel what another list blocked and must never cancel what the
operator decided — no list can open an allow hole the operator did not open.
### 3.11 Network Posture
- Default web bind: LAN/Tailscale-friendly (non-loopback allowed).
@@ -144,14 +128,14 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.13 Filesystem Layout (FHS)
- `/etc/nxdns/config.zon`the declarative source, read only when `run --config` names it. A file no flag names changes nothing.
- `/etc/nxdns/config.zon`bootstrap (first start only).
- `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db`
- `/var/lib/nxdns/blocklists/*.list|*.wild|*.allow` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp|*.allow.tmp` during a refresh)
- `/var/lib/nxdns/blocklists/*.list|*.wild` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp` during a refresh)
- `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald.
### 3.14 Frontend Stack (Decision I)
- Vite + React + TypeScript + StyleX + React Aria; TanStack Router + TanStack Query. SPA, no SSR.
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR.
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
@@ -189,7 +173,7 @@ Client DNS Query
Response to client
```
Cross-cutting: `ConfigManager` (load/reconcile/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics).
Cross-cutting: `ConfigManager` (bootstrap/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics).
---
@@ -219,8 +203,7 @@ src/
filter/ # pure
matcher.zig rules.zig wildcard.zig
parser_hosts.zig parser_domains.zig parser_abp.zig
fetcher.zig compiler.zig # list download -> compiled .list/.wild/.allow files
regex.zig # linear-time Pike VM for operator regex rules
fetcher.zig compiler.zig # list download -> compiled .list/.wild files
safesearch.zig
local/ # pure
@@ -240,8 +223,7 @@ src/
logger.zig retention.zig disk_monitor.zig
config/
model.zig loader.zig reconcile.zig import.zig export.zig # all ZON via std.zon
validate.zig faults.zig
model.zig bootstrap.zig import.zig export.zig validate.zig # all ZON via std.zon
web/
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
@@ -250,7 +232,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + StyleX + React Aria + TanStack
web/ # Vite + React + TS + Tailwind + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/
@@ -294,16 +276,13 @@ For `{domain, group_id}` (the qtype travels with the query for logging and respo
for matching):
1. Normalize: lowercase, trim trailing dot.
2. Build candidate chain (full, parent1, parent2, …).
3. Explicit rules per §3.10 precedence, allow before block at each level: exact rules against every candidate in the chain, then wildcard patterns and then regex patterns against the whole name (both kinds express their own reach, so neither walks the chain).
4. Group's blocklist exceptions (`@@` entries), against every candidate in the chain. They cancel a block a list made and never one a rule made.
5. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
6. Group's blocklist wildcards, matched against every proper parent of the query name.
7. No match → allow.
3. Explicit rules per §3.10 precedence, evaluated against every candidate in the chain.
4. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
5. Group's blocklist wildcards, matched against every proper parent of the query name.
6. No match → allow.
Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard
entries match every proper parent, and exception entries walk the candidate chain the way rules do
(§3.9), so `@@||good.ads.example^` also lifts `y.good.ads.example`. ABP `||x.y^` emits both a domain
entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
Blocklist entries do not parent-walk; only rules do (§3.9). ABP `||x.y^` emits both a domain entry
`x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
### 7.2 Group Assignment
@@ -362,16 +341,7 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
`journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set.
### 11.2 config.db Schema (v1 baseline)
The DDL below is the live schema, kept byte-identical to
`src/storage/config_schema.zig`. `src/storage/migrations.zig` carries it as its
one and only step, so a database is at **version 1** or it does not exist.
Until nxdns reaches v0.1 this baseline is **editable**: a schema change edits
this section and `config_schema.zig` together and adds no migration step. nxdns
has no installs, so there is no database for a step to reconcile. At v0.1 the
baseline freezes and every later change becomes an append-only step.
### 11.2 config.db Schema
```sql
CREATE TABLE schema_version (version INTEGER NOT NULL);
@@ -404,8 +374,7 @@ CREATE TABLE upstreams (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
priority INTEGER NOT NULL DEFAULT 100,
enabled INTEGER NOT NULL DEFAULT 1,
tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE blocklist_sources (
@@ -417,9 +386,7 @@ CREATE TABLE blocklist_sources (
last_updated INTEGER,
domain_count INTEGER NOT NULL DEFAULT 0,
wildcard_count INTEGER NOT NULL DEFAULT 0,
exception_count INTEGER NOT NULL DEFAULT 0,
skipped_regex_count INTEGER NOT NULL DEFAULT 0,
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
checksum TEXT
);
@@ -433,7 +400,7 @@ CREATE TABLE rules (
id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
action TEXT NOT NULL CHECK(action IN ('allow','block')),
created_at INTEGER NOT NULL
);
@@ -504,24 +471,52 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
## 12. Configuration
### 12.1 Config ZON Shape
The canonical shape is not duplicated here. It lives in
[docs/reference/configuration.md](docs/reference/configuration.md), which is
handwritten against `config/model.zig` and only partly guarded (the drift test
covers settings-key rows, not the whole shape, so a new collection can go
undocumented while the guard stays green), and `nxdns export` emits it. A copy in
this document is how §12.1 came to describe an `.upstream.servers` field that
never existed and to omit the required `.groups` and `.upstreams` — a sample
nobody could load. The skeleton, for orientation only:
### 12.1 Bootstrap ZON Shape
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
.rules = .{
.{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
.upstream = .{
.servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" },
.read_timeout_ms = 3000,
},
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
.blocking = .{ .response = .zero, .ttl = 5 }, // .zero | .nxdomain
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
.web = .{
.enabled = true,
.bind = "0.0.0.0",
.port = 8080,
.password = "", // empty => auth disabled
.session_ttl_hours = 24,
.api_rate_limit_per_min = 300,
.sse_max_connections_per_ip = 3,
},
.doh_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 443,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.dot_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 853,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.edns = .{ .ecs_mode = .strip }, // .strip | .forward
.local_records = .{ .{ .name = "nas.lan", .rtype = .A, .value = "192.168.1.10" } },
.forward_zones = .{ .{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" } },
.logging = .{
.level = .info,
.retention_days = 30,
.query_log_buffer_max = 10000,
.hide_domains = false,
.hide_client_ips = false,
.output = .stderr, // .stderr | .syslog | .file
.file_path = "/var/log/nxdns/nxdns.log",
.max_size_mb = 50,
.max_files = 5,
},
.disk = .{ .min_free_mb = 200, .warn_free_mb = 500 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
}
```
@@ -597,11 +592,11 @@ UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + he
Exit: A/AAAA forwarding over UDP + TCP; health populated.
### Phase 4 — Storage + Config
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`.
Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON bootstrap + import/export; `nxdns check`.
Exit: first start seeds DB from ZON; export → import round-trips byte-stable.
### Phase 5 — Filtering + Local DNS
Rule matcher (exact/parent/wildcard; `regex` added in m21); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones.
Rule matcher (exact/parent/wildcard); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones.
Exit: precedence table validated by tests; local zone answers + conditional forwards work.
### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor
@@ -628,7 +623,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
## 17. Testing Strategy
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON bootstrap + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
@@ -702,7 +697,7 @@ longer the only path.
| # | Decision |
|---|----------|
| A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only |
| B | Rule kinds: exact, parent-walk, wildcard, regex (m21, own linear-time engine). Regex *from downloaded lists* stays out of scope |
| B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope |
| C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering |
| D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client |
| E | `std.Io` injected everywhere; `Threaded` backend (io_uring flag dropped in m11 — Evented networking is stubbed at 0.16.0); no custom thread pool |
+6 -17
View File
@@ -1,25 +1,14 @@
# nxdns
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
forwards the rest over an encrypted connection.
```mermaid
flowchart LR
devices["Your devices"] -- "DNS query" --> nxdns["nxdns"]
nxdns -- "answer" --> devices
nxdns -- "allowed" --> upstream["Upstream resolvers<br/>DoH / DoT"]
upstream -- "answer" --> nxdns
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
```
One static Zig binary. SQLite holds the state and the query log, which the web
UI, the REST API and `/metrics` read.
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what.
## Features
- Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists —
whose `@@` exception lines are honoured — plus your own allow and block rules,
exact, wildcard (`*.example.com`) or regular expression
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`)
- Two configuration modes: a database the web UI edits, or a ZON file you keep
in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and
-9
View File
@@ -157,15 +157,6 @@ pub fn build(b: *std.Build) void {
.import_module = sourceModule(b, target, optimize, "src/web/http_util.zig"),
});
// `src/filter/regex.zig` imports only std (milestone-21 ruling 5), so its
// fuzz module roots directly at the file as well.
addFuzzSuite(b, target, optimize, fuzz, test_step, .{
.name = "regex-fuzz",
.root = "tests/fuzz/regex_fuzz.zig",
.import_name = "regex",
.import_module = sourceModule(b, target, optimize, "src/filter/regex.zig"),
});
// The bench harness (milestone-12 ruling 1). The measured roots
// (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative
// import closures (model.zig, types.zig, ...), and a file may belong to
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.2",
.version = "0.0.1",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+1 -1
View File
@@ -29,7 +29,7 @@ Directories:
| Directory | Role |
|---|---|
| `src/dns/` | Pure DNS wire format: header, names, questions, records, whole packets, EDNS(0)/ECS (`edns.zig`), enums and limits (`types.zig`). No allocation, no `std.Io` beyond writing to a caller's writer. |
| `src/filter/` | Blocklist pipeline: line parsers (hosts, domains, ABP), the compiler that turns a downloaded list into `.list`/`.wild`/`.allow` bodies, `domain_set.zig` (exact-match set, no Bloom filter), `matcher.zig` (the immutable snapshot every query evaluates against), per-group `rules.zig`, `wildcard.zig`, `regex.zig` (a Pike VM for the operator's regex rules, linear-time by construction), `safesearch.zig`, blocked-response synthesis (`response.zig`). Two I/O edges live here too: `fetcher.zig` (HTTP download) and `manager.zig` (files + DB + snapshot swap). |
| `src/filter/` | Blocklist pipeline: line parsers (hosts, domains, ABP), the compiler that turns a downloaded list into `.list`/`.wild` bodies, `domain_set.zig` (exact-match set, no Bloom filter), `matcher.zig` (the immutable snapshot every query evaluates against), per-group `rules.zig`, `wildcard.zig`, `safesearch.zig`, blocked-response synthesis (`response.zig`). Two I/O edges live here too: `fetcher.zig` (HTTP download) and `manager.zig` (files + DB + snapshot swap). |
| `src/local/` | Local DNS records and conditional forward zones: immutable lookup tables built once from DB rows (`records.zig`, `forward_zones.zig`), plus the plain UDP/TCP client for LAN resolvers (`forward_client.zig`). |
| `src/cache/` | `dns_cache.zig`: bounded in-memory TTL cache of whole response messages, keyed by the question. The clock arrives as a parameter. |
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
+1 -1
View File
@@ -99,7 +99,7 @@ nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
```
```
info(migrations): config.db migrated from schema version 0 to 1
info(migrations): config.db migrated from schema version 0 to 2
imported /tmp/nxdns-lab/backup.zon
```
+1 -1
View File
@@ -211,7 +211,7 @@ why the container is `docker-nxdns-1`.
A healthy first start logs the reconcile, the authority and the bound sockets:
```
info(migrations): config.db migrated from schema version 0 to 1
info(migrations): config.db migrated from schema version 0 to 2
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
web authentication is now enabled
+3 -8
View File
@@ -184,13 +184,8 @@ was written before the first start.
0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a
`ConfigurationDirectory`, which systemd leaves owned by root, and the service
runs as `nxdns`. A root-owned 0600 file would be unreadable to it.
Keep that group read bit for good, not just for the first boot. Under
`run --config` the service reads this file on **every** start, so tightening
the mode later breaks the next restart. Under database authority it is
`nxdns import` that reads the file, as whoever runs that command, and a bare
`nxdns run` never reads it at all.
runs as `nxdns` and has to read this file on the first start. A root-owned 0600
file would be unreadable to it.
Do not expect `nxdns check` to catch a permissive mode here. Its only
permission warning is for a TLS private key
@@ -225,7 +220,7 @@ nxdns import /etc/nxdns/config.zon
```
```
info(migrations): config.db migrated from schema version 0 to 1
info(migrations): config.db migrated from schema version 0 to 2
imported /etc/nxdns/config.zon
```
+7 -10
View File
@@ -41,8 +41,8 @@ zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000
nxdns bench suite=filter domains=100000 iters=20000 seed=0x5eed optimize=ReleaseFast
suite ops p50(us) p95(us) p99(us) max(us)
filter 20000 2.38 2.76 2.88 20.32
blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
filter 20000 0.14 0.25 0.27 0.51
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
@@ -60,8 +60,8 @@ zig build bench -Doptimize=ReleaseFast -- cache --iters=20000
```
suite ops p50(us) p95(us) p99(us) max(us)
cache 20000 0.14 0.18 0.20 5.14
hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.3 MiB
cache 20000 0.12 0.21 0.23 0.54
hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.2 MiB
target p95 < 5ms: PASS
```
@@ -71,7 +71,7 @@ zig build bench -Doptimize=ReleaseFast -- compile --domains=100000
```
suite ops p50(us) p95(us) p99(us) max(us)
compile 100000 wall 15.623ms, 6400464 lines/s, 100000 domains kept (informational)
compile 100000 wall 11.512ms, 8686215 lines/s, 100000 domains kept (informational)
```
`--seed=N` changes the generated domains and the query order; the default is
@@ -94,9 +94,6 @@ usage: zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [--d
is building the key, getting the entry and stamping the response id.
- `blocked N/M` and `hits N/M` are sanity counters. The harness aborts if either
is zero — a suite that never hits its own path measures nothing.
- `32 regex rules` on the `filter` line is the rule set the suite loads. No
generated query matches any of them, so every operation runs all 32 programs
to their end, which is the costly case and the one worth measuring.
- Two memory figures appear on purpose. `Snapshot.memoryBytes` and
`DnsCache.memoryBytes` are the in-repo accounting of those structures; `VmRSS`
is what the kernel holds resident for the whole process, allocator slack and
@@ -124,8 +121,8 @@ zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000
```
```
filter 20000 2.34 2.71 2.85 15.06
blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
filter 20000 0.14 0.26 0.27 2.42
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
+1 -19
View File
@@ -401,30 +401,12 @@ snapshot loaded but has no sources in it. The line
a source in the admin interface, or a `blocklist_sources` entry to the
configuration file with a `group_sources` link naming a group.
One name resolving while its neighbours are blocked is a third case, and
`/api/lookup` answers it directly: it reports which level of the filtering
ladder decided, and against what.
```sh
curl -s 'http://127.0.0.1:8080/api/lookup?domain=api.ads.tvb.com'
```
```json
{"domain":"api.ads.tvb.com","group_id":1,"local_records":false,"forward_zone":null,"blocked":false,"reason":"blocklist_exception","matched":"api.ads.tvb.com","source_url":"https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt","safe_search_rewrite":null}
```
`blocklist_exception` means a downloaded list lifted that name with an `@@`
line, and `source_url` names the list that did it. Nothing is broken, and the
list is not overruling you: an exception cancels only what another list blocks.
Your own rule wins over it. Adding an exact block rule for the same name and
asking again reports `rule_block_exact`, `blocked` true and a null `source_url`.
## A database stamped by a newer binary
**Symptom.** After putting an older binary back, it will not start:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2
nxdns run failed: SchemaTooNew
```
+10 -9
View File
@@ -307,7 +307,7 @@ opens the database immutable and never migrates, so on a database still one
version behind it reports the mismatch and exits 2 rather than fixing it:
```
FAIL /var/lib/nxdns/config.db: schema version 0, this nxdns expects 1; `nxdns run` migrates it, `check` will not
FAIL /var/lib/nxdns/config.db: schema version 0, this nxdns expects 2; `nxdns run` migrates it, `check` will not
```
That line was reproduced here against a database stamped at version 0; the path
@@ -317,28 +317,29 @@ A fresh database is created at the current schema version; an older one is
stepped up to it. The log line names both versions:
```
info(migrations): config.db migrated from schema version 0 to 1
info(migrations): config.db migrated from schema version 0 to 2
```
> Verified on this host: that exact line is what `nxdns import` printed when it
> created the scratch database used throughout this page. An empty data
> directory is schema version 0, which is why a first run reports a migration
> rather than nothing. Version 1 is the only schema nxdns has published, so an
> upgrade from a populated older one is not a case that exists yet.
> rather than nothing. The step from a populated older schema to 2 was not
> reproduced here — it needs a database written by an older binary, which this
> host does not have.
Rolling back is the case that has no answer. A database stamped by a newer
binary refuses to open, so an older binary against an upgraded data directory
fails to start:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2
nxdns run failed: SchemaTooNew
```
> Reproduced on this host, with one substitution: no binary from the future was
> available, so the scratch database's `schema_version` row was set to 99 by
> hand and `nxdns run` was pointed at it. The two lines above are that run's
> output.
> Not reproduced on this host: the same missing ingredient as above, a
> database at a schema version this binary does not support. The two lines
> are the messages `src/storage/migrations.zig` emits, not a run captured
> here.
That run exits 1. Recovering means importing the export you took in step 1 into
a fresh data directory with the older binary.
-28
View File
@@ -277,31 +277,3 @@ included. The password then lives where the rest of the configuration lives: set
Request and response schemas for every operation live in the OpenAPI document:
`src/web/openapi.yaml` in the repository, or `GET /api/openapi.yaml` from a
running server.
### Block reasons
Three places carry the same tag: `block_reason` on a `GET /api/queries` row,
`block_reason` on a live-stream frame, and `reason` on a `GET /api/lookup`
answer. The tag names the level that decided the query, and the levels are
listed here in the order they are consulted — the first one that matches wins,
so a rule always outranks a list.
| Tag | Decided by |
| --- | --- |
| `rule_allow_exact` | An `exact` rule with action `allow` |
| `rule_block_exact` | An `exact` rule with action `block` |
| `rule_allow_wildcard` | A `wildcard` rule with action `allow` |
| `rule_block_wildcard` | A `wildcard` rule with action `block` |
| `rule_allow_regex` | A `regex` rule with action `allow` |
| `rule_block_regex` | A `regex` rule with action `block` |
| `blocklist_exception` | An `@@` exception line in a downloaded list |
| `blocklist_domain` | A plain name in a downloaded list |
| `blocklist_wildcard` | A domain anchor (`||name^`) in a downloaded list |
`/api/lookup` also answers `none` when nothing matched. A query row never
carries `none`: `block_reason` is null unless the query was blocked.
A `cname:` prefix means the decision landed on a CNAME target rather than on
the name the client asked for, so `cname:blocklist_domain` reads as "the list
blocks a name this answer redirects to". Only `/api/queries` and the live
stream show the prefix; `/api/lookup` does not follow CNAMEs.
+2 -2
View File
@@ -62,8 +62,8 @@ reconciled '/etc/nxdns/config.zon': no changes
Blocklist state is not declarative and survives every reconcile: a source whose
URL the file still names keeps its row id, its checksum, its counters and its
compiled `<id>.list`, `<id>.wild` and `<id>.allow`, so a restart in file mode
downloads nothing. Editing a source's URL is a new identity — a new row, a new id, and a
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads
nothing. Editing a source's URL is a new identity — a new row, a new id, and a
fresh download.
### Failing to start in file mode
+4 -62
View File
@@ -279,29 +279,6 @@ Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by the
fetcher and compiled into domain sets. A disabled source is neither downloaded
nor loaded.
A list in Adblock Plus syntax may also carry exception lines, `@@||name^` and
`@@||name`, either of which may end in `$important`. Those become allow entries
that cancel what any attached list blocks, for the name and its subdomains. They
cancel nothing an operator decided: every rule of the table above is checked
first, so a downloaded list can reopen only a hole another downloaded list dug.
Each source reports how many it carried as `exceptions`; there is no way to write
one by hand, and no reason to want one — write an allow rule instead.
Two counters report what a compile skipped, and they are different facts.
`skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
patterns only from the operator, so a regex line in a downloaded list is counted,
skipped and surfaced — adopt the ones you trust as `regex` rules.
`skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$`
modifier (except `$important` on an exception line, tolerated above), scheme
anchors, non-anchored `@@` forms — and, in a `domains`-format
list, a line holding more than one field before its inline comment, which usually
means the list is really a hosts file that was declared as `domains`. Neither is
an error, and the two are never one number. A large `skipped_unsupported` beside
a small `domain_count` usually means the list is written for browser extensions,
and its DNS or hosts variant will block more here. Both appear per source in the
blocklists UI and on `/api/blocklists`.
### group_sources
Which groups consult which blocklist sources.
@@ -322,48 +299,15 @@ Per-group allow and block overrides, checked before the blocklists.
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` \| `.regex` | required | — |
| `kind` | enum `.exact` \| `.wildcard` | required | — |
| `action` | enum `.allow` \| `.block` | required | — |
Pattern rules: an `.exact` pattern is a plain domain name and may not contain
`*`. A `.wildcard` pattern must contain at least one label that is exactly `*`
(`*.tracker.example`, or `*` alone), and every other label must be a legal DNS
label. `ads*.example` is not a valid wildcard; a partial label is what the
`.regex` kind is for.
label. `ads*.example` is not a valid wildcard.
A `.regex` pattern is a regular expression matched against the whole normalized
lowercase name, unanchored unless you write `^` or `$` — the POSIX-grep
convention. It is stored exactly as you typed it, which the other two kinds are
not: lowercasing would turn `\D` into `\d`, and trimming a trailing `.` would
delete an any-byte atom. The engine (`src/filter/regex.zig`) accepts literal
bytes, `.` for any byte, character classes `[a-z0-9]` with a leading `^` for
negation, the escapes `\d` and `\w` plus `\` before any other ASCII punctuation
to make it a literal, the repetitions `*` `+` `?` `{n}` `{n,m}` `{n,}`,
alternation `|`, grouping `(...)`, and the anchors `^` and `$`.
Everything else is refused at the edge rather than approximated, so a pattern
written for another engine fails where you can read the diagnostic instead of
silently matching names you did not mean:
- backreferences, lookaround, captures, named groups, Unicode classes and the
`(?…)` prefix they share;
- any alphanumeric escape the list above omits — `\s`, `\b`, `\1`, `\D`;
- a `]` inside a class, unless written `\]`;
- an empty pattern, and an empty branch: `ads|` is refused rather than read as a
pattern that matches every name;
- a quantifier applied straight to another quantifier: `a+?` is refused rather
than read as `(a+)?`, which matches every name. Write `(a+)?` to mean that.
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each
limit with its own diagnostic, and one group holds at most 256 regex rules.
Groups do not capture, and the engine simulates every alternative in lockstep,
so a pattern costs at most its compiled length times the length of the name —
`(a+)+b` is as cheap here as it is expensive in a backtracking engine.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`), which checks
the three kinds in the order they are listed above, allow before block within
each. Regex is checked last of the three because it is the only kind that costs
more than a hash lookup or a label walk.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`).
### local_records
@@ -604,12 +548,10 @@ upstream. Everything else keeps its default.
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
},
// Overrides beat blocklists. Wildcards need a label that is exactly "*";
// a partial label takes a regex, which is unanchored unless you say "^".
// Overrides beat blocklists. Wildcards need a label that is exactly "*".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
.{ .group = "kids", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
},
// Local names, answered without any upstream.
+8 -10
View File
@@ -46,21 +46,19 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
| `blocklists/<id>.allow` | Exception entries for the same source: the names its `@@` lines lift. Absent on a source compiled before exceptions were honoured, which reads as empty. | 0600 |
| `blocklists/<id>.raw.tmp`, `<id>.list.tmp`, `<id>.wild.tmp`, `<id>.allow.tmp` | Transient refresh state: the downloaded body and the three compile outputs before they are published by rename. | 0600 |
| `blocklists/<id>.raw.tmp`, `<id>.list.tmp`, `<id>.wild.tmp` | Transient refresh state: the downloaded body and the two compile outputs before they are published by rename. | 0600 |
`<id>` is the `blocklist_sources` row id.
### The orphan sweep
The sweep decides by id, not by suffix. It matches all seven names above and
The sweep decides by id, not by suffix. It matches all five names above and
deletes those whose `<id>` is no longer a `blocklist_sources` row, so the
compiled `.list`, `.wild` and `.allow` of a removed source go, and so do a
`.raw.tmp`, `.list.tmp`, `.wild.tmp` or `.allow.tmp` left behind by a refresh
that was killed before it could clean up. Files belonging to a source that still
has a row are never touched, whatever state they are in: the sweep holds the same
lock every refresh takes, so it never reads the directory while a refresh is
part-way through.
compiled `.list` and `.wild` of a removed source go, and so do a `.raw.tmp`,
`.list.tmp` or `.wild.tmp` left behind by a refresh that was killed before it
could clean up. Files belonging to a source that still has a row are never
touched, whatever state they are in: the sweep holds the same lock every refresh
takes, so it never reads the directory while a refresh is part-way through.
It runs at three moments:
@@ -88,7 +86,7 @@ losing the refresh pass behind them, let alone the server.
The temporaries of a source that still exists are cleaned by the refresh that
owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`,
`.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
`.list.tmp` and `.wild.tmp` as it finishes, successfully or not.
A `querylog.db` is moved aside when it is missing nothing but usability:
SQLite reports it corrupt or not a database, `PRAGMA quick_check` does not
+8 -16
View File
@@ -27,7 +27,7 @@ the asset-free figure is a real measurement rather than an estimate.
## Measured: x86_64 development host
Date: 2026-08-13. Hardware and build: Intel Core i7-14700K, Linux 6.18,
Date: 2026-08-02. Hardware and build: Intel Core i7-14700K, Linux 6.18,
Zig 0.16.0, `-Doptimize=ReleaseFast`, harness defaults (1,000,000 domains,
200,000 iterations per suite, seed 0x5eed).
@@ -37,26 +37,18 @@ baseline for regressions on the machine development happens on.
```
suite ops p50(us) p95(us) p99(us) max(us)
filter 200000 2.41 2.80 2.97 22.16
blocked 66699/200000, 32 regex rules, Snapshot.memoryBytes 28.0 MiB, VmRSS 32.0 MiB
filter 200000 0.11 0.18 0.27 16.41
blocked 66699/200000, Snapshot.memoryBytes 28.0 MiB, VmRSS 31.8 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
cache 200000 0.11 0.17 0.22 5.40
hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.7 MiB
cache 200000 0.10 0.14 0.17 3.53
hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.6 MiB
target p95 < 5ms: PASS
compile 1000000 wall 98.597ms, 10142224 lines/s, 1000000 domains kept (informational)
compile 1000000 wall 96.025ms, 10413949 lines/s, 1000000 domains kept (informational)
```
Every in-process §18 target passes on this host: the filter target by about
360x, the cache target by about four orders of magnitude, the memory target by
about 3x.
The filter suite loads 32 regex rules that no query in the mix matches, which is
the expensive case rather than the cheap one: the regex levels sit below every
hash and wildcard level, so a name no pattern matches is the name that runs all
32 programs to their end. Every op pays that, which is what moved the filter p95
from 0.18 µs before regex rules existed to the 2.80 µs above. The margin against
the 1 ms target is what makes paying it on every miss an acceptable price.
Every in-process §18 target passes on this host: the two latency targets by
three to four orders of magnitude, the memory target by about 3x.
### The two memory figures
+25 -45
View File
@@ -9,13 +9,13 @@ delete the directory.
Follow the steps in order. Each one says what it did.
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 2, 4 to 11, 13 and 14 were re-run
end to end for this revision, and the transcripts are that run's output with the
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
to end for this revision, and the transcripts are that run's output with the
tutorial directory substituted. Two things were not re-run: the browser page in
step 12 — its endpoints were exercised, the page itself was not opened — and the
`npm` build in step 1, whose `web/dist` was already on disk and is the one the
binary under test embeds. The ZON block at the end of step 14 was checked with
`nxdns check --config` rather than started.
two build commands in steps 1 and 2, which had already produced the binary under
test. The ZON block at the end of step 14 was checked with `nxdns check
--config` rather than started.
## What you need
@@ -109,7 +109,7 @@ zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial
```
```
info(migrations): config.db migrated from schema version 0 to 1
info(migrations): config.db migrated from schema version 0 to 2
imported /home/you/nxdns-tutorial/config.zon
```
@@ -127,7 +127,7 @@ zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
```
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
@@ -146,8 +146,8 @@ dig @127.0.0.1 -p 15353 example.com A +noall +answer
```
```
example.com. 229 IN A 172.66.147.243
example.com. 229 IN A 104.20.23.154
example.com. 90 IN A 172.66.147.243
example.com. 90 IN A 104.20.23.154
```
nxdns had no answer cached, so it forwarded the query to
@@ -220,30 +220,20 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
```
```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0,"skipped_unsupported":0}]}
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
```
The four zeros describe this particular download, not the hosts format.
`wildcards` counts entries covering a name and its subdomains, `exceptions`
counts the `@@` lines an Adblock Plus list uses to lift a name another list
blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a
downloaded list. `skipped_unsupported` counts lines nxdns cannot translate into a
DNS decision, and a large value next to a small `domains` means the list targets
browsers rather than DNS. Only `exceptions` is Adblock-Plus-only: a hosts list
can carry regex lines, `*.`-prefixed wildcards, and bare sink addresses that
count as unsupported. This one carries none of them.
The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears:
```
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 2523973 bytes
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes
```
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist
snapshot in full and swaps it in atomically, so queries keep being answered from
the previous snapshot the whole time the new one is being built. After this, the
domain count in the JSON above — 97648 on the day this was run — is live.
domain count in the JSON above — 99559 on the day this was run — is live.
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
Restarting nxdns does not re-download it.
@@ -269,18 +259,16 @@ dig @127.0.0.1 -p 15353 wikipedia.org A +noall +answer
```
```
wikipedia.org. 130 IN A 185.15.58.224
wikipedia.org. 17 IN A 185.15.58.224
```
One thing to know before you try other names: an entry in a hosts list blocks
exactly the name it names. `doubleclick.net` is on this list, and so are
`ad.doubleclick.net` and `www.google-analytics.com`. `ads.doubleclick.net` is
not on it, and nxdns does not block it — the entry for the parent says nothing
about the child. Two things do walk up the parent chain, and neither is in play
here: a rule you write yourself, with a wildcard pattern such as
`*.doubleclick.net`, and an Adblock Plus list's `||doubleclick.net^`, which
covers the name and everything under it. This list is a hosts file, so when you
pick a domain to test against it, pick one that is literally in the file.
One thing to know before you try other names: a blocklist entry blocks exactly
the name it names. `doubleclick.net` on the list does not block
`ads.doubleclick.net`; that name is blocked because the list happens to contain
it too. Blocklist entries do not walk up the parent chain — only rules you write
yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you
pick a domain to test, pick one that is literally in the file.
`www.google-analytics.com` is another that is.
## 12. Open the web interface
@@ -303,7 +291,7 @@ nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 6 and read the first log lines:
```
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 2523973 bytes
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
info(nxdns): authority: database
```
@@ -326,12 +314,8 @@ zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial
```
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(blocklist_manager): pruned orphaned blocklist file 1.allow
info(blocklist_manager): pruned orphaned blocklist file 1.wild
info(blocklist_manager): pruned orphaned blocklist file 1.list
```
**Read that first line.** The blocklist source is gone. That is not a bug — it is
@@ -340,12 +324,8 @@ source, and in file mode the file is the complete statement of what the
configuration is, so anything the database holds that the file does not name is
removed at every start. The reconcile said so in one line before doing it.
The three `pruned` lines are the rest of that removal: with the row gone, the
compiled files it owned belong to nobody, so the sweep that runs at every start
deletes them. The three names are the three bodies one source compiles into —
exact domains, wildcards, and the exceptions an Adblock Plus list can lift. The
query log is untouched; what changed is the configuration, and it now matches
the file exactly.
The compiled list is still on disk and the query log is untouched; what changed
is the configuration, and it now matches the file exactly.
Neither mode is the "advanced" one. Database mode suits a box someone
administers through the web interface. File mode suits a file kept in git and
@@ -365,7 +345,7 @@ Ctrl-C to stop it.
## What you have now
A resolver that answers real queries, a real blocklist of about 98000 domains
A resolver that answers real queries, a real blocklist of about 99000 domains
attached to the default group, a query log, and a web interface — all inside one
directory you can delete:
-9
View File
@@ -1,9 +0,0 @@
MIT License
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1 -24
View File
@@ -39,12 +39,6 @@ mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
[npm runtime closure]
@internationalized/date 3.12.3 Apache-2.0
@internationalized/number 3.6.7 Apache-2.0
@internationalized/string 3.2.10 Apache-2.0
@react-types/shared 3.36.1 Apache-2.0
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@tanstack/history 1.162.0 MIT
@tanstack/query-core 5.101.4 MIT
@tanstack/react-query 5.101.4 MIT
@@ -52,37 +46,24 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@tanstack/react-store 0.9.3 MIT
@tanstack/router-core 1.171.15 MIT
@tanstack/store 0.9.3 MIT
aria-hidden 1.2.6 MIT
client-only 0.0.1 MIT
clsx 2.1.1 MIT
cookie-es 3.1.1 MIT
css-mediaquery 0.1.2 BSD
invariant 2.2.4 MIT
isbot 5.2.1 Unlicense
js-tokens 4.0.0 MIT
loose-envify 1.4.0 MIT
react 19.2.8 MIT
react-aria 3.51.0 Apache-2.0
react-aria-components 1.20.0 Apache-2.0
react-dom 19.2.8 MIT
react-stately 3.49.0 Apache-2.0
scheduler 0.27.0 MIT
seroval 1.5.6 MIT
seroval-plugins 1.5.6 MIT
styleq 0.2.1 MIT
tslib 2.8.1 0BSD
use-sync-external-store 1.6.0 MIT
[npm build-time generators whose output ships]
rolldown 1.1.5 MIT
tailwindcss 4.3.3 MIT
vite 8.1.5 MIT
[container base image]
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
[npm packages bundled into web/dist]
@internationalized/string
@stylexjs/stylex
@tanstack/history
@tanstack/query-core
@tanstack/react-query
@@ -90,11 +71,7 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
@tanstack/react-store
@tanstack/router-core
@tanstack/store
clsx
react
react-aria
react-aria-components
react-dom
react-stately
scheduler
use-sync-external-store
+10 -59
View File
@@ -39,42 +39,11 @@
// stay listed in licenses/dependency-identity.txt so that a future build which
// does pull them in trips the guard.
//
// Four more joined that list with the StyleX build integration: css-mediaquery,
// invariant, and loose-envify with js-tokens under it. @stylexjs/stylex
// declares them, but its published runtime imports none of them — a sourcemap
// build of a component calling stylex.props shows no trace of any of the four,
// so they are dead entries of the published manifest rather than code with a
// job here. css-mediaquery is the one that matters: it is BSD, and it reaching
// web/dist would force a licence decision this project has not made.
//
// Seven more joined that list with React Aria, and the sourcemap build is again
// what settled which: @internationalized/date, @internationalized/number,
// @react-types/shared, @swc/helpers, aria-hidden, client-only and tslib are in
// the runtime closure but contribute no module to any chunk. The date and number
// packages serve the calendar and number-field components ruling 4 keeps out of
// scope; @react-types/shared is types only and emits no runtime code;
// client-only is a build-time marker for React Server Components; @swc/helpers
// and tslib are downlevelling helpers the published ESM builds never reach for;
// and aria-hidden sits behind a react-aria path the four adopted components do
// not take. Their licences differ and are recorded one by one in
// src/licenses_drift_test.zig rather than lumped together: @internationalized/
// date, @internationalized/number, @react-types/shared and @swc/helpers are
// Apache-2.0, tslib is 0BSD, aria-hidden and client-only are MIT. Keeping all
// seven out of the bundle is a fact worth re-checking rather than assuming, so
// the guard fails if any of them turns up in a `sources` list.
//
// StyleX now ships: the converted pages call stylex.props, and a sourcemap build
// puts @stylexjs/stylex in the bundle. What that build settles is not the
// obvious answer, though: only @stylexjs/stylex appears as a bundled package,
// and styleq arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
// instead of importing it, so styleq's bytes ship while styleq never appears in
// a `sources` list — no guard would raise it, which is why it is written down
// here.
//
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
// plugin-react and typescript only transform our own sources (react-refresh is
// dev-server only, and tslib is optional and unused), lightningcss only
// minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// dev-server only, and tslib is optional and unused), @tailwindcss/vite
// orchestrates tailwindcss without contributing CSS of its own, lightningcss
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// never touch the build output.
.{
.{
@@ -125,36 +94,18 @@
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
.file = "tanstack-mit.txt",
},
.{
.component = "React Aria (react-aria-components, react-aria, react-stately, @internationalized/string)",
.version = "react-aria-components 1.20.0, react-aria 3.51.0, react-stately 3.49.0, @internationalized/string 3.2.10",
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
.file = "react-aria-apache-2.0.txt",
},
.{
.component = "clsx",
.version = "clsx 2.1.1",
.note = "The class-name joiner React Aria calls when it merges a render-prop class name with its own. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
.file = "clsx-mit.txt",
},
.{
.component = "StyleX",
.version = "@stylexjs/stylex 0.19.0",
.note = "The style runtime for the admin UI. Its published dist vendors styleq's source, so the styleq entry below covers bytes that arrive inside this package. The npm tarball ships no licence file of its own; this text is the LICENSE of facebook/stylex at tag 0.19.0, which is byte-identical to the one on main.",
.file = "stylex-mit.txt",
},
.{
.component = "styleq",
.version = "styleq 0.2.1",
.note = "The class-name merger StyleX calls at runtime. Never a bundled package in its own right: @stylexjs/stylex vendors its source into lib/es/stylex.mjs, so it reaches web/dist without appearing in any sourcemap `sources` list. Separate entry from StyleX: same MIT text, different copyright line.",
.file = "styleq-mit.txt",
},
.{
.component = "TanStack Store",
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
.file = "tanstack-store-mit.txt",
},
.{
.component = "Tailwind CSS",
.version = "tailwindcss 4.3.3",
.note = "A devDependency whose output ships: the admin UI stylesheet is generated from Tailwind's own CSS sources, so the generated CSS in the binary is a derivative of them.",
.file = "tailwindcss-mit.txt",
},
.{
.component = "Vite",
.version = "vite 8.1.5",
@@ -164,7 +115,7 @@
.{
.component = "Rolldown",
.version = "rolldown 1.1.5",
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/styles-*.js, which is embedded in the binary.",
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/classes-*.js, which is embedded in the binary.",
.file = "rolldown-mit.txt",
},
.{
+1 -4
View File
@@ -51,10 +51,7 @@ pub const texts: []const Text = &.{
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") },
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
-25
View File
@@ -1,25 +0,0 @@
React Aria: react-aria-components, react-aria, react-stately and
@internationalized/string, bundled into the admin UI JavaScript that is
embedded in every nxdns binary.
Copyright 2019 Adobe
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
these files except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
The full text of the Apache License, Version 2.0 is reproduced above, in the
Mbed TLS section of this file. The LICENSE file each of these four packages
ships is that same document. The Mbed TLS copy carries an eight-line project
preamble of its own; setting that aside, the two differ on one line, the sample
copyright inside the "APPENDIX: How to apply the Apache License to your work"
boilerplate, which is not part of the licence terms. None of the four ships a
NOTICE file, so Section 4(d) attaches nothing further.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,6 +1,6 @@
MIT License
Copyright (c) Nicolas Gallagher
Copyright (c) Tailwind Labs, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+35 -338
View File
@@ -71,11 +71,6 @@ order preserved; the on-disk header (manager.zig:221-241) gains
`# exceptions {d}` after the `# wildcards` line and the pinning test at
manager.zig:1914-1946 is extended, not weakened.
The "checksum over the `.list` body followed by the `.wild` body" sentence
exists in THREE places, not the one this ruling first named: `compiler.zig:38-39`,
`manager.zig:218` and `sources_repo.zig:100`. All three move together, or the
next reader trusts a stale one.
### 4. Exception counts persist and surface
Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`):
@@ -87,13 +82,8 @@ checksum doc line at sources_repo.zig:100 is updated alongside
`SourceStatus` rehydration (`manager.zig:1458-1502`) 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.
`skipped_regex` shows in TWO tables, and `exceptions` follows it into both:
`SourceStatus.skipped_regex` (`web/src/lib/types.ts:192`) renders at
`SourceStatusSection.tsx:112`, and `Blocklist.skipped_regex_count`
(`types.ts:163`) renders at `BlocklistsPage.tsx:188`. S1 therefore owns
`BlocklistsPage.tsx` as well.
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
@@ -101,25 +91,7 @@ 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.
**Two syntax amendments from S2's review**, both widening what is accepted:
- `{n,}` is legal. It lowers to `{n}` followed by `*`, stays linear, and an
over-large `n` still reports `PatternTooComplex`. Operators write this form;
rejecting it buys no safety.
- `\` before any ASCII punctuation yields that literal, not only the five
escapes listed above — `\*`, `\/` and `\+` occur in Pi-hole-style patterns.
This can only narrow a pattern to a literal, never silently change its
meaning. Escapes that WOULD change meaning stay rejected: any alphanumeric
escape outside `\d` and `\w` (`\s`, `\b`, `\1`, `\D`, `\p{L}`) is
`BadPattern`.
**One rejection the review added:** a quantifier applied directly to another
quantifier is `BadPattern`. `a+?` previously compiled as `(a+)?`, which
matches every name — a block rule written in conventional lazy syntax would
have sinkholed the whole LAN instead of being refused. Parenthesised forms
such as `(a+)?` stay legal and keep their meaning. Matching is unanchored unless anchors are written
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
@@ -134,19 +106,8 @@ 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.
**Amended in S2.** This ruling first said the thread lists live in the
`Program`, sized at compile time. They do not, and must not: the runtime is
`std.Io.Threaded`, so several query threads evaluate one shared snapshot at
once. Scratch inside a shared `Program` is a data race, and reaching it
through `*const Program` would need a `@constCast` that is undefined
behaviour on a genuinely const program. All VM scratch — both thread lists,
the admission marks and the closure stack — is instead a fixed array on the
caller's stack, sized by the compile-time `max_program_len` constant. Zero
allocation at match time is preserved, the published signature is unchanged,
and a `Program` becomes safe to share across threads, which the original
wording would have prevented. The engine is a fuzz-module root like parsers.zig and imports only
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
@@ -164,14 +125,7 @@ set, and a rename would fail both. `model.RuleKind`
validate.zig:891-902) 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, and the fixed
`var spans: [4]std.ArrayList(Span)` at rules.zig:55 widens with it;
`patternIsValid` (`validate.zig:1099-1103`) is declared
`error{OutOfMemory}!bool`, so a regex compile's `BadPattern`,
`PatternTooLong` and `PatternTooComplex` must either fold into `false` or
widen that error set together with its caller at validate.zig:893 — the
diagnostic text itself needs no edit, because validate.zig:898 already
interpolates `rule.kind.toDb()` into "{f} is not a valid {s} pattern";
`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
@@ -190,14 +144,7 @@ rules.zig:42 becomes `"kind must be 'exact', 'wildcard' or 'regex'"`.
`src/web/openapi.yaml:1948,1962,1976`: 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. Milestone 23 replaced the native `<select>` with a React
Aria wrapper, so that is now a data edit, not JSX: append to `KIND_OPTIONS`
at `RulesPage.tsx:15-18`, and extend the option-list assertion at
`RulesPage.test.tsx:119`. Any new test that opens the selector depends on the
`CSS.escape` polyfill in `web/vitest.setup.ts`. Contract samples are
regenerated with the AGENTS.md command
(`zig build test -Dintegration -Dcontract-samples-out=...`); no npm script
generates them. `nxdns export` / `import`
gains the option. Contract samples regenerated. `nxdns export` / `import`
round-trip the new kind with no extra work once `RuleKind.toDb/fromDb` extend
— the enum round-trip test at model.zig:782 is extended to prove it.
@@ -209,11 +156,7 @@ 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. PLAN.md:99 (§3.8) documents exactly two compiled bodies and names
`<source_id>.wild` as the second; ruling 3's third body makes it stale, so S1
updates that line when it lands the `.allow` body. PLAN.md:73 ("No
TOML/regex/HTTP packages needed") stays true and stays as written — a
homegrown engine adds no package. In-code echoes of the old §2.2 move with it:
2 and 6. In-code echoes of the old §2.2 move with it:
`src/filter/wildcard.zig:6-7,20-23`, `src/filter/parsers.zig:25`,
`src/filter/parser_abp.zig:5-6`, `src/filter/compiler.zig:129-131`.
@@ -262,10 +205,8 @@ Owns: `src/filter/parsers.zig`, `src/filter/parser_abp.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/*` (which includes `BlocklistsPage.tsx` per
ruling 4), `web/src/lib/types.ts` (source-stat fields
only), `web/src/lib/contractSamples.gen.ts`, `PLAN.md` (line 99 only, per
ruling 8 — S3 owns every other PLAN edit),
`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`, and the
dump-golden assertions in `src/config/import.zig` and
`src/config/reconcile.zig` test suites only (ruling 9's `exception_count`
@@ -281,9 +222,7 @@ fallout from `ddl_v3`; S1 touches no reconcile logic).
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. `migrations.zig:349` asserts
`target_version == 2`; S1 moves it to 3 (S3 moves it to 4). The spec did
not name that test; it fails otherwise.
- 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
@@ -294,21 +233,15 @@ fallout from `ddl_v3`; S1 touches no reconcile logic).
`filter_integration_test.zig` with a real ABP fixture carrying `@@` lines.
Acceptance (S1):
- [x] `zig build test` passes; fuzz targets build and run.
- [x] A fixture list with `||ads.example^` and `@@||good.ads.example^`
- [ ] `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. Also proven live against the AdGuard DNS filter, which
carries `||ads.tvb.com^` beside `@@||api.ads.tvb.com^`: `dig` sinkholed
`ads.tvb.com` and `x.ads.tvb.com` to 0.0.0.0 and resolved
`api.ads.tvb.com` normally.
- [x] A pre-milestone data directory (no `.allow` files, old checksums)
loads with zero checksum mismatches. Proven live by deleting a loaded
source's `.allow` and reloading.
- [x] `POST /api/blocklists/update` response rows carry `exceptions`
(live: `"domains":154667,"wildcards":154666,"exceptions":11,
"skipped_regex":21`).
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)
@@ -324,13 +257,9 @@ target only).
- S2.3 the fuzz target per ruling 10.
Acceptance (S2):
- [x] `zig build test` passes with the new file in `src/tests.zig`.
- [x] The step-bound property holds under the fuzz corpus. `expectLinear`
(`tests/fuzz/regex_fuzz.zig:101`) asserts
`steps <= program_len * (input_len + 1)` over the corpus that
`zig build test` replays. An interactive `--fuzz` session could not be
used as additional evidence — see the deviation below.
- [x] `regex.zig` imports nothing but `std`.
- [ ] `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)
@@ -351,32 +280,21 @@ ruling 9), `PLAN.md`, `src/filter/wildcard.zig` (comments),
- 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. The line to
change is `tools/bench.zig:158`, which currently reads `.rules = &.{}` — the
filter bench loads no rules at all today, so the variant is new coverage
rather than an edit to an existing rule set. S3 also moves
`migrations.zig:349` from 3 to 4.
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):
- [x] `zig build test` and `cd web && npm test` pass.
- [x] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}`
returns 201; with `"pattern":"("` returns 400 naming the pattern
(live: `{"error":"rules[0].pattern: '(' is not a valid regex pattern"}`).
- [x] A regex block rule blocks a matching name; `/api/lookup` reports
`rule_block_regex` and `matched` carries the pattern text (live:
`ad42-tracker.example.com` blocked, `matched` `^ad[0-9]+-`).
- [x] `zig build bench -Doptimize=ReleaseFast -- filter` passes its targets
with the regex variant present (32 regex rules; p95 2.89 µs against a
1 ms target; VmRSS 32.0 MiB against a 100 MiB target).
- [x] PLAN §2.2 no longer forbids operator regex; all listed echoes updated.
Ruling 8's list turned out to be incomplete: §7.1's evaluation sequence,
the §5 module tree and the Phase 5 summary also spoke of a two-body,
three-kind, no-exception world. All are corrected, and the sweep that
found them also caught milestone-20 drift the ruling never covered.
- [ ] `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
@@ -396,236 +314,15 @@ Deleted surface: none.
## Acceptance (milestone complete)
- [x] All session acceptance boxes above.
- [x] Schema at version 4; a v2 database migrates cleanly with data intact
(`migrations.zig:344`, "a version 2 database upgrades and keeps its
sources at exception_count 0"; the live smoke migrated `0 to 4`).
- [x] A pre-milestone blocklist data directory loads without refetch.
- [x] The six-level operator precedence plus three list levels behave per
- [ ] 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.
Every boundary on the nine-level ladder has a test in which one query
matches **both** levels, so reversing either order fails the suite. The
milestone added four: "both wildcard levels beat an allow regex that
matches", "an operator block rule beats a list exception", "a list
exception beats a list domain entry on the same name", and "a list
domain entry beats a list wildcard entry". The last two were added after
the second review pass found the earlier claim overstated — the existing
exception test used a name absent from `.list`, so it pinned
exception-versus-wildcard rather than exception-versus-domain.
- [x] No `src/filter/` fuzz-root file imports anything but `std`
(`regex.zig` imports `std` alone).
- [x] Contract samples, openapi.yaml and `web/src/lib/types.ts` agree with
- [ ] 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).
## Recorded (anchor re-verification, 2026-08-12)
The spec was written against `ffc3ca6` and verified against `a8e0fe4`. It was
re-verified a third time after milestones 22 and 23 landed (TypeScript 7,
Tailwind removed, StyleX and React Aria). Findings, all folded into the
rulings above:
- **No Zig file changed** between `a8e0fe4` and this re-verification. Every
Zig anchor holds; six ranges are off by a line or two but still contain what
the spec names. The schema is still at version 2, so migration steps 3 and 4
are genuinely new.
- The rules-page kind selector is no longer a `<select>`. It is a
`KIND_OPTIONS` array feeding `web/src/ui/Select.tsx`, a React Aria wrapper
(ruling 7 rewritten).
- `migrations.zig:349` pins `target_version` and is unnamed by the original
spec. S1 moves it to 3, S3 to 4.
- `exception_count` has two UI sites, not one, so S1 owns `BlocklistsPage.tsx`
(ruling 4 rewritten).
- The checksum sentence has three copies, not one (ruling 3 rewritten).
- `patternIsValid`'s error set cannot carry the engine's three error tags as
written (ruling 6 rewritten).
- `PLAN.md:99` documents two compiled bodies and goes stale with ruling 3
(ruling 8 rewritten).
- `tools/bench.zig:158` reads `.rules = &.{}`, so the filter bench loads no
rules today.
- `web/vitest.setup.ts` polyfills `CSS.escape`; without it, a test that opens
the React Aria selector throws under jsdom.
- `npm run build` gained `scripts/assert-css-layers.mjs`, which fails on any
rule outside a cascade layer. A pure StyleX change cannot trip it.
## Recorded (implementation)
Deviations and findings from the build, the Codex review rounds and the live
smoke. Everything here is folded into the code; nothing is outstanding.
- **Regex scratch is on the caller's stack, not in `Program`.** `std.Io.Threaded`
means several query threads share one snapshot, so the Pike VM's two thread
lists cannot live in the compiled program. `matches` takes its scratch from
the caller's frame, which keeps `Program` immutable and shareable and keeps
the match path allocation-free.
- **`{n,}` is legal and `\` + any ASCII punctuation is legal.** Ruling 5 named
neither. Quantifier-on-quantifier (`a+?` read as `(a+)?`) is `BadPattern`:
the first Codex round found `a+?` compiling to something that matched
everything.
- **Embedded whitespace was accepted on the anchored ABP forms.** A line such
as `||good.example bad.example^` reached the compiler, which lowercases and
length-checks but does not reject a space, and wrote an entry only a query
carrying the same space could match. `compiler.zig` passes `.wildcard` and
`.exception` text to `addCandidate` whole, which is why the space survived
there. `parser_abp.isNameCandidate` now refuses whitespace and control bytes
on those two paths.
The bare-name path deliberately does **not** use it. `compiler.zig:93-98`
tokenizes `.domain` text on whitespace and adds each field separately, so a
bare line carrying a space was never broken — it produced two valid entries.
Since `detectFormat` assigns one format per source, a mostly-ABP list that
also carries hosts-style lines depends on exactly that tokenizer to keep
them working. The first attempt at this fix applied the helper to all three
paths and silently dropped that fallback; the second Codex pass caught it.
Two tests now pin it: a parser test that the bare form stays `.domain`, and
a compiler test that an ABP-classified list carrying `0.0.0.0 ads.example`
still emits `ads.example`. The third pass pointed out that the parser test
alone would pass even if the compiler stopped tokenizing, which is the
behaviour the fallback actually depends on.
- **The `.allow` body left stale enumerations behind it.** Adding a third
compiled body — and with it a fourth temporary, `.allow.tmp` — updated the
production code but not every place that lists the file names. The third review pass found four: two `manager.zig` tests that
spell the names by hand, the orphan-sweep fixture in
`filter_integration_test.zig`, and `PLAN.md` §3.13 and §5. The
`manager.zig` table-driven test was worse than stale — it iterates
`source_file_suffixes` itself, so deleting an entry changes the code and the
test's expectations together and everything still passes. The whole repo was
then swept for the pattern rather than the four instances patched, which
turned up seven more — including the reload-cancellation test, whose fixture
wrote no `.allow` file at all, so the third of `loadSource`'s three read
sites was never exercised. A fourth pass then found test 10e comparing only
`.list` and `.wild` across a restart, so a restart that rewrote the exception
body alone would have stayed green. A `comptime` assertion on
`source_file_suffixes.len` now breaks the build when a suffix is added or
removed without updating the hand-written tests.
- **A pre-existing flake in the required suite.** `src/cli.zig:1538` asserted
`std.mem.count(u8, text, "OK") == 0` over output that embeds the temporary
directory path. `std.testing.tmpDir` names that directory with base64 over
12 random bytes, whose 64-symbol alphabet includes uppercase: 15 adjacent
positions each carry `OK` with probability 1/4096, so about one run in 273
fails a test that has nothing to do with naming. It surfaced during
this milestone's watched-fail injections. The assertion now checks that no
line *starts* with a verdict, which covers both `OK:` and
`OK upstreams[...]` and cannot match a path segment. Reproduced and fixed
outside the milestone's scope because a randomly failing required gate
devalues every green run after it.
- **`PLAN.md` still described the seed-once config model.** Seven sites said or
implied that the first start seeds the database from `/etc/nxdns/config.zon`,
which milestone 20 replaced with the two authority modes selected by the
presence of `--config`. One of them listed a `config/bootstrap.zig` that does
not exist — the module is `loader.zig` plus `reconcile.zig`. The claim was
checked against `src/app.zig:339`, not just against the docs. This is
milestone-20 drift found while fixing the milestone-21 echoes in the same
document, and corrected because PLAN is the source of truth a later session
builds from.
- **`PLAN.md` §12.1 held a config sample nobody could load.** It described an
`.upstream.servers` field that never existed and omitted the required
`.groups` and `.upstreams`. The section now points at
`docs/reference/configuration.md` and `nxdns export` and keeps only a
skeleton: a second copy of the schema is what produced the drift, so the
copy is gone rather than corrected.
- **`PLAN.md` §7.1 claimed blocklist entries never parent-walk.** Two of the
three list levels do: wildcard entries match every proper parent, and
exception entries walk the candidate chain, which is why
`@@||good.ads.example^` also lifts `y.good.ads.example`. Only domain entries
match the query name alone.
- **`src/config/model.zig`'s header described the retired bootstrap** and
omitted `exception_count` from its list of runtime columns. The first attempt
at correcting it introduced a new error — it called import a wholesale
replacement set against reconciliation — which the sixth pass caught.
`import.zig:3` is explicit that import has been a thin wrapper over
`reconcile.zig` since milestone 20, so there is one declarative write path,
not two, and it preserves the runtime state of every row the input still
names (`reconcile.zig:1139`). A seventh pass then corrected two more claims
in the same header: `Config` is the whole shape of a config file but not the
only shape the repositories accept (the API writes through `RuleInput`,
`ClientInput`, `ClientEdit`), and compiled-body reuse turns on the preserved
source id and checksum rather than the counters — `loadSource` names the
files after the id and accepts them only against the stored checksum.
- **`PLAN.md` §11.2 presented the v1 DDL as the live schema.** It predates
three migrations, so it lacks `upstreams.tls_name` and
`blocklist_sources.exception_count` and its `kind` CHECK admits only
`exact` and `wildcard` — implementing against it would produce a database
that rejects every regex rule this milestone added. The section is now
labelled the v1 baseline and points at `config_schema.zig` and
`migrations.zig`, with the three steps named.
- **`INSTALL.md` said the service reads `config.zon` "on the first start".**
Neither authority mode behaves that way: under `run --config` the service
reads it on every start, and under database authority `nxdns import` reads it
while a bare `nxdns run` never does. The sentence justified a file mode, so
an operator tightening permissions after first boot would have broken the
next file-mode restart. More milestone-20 drift. The claim had a second copy
in `docs/how-to/install-with-systemd.md`, found only because the seventh pass
looked for it after the first copy was fixed.
- **`PLAN.md` §7.1 and the Phase 5 summary missed this milestone's own
additions.** The evaluation sequence went straight from operator rules to
blocklist domains, omitting the exception level, and the matcher was still
enumerated as exact/parent/wildcard with no regex. Ruling 8 required these
echoes and S3 did not reach them.
- **The UI trimmed regex patterns.** `RulesPage.onSubmit` applied
`pattern.trim()` to every kind, so a regex created in the UI did not store
the bytes an identical `POST /api/rules` would. It now trims only `exact`
and `wildcard`, where the server normalizes anyway. No client-side
whitespace rejection was added: `" foo|bar"` still has a live `bar` branch,
so refusing it would over-reject, and the server stays the authority on
pattern validity.
- **The rule pattern field opted into mobile autocapitalization.** An
autocapitalized regex validates and then silently never matches, because
query names are lowercase and a regex is never normalized. The field now
sets `autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`.
- **`RuleSet.build` received the snapshot arena for its temporaries.** An
arena reclaims only its most recent allocation, so every `defer …deinit`
inside `build` was a silent no-op and the scratch survived until snapshot
teardown, uncounted by `memoryBytes()`. `build` now takes a permanent and a
scratch allocator, and `matcher.zig` passes `arena` and `gpa` respectively.
Regex programs compile into scratch and are copied across by a new
`Program.clone`, which keeps `regex.zig` a single-allocator engine and
leaves `compile`'s signature — and therefore `config/validate.zig` and the
fuzz target — untouched.
Measured on 16 groups each holding 256 regex rules of 254 bytes,
4096 wildcards and 2048 exact rules, with no blocklist sources:
arena capacity fell from 135,662,440 to 12,686,214 bytes against an
unchanged `memoryBytes()` of 11,058,791, so the ratio of real to reported
went from 12.27× to 1.15×. The hidden footprint per snapshot fell from
118.83 MiB to 1.55 MiB, so 117.28 MiB went away. A reload holds two
snapshots, so it was carrying twice that.
`memoryBytes()` needed no change: the formula was always right about what
the `RuleSet` retains, and the divergence was arena capacity the formula
does not claim to describe. The residual 1.15× is arena node headers and
page rounding, which `memoryBytes` documents itself as excluding.
The guard is `the build's temporaries stay out of the permanent allocator`,
which asserts `arena.queryCapacity() < 2 * set.memoryBytes()` and passes
`scratch` as `testing.allocator`, so a permanent allocation wrongly taken
from scratch also fails as a leak. It was watched failing with the split
reverted.
- **An interactive `--fuzz` session cannot run on zig 0.16.0.** Building any
fuzz target with `-ffuzz` fails inside the stock
`/usr/lib/zig/compiler/test_runner.zig:566`, which passes a
`*builtin.StackTrace` to `debug.writeStackTrace` where a
`*const debug.StackTrace` is wanted — two distinct struct declarations. The
failure is entirely inside the toolchain and reproduces on `compiler-fuzz`,
a target this milestone did not touch. Corpus replay under `zig build test`
is unaffected and remains the evidence for the step-bound property.
- **`api.md` gained a block-reason table.** The milestone added three reason
tags and no doc page enumerated any of them. The table lists all nine in
evaluation order plus `none` and the `cname:` prefix.
- **Two comment sites still counted three temporaries.** `manager.zig` line 40
(the `refresh_lock` invariant) and line 1293 (`pruneOrphans`) both omitted
`.allow.tmp`. Each presents itself as exhaustive, so a maintainer could have
added an `.allow.tmp` writer outside `refresh_lock` and let the orphan sweep
delete it mid-compile. `sourceFileId` and `source_file_suffixes` already
matched all four.
- **The rule pattern placeholder named only two kinds.** Ruling 7 requires the
web contract to name the third kind everywhere it names the first two, and
the placeholder read `ads.example.com or *.example.com`. It now carries a
regex example too.
- **Two pre-existing tutorial errors surfaced during the docs sweep.**
`tutorial/first-run.md` step 14 claimed the compiled list stays on disk when
it is in fact pruned (real output: `pruned orphaned blocklist file 1.allow`,
`1.wild`, `1.list`), and step 11 named `ads.doubleclick.net`, which
StevenBlack no longer carries.
## Anti-requirements
- No `$` modifier support beyond tolerating `$important` on exception lines.
-108
View File
@@ -1,108 +0,0 @@
# Milestone 22: TypeScript 7
Goal: move the web toolchain from TypeScript 6.0.3 to TypeScript 7.0.x (the
native compiler, GA 2026-07-08). One devDependency bump; every web gate and
the release pipeline stay green.
Design written 2026-08-12 against HEAD `91a0aa9`. TypeScript 7.0.2 is
`typescript@latest` on npm at spec time; the milestone pins whatever 7.0.x is
current when the session runs.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` is generated; it is type-checked by
`tsc -b` but never hand-edited. If TS7 rejects it, the fix goes in the
generator (`AGENTS.md` regeneration procedure), not the file.
- No license work: `typescript` is a devDependency, absent from both the
runtime closure and the bundled set in `licenses/dependency-identity.txt`.
The Zig drift guard (`src/licenses_drift_test.zig`) must still pass —
it recomputes from `web/package-lock.json` and dev-only changes are
invisible to it.
## Rulings (binding)
### 1. The bump is one line plus the lockfile
`web/package.json:45` `"typescript": "6.0.3"` becomes the current 7.0.x,
exact pin (repo convention: no ranges, milestone-14 ruling 12 pins by exact
version everywhere). `npm install` updates `web/package-lock.json`. Nothing
else in `dependencies` or `devDependencies` moves in this milestone.
### 2. The tsconfigs are already TS7-clean; verify, do not churn
Checked against TS7's removed options (recorded here so the session does not
re-derive it): `tsconfig.app.json` and `tsconfig.node.json` use
`moduleResolution: "bundler"`, `module: "esnext"`, targets `es2022`/`es2023`,
`paths` without `baseUrl`, `verbatimModuleSyntax`, `isolatedModules`,
`noEmit`. None of TS7's removals (`target: es5`, `downlevelIteration`,
`moduleResolution: node10`/`classic`, `module: amd/umd/system/none`,
`baseUrl`-as-alias) are present. `tsconfig.json` is a two-entry project
reference; TS7 supports `tsc -b` and project references. The session changes
a tsconfig only if `tsc -b` errors demand it, and records any such change in
`## Recorded (implementation)`.
### 3. Known TS7 edge: `skipLibCheck` no longer hides parse-level .d.ts errors
Both tsconfigs set `skipLibCheck: true`. Under TS7 that still skips type
checking of `.d.ts` files but not parse-level errors in them. If a dependency
`.d.ts` fails to parse, the fix is a dependency patch bump or an upstream
issue reference recorded in the spec — never a copied-and-edited local
`.d.ts`.
### 4. Nothing else in the toolchain consumes the TS compiler
Verified at spec time: `oxlint` (own parser), `vite`/`vitest`
(esbuild transpile, no type checking), `prettier`, and
`@vitejs/plugin-react` run no `tsc` and load no `typescript` API. The only
consumer is the `typecheck` script (`web/package.json:12`, `tsc -b`), run in
CI at `.gitea/workflows/gates.yml:113-114`. TS7 has no stable programmatic
API until 7.1; nothing in this repo needs one.
## Sessions
One session.
### Session S1: the bump
Owns: `web/package.json`, `web/package-lock.json`, and — only if ruling 2
forces it — `web/tsconfig.app.json`, `web/tsconfig.node.json`,
`web/tsconfig.json`.
- S1.1 bump per ruling 1; `npm install`.
- S1.2 run, in order: `npm run format:check`, `npm run lint`,
`npm run typecheck`, `npm test`, `npm run build`, `npm run assert-bundled`
(all from `web/`).
- S1.3 `zig build test` (the licenses drift guard and the contract-samples
guard run inside it).
Acceptance (S1):
- [ ] `web/node_modules/.bin/tsc --version` reports 7.0.x.
- [ ] `npm run typecheck` exits 0 with no tsconfig change, or the change is
recorded with the TS7 error text that forced it.
- [ ] All six web gate commands exit 0.
- [ ] `zig build test` exits 0.
- [ ] `git diff` touches only the files S1 owns.
## Module layout
New files: none. Deleted surface: none.
## Acceptance (milestone complete)
- [ ] All S1 boxes.
- [ ] `licenses/dependency-identity.txt` is byte-identical to HEAD.
## Anti-requirements
- No other dependency bumps ride along — not oxlint, not vite, not vitest,
not `@types/*`.
- No tsconfig flag additions or "modernization" beyond what a TS7 error
forces.
- No typescript-eslint, ts-jest, or any tool needing the TS programmatic API
(unavailable until 7.1, and unwanted regardless — oxlint is the linter).
- No `tsgo` binary references anywhere: the binary is `tsc`, scripts stay as
they are.
- No edits to `web/src/**` — a source change to satisfy the new compiler is a
finding to record and fix, not silently absorb, unless `tsc -b` fails
without it; then it is recorded in `## Recorded (implementation)`.
-574
View File
@@ -1,574 +0,0 @@
# Milestone 23: StyleX and React Aria replace Tailwind
Goal: every styled element in `web/src` renders through StyleX
(`@stylexjs/unplugin` at build time); Dialog, AlertDialog, Tabs and Select
come from `react-aria-components`; Tailwind is removed. TanStack Router and
TanStack Query stay (decided 2026-08-12 after review; the router swap is out
of scope permanently, not deferred).
Design written 2026-08-12 against HEAD `91a0aa9`. Needs milestone 22 landed
(the StyleX and RAC `.d.ts` surfaces are checked by the TS7 `tsc -b`).
Inventory this spec rests on: 522 `className=` occurrences across 31
`web/src` files; 17 shared class constants in `web/src/ui/classes.ts`;
`web/src/styles.css` is the single line `@import "tailwindcss"` (so dark mode
is Tailwind 4's default `prefers-color-scheme` media strategy — no class
toggle exists); the Tailwind plugin sits at `web/vite.config.ts:3,8`;
`tailwindcss` and `@tailwindcss/vite` are devDependencies
(`web/package.json:34,44`) and appear in neither the runtime closure nor the
bundled set of `licenses/dependency-identity.txt`.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` and `web/src/lib/types.ts` are API
contract surfaces; this milestone is styling-only and touches neither.
- Every session leaves all gates green: `npm run format:check`, `npm run
lint`, `npm run typecheck`, `npm test`, `npm run build`,
`npm run assert-bundled` (from `web/`), and `zig build test`. The release
gate (`zig build dist` + `verify-dist`, `.gitea/workflows/gates.yml`) keeps
its byte budgets; they are not tight — `web/dist` is 440,924 bytes raw
against the 15 MiB binary budget — but `verify-dist` remains the arbiter.
- `assert-bundled` fails whenever the bundled npm package set changes. That
failure is the license workflow trigger, not an obstacle: review each new
package into `licenses/inventory.zon`, then record the new set in
`licenses/dependency-identity.txt` (procedure in that file's header).
Never update the recorded list without the inventory review.
## Rulings (binding)
### 1. Build integration is `@stylexjs/unplugin`, proven before any page moves
`stylex.vite()` from `@stylexjs/unplugin` goes into `web/vite.config.ts`
alongside the existing plugins (Tailwind stays until S3). Configuration per
the official Vite guide (stylexjs.com/docs/learn/installation/vite):
`useCSSLayers: true`, `runtimeInjection: false`, `dev` keyed off the Vite
mode. The generated CSS injects into an existing CSS asset, so
`web/src/styles.css` (imported by `main.tsx:8`) remains the CSS entry for the
whole milestone; the dev/HMR virtual-module wiring from that guide is part of
S1. The unmaintained community `vite-plugin-stylex` is forbidden. S1's
acceptance proves dev server, `vitest run` and `vite build` all handle a
StyleX component before S2 or S3 convert anything — this is a gate, not an
assumption.
### 2. Tokens live in `web/src/ui/tokens.stylex.ts`
`stylex.defineVars` with role names, not raw shades: surface, surface-raised,
border, border-strong, text, text-muted (the zinc ramp today), primary and
primary-text (blue-600/white), danger, danger-surface, danger-border (the red
ramp), focus (blue-600). Each var carries its dark value under
`@media (prefers-color-scheme: dark)` inside `defineVars` — same media
strategy the app has now. No theme toggle, no `data-theme` attribute.
### 3. `web/src/ui/styles.ts` replaces `ui/classes.ts`
The 17 exports of `web/src/ui/classes.ts` (focusRing, insetFocusRing, input,
smallInput, button, smallButton, largeButton, primaryButton,
largePrimaryButton, rowButton, linkButton, dangerLinkButton, retryButton, th,
td, tableWrap, formCard) become `stylex.create` style objects in
`web/src/ui/styles.ts`, composed at call sites via
`{...stylex.props(styles.button, extra)}`. The milestone-9 accessibility
floor restated by milestone-18 ruling 10 carries over verbatim: every
interactive element renders the focus ring (`:focus-visible` outline, 2px,
offset 2, focus token; the inset variant for controls flush against panel
edges). `ui/classes.ts` is deleted in S3 when its last importer converts.
### 4. React Aria scope is exactly four components — end state, not a phase
From `react-aria-components`: Dialog + Modal (replacing the hand-rolled
`role="dialog"` overlay in `web/src/features/clients/ClientEditDialog.tsx:27-28`),
AlertDialog composition (ruling 5), Tabs (replacing the button-pair tab
switcher in `web/src/features/local/LocalDnsPage.tsx`), Select (replacing the
7 files carrying native `<select>`: LookupPage, QueryLogPage, RecordsTab,
RulesPage, SettingsPage, PrefixesEditor, ClientEditDialog). Everything else —
buttons, text inputs, tables, links, banners — stays native HTML styled with
StyleX. A native styled `<button>` is the finished design, not an interim
one. S2 wraps the four as shared components: `web/src/ui/Dialog.tsx`,
`web/src/ui/ConfirmDialog.tsx`, `web/src/ui/Tabs.tsx`,
`web/src/ui/Select.tsx`, each styled with StyleX through RAC's render-prop
state booleans (`stylex.props(isSelected && styles.selected)`) — never
data-attribute selectors, which StyleX cannot express.
### 5. `window.confirm` dies with the conversion
All call sites become `ConfirmDialog` (RAC AlertDialog: `role="alertdialog"`,
focus trapped, danger-styled confirm button):
`web/src/features/blocklists/BlocklistsPage.tsx:61`,
`web/src/features/upstreams/UpstreamsPage.tsx:43`,
`web/src/features/rules/RulesPage.tsx:34`, and
`web/src/ui/useCrudForm.ts:49` (which carries the confirm for ZonesTab and
RecordsTab). `useCrudForm`'s `CrudFormSpec.confirmDelete` keeps its signature;
the hook returns the dialog state instead of calling `window.confirm`. The
tests that stub confirm today
(`web/src/features/upstreams/UpstreamsPage.test.tsx:140,144,188`,
`web/src/ui/useCrudForm.test.tsx:102`) are rewritten to drive the real
dialog: open, assert `role="alertdialog"` and the entity name in the message,
confirm and cancel paths both asserted. BlocklistsPage and RulesPage gain the
same coverage for their delete paths.
### 6. Styling mechanics
`stylex.create`/`stylex.props` only. Conditions are boolean-guarded style
objects; variants are separate named styles; responsive behavior (the
`md:` sidebar grid in `web/src/shell/AppShell.tsx:90-98`, the mobile drawer)
becomes `@media` conditions inside style values. No dynamic class-name
strings, no `clsx`-style helpers, no `className` literals left anywhere in
`web/src` after S3 — `className`/`style` attributes appear only as the spread
of `stylex.props(...)` (RAC render-prop `className` functions included).
TanStack `Link` `activeProps`/`inactiveProps` (`AppShell.tsx:35-42`) pass
`stylex.props(...).className` outputs.
### 7. Visual contract: drift accepted, structure pinned
No pixel parity requirement and no visual-regression harness. Pinned instead,
checkable by reading the converted file against the original: same layout
structure (grid/flex relationships, table columns, heading levels), token
roles per ruling 2 (zinc surfaces, blue primary actions, red danger), the
focus floor of ruling 3 on every interactive element, dark mode via media
query, `overflow-x-auto` table wrappers preserved.
### 8. License inventory, named per step
- S1 adds `@stylexjs/stylex` (runtime dependency; `stylex.props` ships in the
bundle) to the runtime closure and the bundled set. `@stylexjs/unplugin` is
a devDependency — runtime closure and bundled set untouched by it.
- S2 adds `react-aria-components` and its transitive closure
(`@react-aria/*`, `@react-stately/*`, `@react-types/*`,
`@internationalized/*`, `@swc/helpers` — the exact bundled subset is
whatever `npm run assert-bundled` reports after S2's conversions; every
reported name gets a reviewed `licenses/inventory.zon` entry before the
recorded list moves). This is the milestone's largest review surface.
- S3 removes nothing from the license files: Tailwind was dev-only and never
recorded. The `@tanstack/*` entries stay untouched.
### 9. PLAN.md wording moves in S3
`PLAN.md:138` "Vite + React + TypeScript + Tailwind; TanStack Router +
TanStack Query" → "Vite + React + TypeScript + StyleX + React Aria; TanStack
Router + TanStack Query". `PLAN.md:235` directory comment updates the same
way. Line 562 names loaders and Query only — untouched. No other PLAN
amendment.
### 10. Conversion order is ascending `className` count, one commit per file
So a regression bisects to one page. Counts at spec time:
| file | count |
|---|---|
| lib/InlineError.tsx | 2 |
| features/settings/ReadOnlyConfigBanner.tsx | 2 |
| features/dashboard/HealthBanners.tsx | 2 |
| features/local/LocalDnsPage.tsx | 3 |
| features/settings/RestartBanner.tsx | 3 |
| features/dashboard/StatCards.tsx | 5 |
| routes.tsx | 6 |
| auth/LoginPage.tsx | 9 |
| features/dashboard/DashboardPage.tsx | 9 |
| features/groups/GroupSourcesEditor.tsx | 9 |
| features/pause/PauseWidget.tsx | 9 |
| features/blocklists/BlocklistForm.tsx | 11 |
| features/clients/ClientEditDialog.tsx | 11 |
| features/dashboard/DiskCard.tsx | 13 |
| features/upstreams/UpstreamForm.tsx | 14 |
| features/clients/PrefixesEditor.tsx | 15 |
| shell/AppShell.tsx | 17 |
| features/live/LiveLogPage.tsx | 18 |
| features/dashboard/TimeseriesChart.tsx | 21 |
| features/dashboard/UpstreamHealthTable.tsx | 21 |
| features/upstreams/UpstreamsPage.tsx | 21 |
| features/groups/GroupsPage.tsx | 22 |
| features/lookup/LookupPage.tsx | 25 |
| features/local/ZonesTab.tsx | 26 |
| features/blocklists/SourceStatusSection.tsx | 27 |
| features/clients/ClientsPage.tsx | 28 |
| features/rules/RulesPage.tsx | 31 |
| features/blocklists/BlocklistsPage.tsx | 31 |
| features/settings/SettingsPage.tsx | 31 |
| features/local/RecordsTab.tsx | 34 |
| features/queries/QueryLogPage.tsx | 46 |
S2 takes its owned cluster out of this order (converted together because the
RAC wrappers land there); S3 walks the remainder smallest-first.
### 11. Tests that must change, known in advance
RAC changes DOM and focus behavior; these edits are expected work, not
regressions to paper over:
- Dialog: `ClientsPage.test.tsx` (ClientEditDialog now portals into an RAC
Modal; queries move to `within(screen.getByRole("dialog"))`).
- Tabs: `LocalDnsPage.test.tsx` (tab switcher becomes `role="tablist"`/
`role="tab"`; `getByRole("button", { name: ... })` queries move to
`getByRole("tab", ...)`).
- AlertDialog: `UpstreamsPage.test.tsx`, `useCrudForm.test.tsx` per ruling 5.
- Select: RAC Select renders a button + listbox, not `<select>`, so
`getByLabelText(...)` + `fireEvent.change` patterns break in the tests for
the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab,
RulesPage, SettingsPage, ClientsPage). They move to
`userEvent`-style open-then-select interactions.
All other tests query by role/text and are expected to survive; a test that
asserted a Tailwind class is rewritten against role or accessible name.
## Sessions
Three sessions, strictly sequential: S1 → S2 → S3. (S2 and S3 both write
`web/package.json` and the license files; the RAC wrappers S3 consumes are
built in S2.)
### Session S1: StyleX foundation, proven end to end
Owns: `web/package.json`, `web/package-lock.json`, `web/vite.config.ts`,
`web/src/styles.css`, `web/src/ui/tokens.stylex.ts` (new),
`web/src/ui/styles.ts` (new), `web/src/ui/styles.test.tsx` (new),
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S1.1 add `@stylexjs/stylex` (dependency) and `@stylexjs/unplugin`
(devDependency), exact pins; wire `stylex.vite()` per ruling 1. Tailwind
plugin stays.
- S1.2 tokens per ruling 2; shared styles per ruling 3. `ui/classes.ts` is
not deleted yet — both vocabularies coexist until S3.
- S1.3 `styles.test.tsx`: render a probe component using
`stylex.props(styles.button)` under vitest; assert a non-empty
`className` lands on the element (proves the transform runs in the test
pipeline, since `runtimeInjection` is off).
- S1.4 license entries per ruling 8.
Acceptance (S1):
- [ ] `npm run dev` serves a probe page where a StyleX rule visibly applies
and edits hot-reload (checked by hand, result recorded).
- [ ] `npm test` passes including `styles.test.tsx`.
- [ ] `npm run build` emits StyleX CSS into the `web/dist` assets; the built
`index.html` page renders with the probe styled.
- [ ] `npm run assert-bundled` passes with `@stylexjs/stylex` in the recorded
set; `zig build test` passes (drift guard agrees).
- [ ] All remaining gates green.
### Session S2: React Aria primitives and their cluster
Owns: `web/package.json`, `web/package-lock.json`, `web/src/ui/Dialog.tsx`
(new), `web/src/ui/ConfirmDialog.tsx` (new), `web/src/ui/Tabs.tsx` (new),
`web/src/ui/Select.tsx` (new), `web/src/ui/useCrudForm.ts`,
`web/src/ui/useCrudForm.test.tsx`, `web/src/features/clients/*`,
`web/src/features/local/*`, `web/src/features/upstreams/*`,
`web/src/features/blocklists/BlocklistsPage.tsx` and its test,
`web/src/features/rules/RulesPage.tsx` and its test,
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S2.1 add `react-aria-components`, exact pin; build the four wrappers per
ruling 4, styled with tokens and `ui/styles.ts`.
- S2.2 convert the owned cluster fully to StyleX while adopting the wrappers:
ClientEditDialog + PrefixesEditor + ClientsPage (Dialog, Select);
LocalDnsPage + ZonesTab + RecordsTab (Tabs, Select, ConfirmDialog via
useCrudForm); UpstreamsPage + UpstreamForm, BlocklistsPage, RulesPage
(ConfirmDialog, Select). No `className` literal survives in an owned file.
- S2.3 `window.confirm` removal and test rewrites per rulings 5 and 11.
- S2.4 license review per ruling 8 — every package `assert-bundled` names.
Acceptance (S2):
- [ ] `grep -rn 'window\.confirm(' web/src` → zero matches. The bare name
still appears in three comments in `ui/` that record what replaced it,
which is why the check is for a call and not for the name.
- [ ] Delete flows on upstreams, blocklists, rules, zones and records drive a
`role="alertdialog"` element; confirm and cancel both covered by tests.
- [ ] Tabs on `/local-dns` expose `role="tablist"`; keyboard arrow keys move
between tabs (asserted in `LocalDnsPage.test.tsx`).
- [ ] No `className=` literal in S2-owned files
(`grep -c 'className="' <file>` = 0; `stylex.props` spreads excepted).
- [ ] `assert-bundled` passes with the RAC closure recorded;
`zig build test` passes.
- [ ] All remaining gates green.
### Session S3: the sweep and the removal
Owns: every `web/src` file not owned by S1/S2 that carries `className=`
(the ruling-10 remainder: InlineError, ReadOnlyConfigBanner, HealthBanners,
RestartBanner, StatCards, routes.tsx, LoginPage, DashboardPage,
GroupSourcesEditor, GroupsPage, PauseWidget, BlocklistForm,
SourceStatusSection, DiskCard, AppShell, LiveLogPage, TimeseriesChart,
UpstreamHealthTable, LookupPage, QueryLogPage, SettingsPage) plus their
tests, `web/src/ui/classes.ts` (delete), `web/package.json`,
`web/package-lock.json`, `web/vite.config.ts`, `web/src/styles.css`,
`PLAN.md`.
- S3.1 convert the remainder smallest-first per ruling 10, one commit per
file, gates green at every commit.
- S3.2 delete `ui/classes.ts` when its import count reaches zero.
- S3.3 remove Tailwind: drop `tailwindcss` and `@tailwindcss/vite` from
`package.json`, the plugin from `vite.config.ts:3,8`, and the
`@import "tailwindcss"` line from `styles.css` (the file itself stays as
the StyleX CSS entry per ruling 1).
- S3.4 PLAN.md per ruling 9.
Acceptance (S3):
- [ ] `grep -rn 'className="' web/src` returns zero matches. No Tailwind
dependency in `web/package.json`, no plugin in `web/vite.config.ts`, no
`@import "tailwindcss"` in `web/src/styles.css`. The word still appears
in seven comments in `web/src` that name the ramps the tokens were taken
from and the reset the preflight used to supply; those are the record of
where the values came from, so the check is not for the name.
- [ ] `ui/classes.ts` is gone; nothing imports it.
- [ ] Dark scheme still follows `prefers-color-scheme` (vitest: token var
resolves differently under a mocked dark media query, or a recorded
manual check in both schemes).
- [ ] `PLAN.md:138` and `:235` name StyleX + React Aria; `git diff PLAN.md`
touches nothing else.
- [ ] All gates green, including `zig build dist` + `zig build verify-dist`
with `-Dweb-dist=web/dist` (run once at milestone end).
### Orchestrator
Verify S1 acceptance (the ruling-1 gate) before S2 starts; S2 before S3.
After S3: full gate set, then a live smoke — `zig build` a server with the
fresh `web/dist`, click through every page in light and dark scheme, exercise
one delete confirm and the local-dns tabs. Record deviations in
`## Recorded (implementation)`.
## Module layout
New files:
- `web/src/ui/tokens.stylex.ts` — the design tokens (ruling 2).
- `web/src/ui/styles.ts` — the shared style vocabulary (ruling 3).
- `web/src/ui/styles.test.tsx` — the transform-pipeline probe (S1.3).
- `web/src/ui/Dialog.tsx`, `web/src/ui/ConfirmDialog.tsx`,
`web/src/ui/Tabs.tsx`, `web/src/ui/Select.tsx` — the RAC wrappers
(ruling 4).
Deleted surface: `web/src/ui/classes.ts`; the `window.confirm` calls; the
Tailwind dependency pair and its vite plugin line and CSS import.
## Acceptance (milestone complete)
- [ ] All session boxes above.
- [ ] Zero Tailwind: no dependency, no plugin, no `@import`, no utility
`className` literal in `web/src`.
- [ ] RAC appears in exactly four wrapper files plus their consumers; no
other `react-aria-components` import (`grep -rln 'react-aria-components'
web/src` ⊆ the four wrappers).
- [ ] Focus ring visible via keyboard on every interactive element of every
page (spot-checked live, recorded).
- [ ] `licenses/dependency-identity.txt`, `licenses/inventory.zon` and the
bundled reality agree (`assert-bundled` and `zig build test` both
green).
- [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are
byte-identical to HEAD.
## Recorded (implementation)
Written during S1 and the S2 hand-off. Each entry is a place the spec was wrong
or a decision the spec did not have.
### S1 could not put StyleX in the bundle, and its acceptance says otherwise
S1 owns no page file, so no production module imports StyleX and rollup emits
none of it. Two S1 boxes — "`npm run build` emits StyleX CSS into the `web/dist`
assets" and "`assert-bundled` passes with `@stylexjs/stylex` in the recorded
set" — are therefore unmeetable inside S1 as scoped, and the recorded bundled
set is unchanged at the S1 commit. Both were proven instead against a temporary
probe component, then deferred to S2, which converts real pages and does change
the bundled set. Ruling 8's "S1 adds `@stylexjs/stylex` ... to the runtime
closure and the bundled set" is half right: the closure moves in S1, the bundled
set in S2. The inventory entry for StyleX is anticipatory at the S1 commit,
which is deliberate — see the styleq entry for why it cannot wait.
### The probe results, recorded rather than left as a claim
Against the temporary probe: the built CSS carries `@layer priority1..N` and the
token variables, resolving to `#fafafa` light and `#09090b` dark with
lightningcss `lab()` fallbacks. The dev server needs no wiring of its own — the
plugin injects `<link rel="stylesheet" href="/virtual:stylex.css">` into
`index.html`, so the `DevStyleXInject` component in StyleX's Vite guide is for
frameworks that bypass Vite's HTML transform and is not used here. Editing a
token regenerates the virtual stylesheet and the dev server issues a page
reload.
### S1 wrote outside its owned files, and outside an anti-requirement
The licence guard forced it: `licenses/stylex-mit.txt`, `licenses/styleq-mit.txt`
and their registration in `licenses/licenses.zig`, plus the `npm_not_shipped`
array in `src/licenses_drift_test.zig`. The last contradicts the "no edits ...
under `src/`" anti-requirement below. The anti-requirement is amended, not
ignored: `src/licenses_drift_test.zig` is writable for its licence-fact arrays
and, from S2, for the per-package licence change described next. The rest of
`src/` stays closed.
### Apache-2.0 inbound: a decision, not a deduction
`react-aria-components` 1.20.0 brings eight Apache-2.0 packages
(react-aria-components, react-aria, react-stately, `@react-types/shared`, three
`@internationalized/*`, `@swc/helpers`) and `tslib` under 0BSD into an EUPL-1.2
project. The user accepted Apache-2.0 inbound on 2026-08-12 and authorised the
guard change it needs. This is recorded as their decision because nothing in the
licence settles it: EUPL-1.2's appendix does not list Apache-2.0 — it names GPL,
AGPL, OSL, EPL, CeCILL, MPL, LGPL, CC BY-SA, EUPL and LiLiQ — and that appendix
governs Article 5 outbound relicensing, not inbound consumption. Any claim that
the appendix permits this is false and must not be written into the inventory.
`src/licenses_drift_test.zig:122` hardcodes a single expected licence and
becomes per-package in S2; the guard stays strict, an unexpected licence still
fails.
### Ruling 8's picture of the React Aria closure is wrong
There are no `@react-aria/*` or `@react-stately/*` scoped packages. RAC 1.20.0
ships consolidated `react-aria` and `react-stately`. The runtime closure it adds
is the eleven packages named above plus `aria-hidden`, `clsx` and `client-only`
under MIT — not the "dozens" the ruling anticipated.
### Ruling 11's claim about the tab tests is wrong
`LocalDnsPage.tsx` already renders `role="tablist"` and `role="tab"` by hand, so
no test query moves from `getByRole("button")` to `getByRole("tab")`. What RAC
adds there is keyboard arrow-key navigation, which the hand-rolled switcher
lacks, and that is what the S2 acceptance box asserts.
### `formCard` is a layout change, not a port
`space-y-3` set sibling margins on a block container; the StyleX version is a
flex column with `gap`. StyleX cannot express the `> * + *` selector `space-y`
compiles to, so `gap` is the only mechanism available. Both call sites
(`RecordsTab`, `ZonesTab`) are plain vertical form stacks, where the difference
in child sizing and margin behaviour does not show. Recorded against ruling 7,
which pins layout structure.
### S2 needed two build-config changes the spec did not anticipate
Both are in files S1 and S3 own, both were forced, and neither is optional.
`web/vitest.setup.ts` (new) plus `setupFiles` in `web/vite.config.ts`: jsdom
implements no `CSS` interface at all, and React Aria's collection code calls
`CSS.escape` unguarded when it looks an item up by key
(`react-aria/dist/private/selection/utils.mjs:22`). Every RAC Select and Tabs
test threw `Cannot read properties of undefined (reading 'escape')` before this
landed. The setup file implements CSSOM's *serialize an identifier* algorithm
rather than approximating it, because a wrong escape would break the key lookups
silently instead of loudly.
The `@` alias passed to `stylex.vite()` in `web/vite.config.ts`: StyleX resolves
the `.stylex.ts` theme file itself and cannot see Vite's `resolve.alias`, so
`import { colors } from "@/ui/tokens.stylex"` in a feature file failed the build
with "Could not resolve the path to the imported file". Relative imports were the
alternative; passing the alias keeps the repo's `@/` convention instead.
### RAC Select changes the accessible name of a labelled control
`useSelect` labels the trigger button with `aria-labelledby="<value> <label>"`,
so a select labelled "Type" showing "A" has the accessible name "A Type", and
`getByLabelText("Type")` no longer finds it. Converted tests query
`getByRole("button", { name: /Type$/ })` and read the options by opening the
listbox. RAC also renders a hidden native `<select>` for form submission; it sits
inside an `aria-hidden` container, so role and label queries skip it.
### The RAC anti-requirement forbids parts of a component it requires
`Select.tsx` imports RAC `Button`, `Popover`, `Label`, `ListBox`, `ListBoxItem`
and `SelectValue`. The anti-requirement below names `Button` and `Popover`
among the components RAC must not bring. Those two are not adopted components
here — RAC composes a Select out of exactly these parts, and there is no RAC
Select without them. The anti-requirement is amended: it forbids adopting a RAC
component as an app-wide primitive, and the internal parts of the four adopted
components are permitted where no file outside their wrapper imports them.
That last clause is the check that keeps the scope honest, and it holds:
`react-aria-components` is imported only by the four `ui/` wrappers. A RAC
`Button` in a feature file would still be a breach.
### The focus-ring floor was briefly amended on a false premise
S2 left the Select's options with their outline removed, showing keyboard focus
as a background inversion alone. This section previously amended ruling 3 to
excuse that, arguing RAC drives option focus through an `isFocused` render prop
so a `:focus-visible` rule could never match. That argument is wrong.
`useSelectableItem` gives options a roving `tabIndex` and focuses the option's
own DOM node (`react-aria/dist/private/selection/useSelectableItem.mjs:64`),
which `ListBoxItem` spreads onto its element — so `:focus-visible` matches and
the ring was simply missing. The amendment is withdrawn and ruling 3 stands
unchanged. `Select.tsx` now composes the shared inset ring onto every option:
inset because an option flush against a scrolling popover clips an outset
outline, and recoloured to `primaryText` on the highlighted row because the
focus token is the same blue that row paints behind it. The inversion stays as
the listbox convention; it is no longer the only indicator.
### Two notes for S3
`textMuted` resolves to zinc-400 in dark mode. Several current `text-zinc-500`
call sites carry no dark override and stay zinc-500, so the token cannot replace
those without a shade shift — decide per call site rather than sweeping.
Vitest began reporting "Tests closed successfully but something prevents Vite
server from exiting" with the StyleX plugin in the pipeline. The suite passes
and exits 0. It should not be allowed to become the accepted baseline.
### Both S3 notes are now settled
**The vitest warning is fixed, and it was not our code.** Bisecting a trivial
one-assertion test against a bare config showed `react()` exits clean and
`stylex.vite()` does not. `@stylexjs/unplugin@0.19.0`'s
`lib/es/vite.mjs:59` starts a 150ms `setInterval` in `configureServer` to poll
its CSS store for HMR, and clears it from the `close` event of
`server.httpServer`. A probe plugin confirmed vitest resolves `httpServer` to
`null`, because it runs the dev server in middleware mode — so the timer is
never cleared and holds the event loop open until vitest kills the worker on a
ten-second timeout. `web/vite.config.ts` now strips that one hook when
`process.env.VITEST` is set; the transform hook, which the tests do need, is
untouched, and `ui/styles.test.tsx` still proves it runs. `npm test` reports 31
files and 183 tests passed, with no warning, and exits 0.
**`textMuted` was swept; here is the per-call-site judgement.** Measuring the
two shifts against their grounds settles it in opposite directions:
- 39 call sites were a flat `text-zinc-500` with no dark override. On the dark
ground that measures **4.12:1**, which fails WCAG AA. The token's zinc-400
dark value measures **7.56:1**. The sweep repaired a real accessibility
defect there, so it stands, and the token comment now records why.
- 4 call sites were `text-zinc-600 dark:text-zinc-400` — already adaptive. The
sweep moved their light value to zinc-500, from **7.40:1** to **4.62:1**.
That clears AA by a hair on small text: the query-log table header, the
dashboard period button, the inactive nav item and the chart legend. It buys
nothing, so a second token `textSecondary` (zinc-600 light, zinc-400 dark)
restores the original pair at exactly those four.
Contrast was computed from the tokens' own oklch values through linear sRGB to
WCAG relative luminance, not read off a chart.
### The reset's font stack is not Tailwind's, deliberately
Tailwind is uninstalled, so its default stack cannot be reproduced from the
tree, and ruling 7 accepts the metric drift. One part is not drift: `Select.tsx`
renders U+25BE for its chevron, and Segoe UI does not carry that glyph, so on
Windows the stack has to reach a font that does. `"Segoe UI Symbol"` and
`"Noto Sans Symbols 2"` are appended for that glyph. No emoji families are
listed, because the app renders no emoji.
### Two review findings closed by decision, not by a change
Both were put to Mokhtar Mial on 2026-08-12 and both are closed. Neither is to
be re-opened by a later review reading the history and inferring an oversight.
`0f58966` carries a `README.md` edit that was already in the working tree and
is not part of the `srOnly` work its message describes. The content is intact
and only the attribution is wrong; rewriting published history to correct that
was declined.
Ruling 10 asks for one commit per file in ascending `className` count. S3 did
not hold to the order strictly. Bisectability does not depend on it and the
commits were already published, so the order stands as it is.
## Anti-requirements
- No router or query changes: TanStack Router and TanStack Query stay,
untouched. That decision is closed.
- No RAC beyond Dialog, AlertDialog, Tabs, Select — no RAC Button, TextField,
Table, Menu, DatePicker adopted as an app primitive. Native elements styled
with StyleX are the end state. The internal parts of the four adopted
components (Button, Popover, Label, ListBox, ListBoxItem, SelectValue inside
Select) are permitted, and only inside their own wrapper file — see the
recorded amendment above.
- No runtime style injection (`runtimeInjection: false` stays), no CSS-in-JS
at runtime, no styled-components-like patterns.
- No dynamic class-name assembly, no `clsx`/`classnames` dependency, no
Tailwind-compatibility shim.
- No theme toggle, no `data-theme`, no stored preference — media-query dark
mode only, as today.
- No redesign: layout structure is pinned by ruling 7. Drift in shades and
spacing is accepted; new page structures are not.
- No visual-regression harness, no screenshot tooling.
- No edits to `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts`,
`web/src/lib/api.ts`, or anything under `src/` (the Zig tree).
- No `vite-plugin-stylex` (unmaintained community plugin).
-581
View File
@@ -1,581 +0,0 @@
# Milestone 24: `skipped_unsupported` is persisted, surfaced and explained
Goal: close the compile-pipeline silent drop that misleads the operator. `Counts.
skipped_unsupported` (`src/filter/compiler.zig:34`) is counted on every compile
(compiler.zig:92) and then discarded on the happy path — it reaches the
compiled-file header and the `NoValidEntries` error text, but no database
column, no API field and no UI cell. Its sibling `skipped_regex` reaches all
three. A blocklist made almost entirely of cosmetic browser filters therefore
compiles to almost nothing and looks, in the UI, exactly like a clean list.
AGENTS.md forbids exactly this ("no silent drops"). The milestone persists the
count, surfaces it everywhere `skipped_regex` is surfaced, and explains to the
operator why the two skip counters are different facts.
Design written 2026-08-13 against HEAD `21571e4`, revised after a Codex review
of the first draft.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- **The v1 baseline is editable and there is no migration step.** As of commit
`21571e4`, `src/storage/migrations.zig` holds exactly one step and
`target_version` is 1 (migrations.zig:29-36). nxdns has zero installs; the
baseline freezes at v0.1 (`config_schema.zig:6-12`, PLAN §3.7, §11.2). The
new column is one line edited into `config_schema.ddl_v1` plus the identical
line in PLAN §11.2, which is kept byte-identical to it. A diff that adds a
`ddl_v2` or a second `Step` is wrong and must be reverted, not merged.
- After the API shape change, regenerate the contract samples
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md — the
`zig build test -Dintegration -Dcontract-samples-out=...` command, never a
hand edit) and update `web/src/lib/types.ts` to match.
- No new `src/**.zig` files, so `src/tests.zig` and `build.zig` are untouched.
## Rulings (binding)
### 1. The column is `skipped_unsupported_count`, one edited line, no step
`skipped_regex_count` (config_schema.zig:63) sets the convention; the new
column follows it. In `config_schema.ddl_v1`, directly after the
`skipped_regex_count` line inside `CREATE TABLE blocklist_sources`:
```sql
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
```
The identical line goes into PLAN §11.2 after PLAN.md:421 — that section is
kept byte-identical to `ddl_v1` and currently is (verified: the fenced SQL
matches the Zig string literal line for line).
Consequence to accept, not to fix: a development database stamped version 1
before this edit will fail the first `SELECT` naming the column with "no such
column", because the stamped version equals `target_version` and the step never
reruns. That is the documented pre-v0.1 contract (`config_schema.zig:6-12`) —
delete the scratch database. Do not add fallback SQL, `PRAGMA table_info`
probing, or a migration step to paper over it.
The baseline test "a fresh database reaches the baseline with every v1 column
and rule kind" (migrations.zig:266-283) gains
`try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));`
beside the existing `exception_count` probe.
### 2. The repo persists it beside `skipped_regex_count`
`src/storage/repositories/sources_repo.zig`, mirroring `skipped_regex_count`
exactly (no default on either struct field, so every construction site fails
to compile until it names the count — that is the visibility this milestone is
about):
- `SourceRow` (sources_repo.zig:80-97) gains `skipped_unsupported_count: i64`
after `skipped_regex_count`.
- `SourceStats` (sources_repo.zig:99-110) gains the same field.
- `row_columns_sql` (sources_repo.zig:112-117) appends
`skipped_unsupported_count` to the SELECT list (index 11);
`readSourceRow` (sources_repo.zig:128-148) reads it with
`stmt.columnInt(11)`.
- `update_stats_sql` (sources_repo.zig:159-164) adds
`skipped_unsupported_count = ?8`; `updateSourceStats`
(sources_repo.zig:168-179) binds it.
- The module doc comment (sources_repo.zig:3-5) adds the column to its list of
server-produced facts, and so does the `src/config/model.zig:14` doc comment
that repeats that list.
**The sum at sources_repo.zig:318 includes the new column.** That sum lives in
the test "insertBlocklistSource leaves the runtime columns at their defaults";
it exists to prove an insert leaves every runtime counter at 0, and the new
column is a runtime counter. This is a deliberate decision, not an oversight:
no production query sums these columns into a "total entries" figure, and none
may start to — `skipped_regex_count` and `skipped_unsupported_count` count
lines *not* written, unlike `domain_count`, `wildcard_count` and
`exception_count`, so any future entries total must exclude both. The test sum
asserts defaults, which is the one context where adding them is correct.
Existing `SourceStats` / assertion sites in this file (the literals at
sources_repo.zig:386-393, 420-427, 483-493 and the zero-default loop at
sources_repo.zig:365-374) carry the new field with distinct non-zero values
where their siblings have them, and the round-trip assertions extend to it.
### 3. The manager writes it, and rehydration restores it
`src/filter/manager.zig`. The header already prints
`# skipped_unsupported {d}` (manager.zig:243) and `SourceStatus.counts` is a
full `compiler.Counts` (manager.zig:178), so within one process the count
already reaches the status table. What is missing is the database, which is
the only thing a restart reads — rehydration never reparses headers.
- The fresh-publish path (manager.zig:854-861) passes
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported` to
`updateSourceStats`.
- **The checksum-unchanged path (manager.zig:821-836) carries a bug this
milestone fixes.** It writes the *stored* `row.skipped_regex_count` back to
the database (manager.zig:830) while handing the *fresh*
`compiled.result.counts` to the in-memory status (manager.zig:833). The
checksum covers the `.list`, `.wild` and `.allow` bodies only, and a skipped
line lands in none of them — so a list that changes only its regex or
browser-syntax lines keeps its checksum, the running server shows the new
number, the database keeps the old one, and the next restart silently reverts
what the operator saw. That is milestone 21's column, wrong today.
Both skip counters take `compiled.result.counts` on this path:
`.skipped_regex_count = compiled.result.counts.skipped_regex` and
`.skipped_unsupported_count = compiled.result.counts.skipped_unsupported`.
~~`domain_count`, `wildcard_count` and `exception_count` keep reading from
`row` — they count written entries, so an unchanged checksum does mean an
unchanged value for them.~~ **Corrected post-commit:** that claim was false.
The unframed digest could not distinguish an entry in `.list` from the same
entry in `.wild`, so an unchanged checksum did not vouch for the entry
counts either. The addendum below frames the hash and makes all five stat
fields read fresh on this path.
This needs a test the author has watched fail with the fix reverted: two
compiles of bodies whose written entries are identical but whose skipped
lines differ, asserting the same checksum, then asserting the database holds
the second compile's counts, then rehydrating through `applyLoadOutcomes` to
prove the restored status carries them. Report the observed failure output.
- `applyLoadOutcomes` rehydration (manager.zig:1545-1550) adds
`.skipped_unsupported = countOf(row.skipped_unsupported_count)` to the
`Counts` it rebuilds, so a restarted server reports what the last compile
skipped instead of 0.
- Test literals name the field: one `SourceStats` (manager.zig:1928) and two
`SourceRow` (manager.zig:2051, 2225). The rehydration test that feeds a row
through
`applyLoadOutcomes` asserts a non-zero `skipped_unsupported` lands in
`status.counts`. The header pin test (manager.zig:2030-2031) already covers
the `# skipped_unsupported` line and is extended only if its fixture counts
change.
- `rejectedWithoutEntries` (manager.zig:1640-1643) and `failNoValidEntries`
(manager.zig:1127-1128) already read the count and are unchanged.
`src/filter/filter_integration_test.zig` names `.skipped_regex_count` in six
`SourceStats` literals (:826, :858, :1075, :1145, :1257, :2158) — each gains
the sibling field. The stats round-trip assertion at :915 gains a sibling
assertion for the new column.
**Do not add an unsupported line to the existing fixture.** The fixture at
filter_integration_test.zig:112 is hosts-shaped, and `detectFormat`
(`src/filter/parsers.zig:118`) assigns one format to a whole source. A single
`##.ad-banner` or `$`-modifier line flips it to ABP, which re-parses every
existing line: the `*.wild` entry becomes unsupported and address fields
tokenize as domains (`src/filter/parser_abp.zig:68`). Every expected count in
that test would move, for a reason unrelated to this milestone.
Add a **separate ABP-format fixture and test** carrying `##.ad-banner` and
`||ads.example^$third-party` beside two blockable names, and assert
`skipped_unsupported_count = 2` through the compile-persist-read round trip
there.
`src/config/reconcile.zig`: the runtime-column preservation test seeds stats
at reconcile.zig:1096-1110 and asserts survival at reconcile.zig:1173. The
seed gains `.skipped_unsupported_count` with a distinct value and the
assertion block gains its expectation. No reconcile logic changes: the engine
updates declarative columns by name and never touches runtime columns, so the
new column survives with zero code change — the test is the proof.
### 4. The API speaks it in both shapes
Two shapes carry blocklist counters, and the new count joins both under the
names its siblings set:
- **`Blocklist`** (rows of `GET /api/blocklists`, serialized straight from
`SourceRow`): the field arrives automatically once `SourceRow` has it, as
`skipped_unsupported_count`. `src/web/openapi.yaml:1877-1895` adds it to
`required` and `properties`.
- **`SourceStatus`** (rows of `POST /api/blocklists/update`): `StatusView`
(`src/web/handlers/blocklists.zig:52-80`) gains
`skipped_unsupported: u32` after `skipped_regex`, mapped from
`status.counts.skipped_unsupported` in `from`.
`src/web/openapi.yaml:1920-1938` adds it to `required` and `properties`.
The handler test literals at blocklists.zig:323 (`SourceStats`) and :386
(`counts`) carry the field with non-zero values and the response assertions
extend to it.
Contract fallout, all in the same session (ruling 6 explains why):
- Regenerate `web/src/lib/contractSamples.gen.ts` with the AGENTS.md command.
- `web/src/lib/types.ts`: `Blocklist` gains
`skipped_unsupported_count: number` (types.ts:154-166); `SourceStatus`
gains `skipped_unsupported: number` (types.ts:183-195).
- The web test mocks are **not** typed against these interfaces, so `tsc` will
not force them. `BLOCKLISTS` in
`web/src/features/blocklists/BlocklistsPage.test.tsx` is an inferred object
literal handed to a `Record<string, unknown>` (BlocklistsPage.test.tsx:40),
and the same untyped-fetch pattern holds in
`web/src/features/groups/GroupsPage.test.tsx:16` and
`web/src/features/settings/authority.test.tsx:64` — both of which already
omit `exception_count` without failing. Updating a mock here is a semantic
fixture change, not a typecheck fix, and the implementer must not expect a
compiler error to point at them.
So: the BlocklistsPage mocks (:21, :34 blocklist rows; :104, :155, :168
status rows) gain the field because S2's rendering assertions read it. The
groups and settings mocks are left alone — they render no counter column,
and widening them buys nothing. S1 picks values no other cell in the same
table already shows (`3` and `7` are taken), e.g.
`skipped_unsupported_count: 21` and `skipped_unsupported: 17`.
### 5. The UI shows it always, in both tables, and says what it means
Both tables gain a `Skipped unsupported` column directly after
`Skipped regex`, rendered unconditionally:
- `web/src/features/blocklists/BlocklistsPage.tsx`: header after :157, cell
`{b.skipped_unsupported_count}` after :190, same
`shared.td, shared.tabularNums` props as its neighbours.
- `web/src/features/blocklists/SourceStatusSection.tsx`: header after :91,
cell `{source.skipped_unsupported}` after :114.
Always-shown is a decision, not a default: every other counter column here is
unconditional, including `Skipped regex` and `Exceptions`, which are 0 for
every plain hosts list, and the tutorial already explains those zeros. A
column that appears only when non-zero would make two lists' tables disagree
in shape, would hide the header that gives the number its meaning, and would
special-case exactly the counter this milestone exists to make visible. The
zero cell is not noise; it states that nothing in this list was classified as
unsupported — which is narrower than "clean", since `invalid` and `long_lines`
stay unsurfaced (ruling 7).
The two counters mean different things and the page must say so once. A muted
paragraph (the existing `styles.empty`-style muted text, matching
`SourceStatusSection`'s `note` treatment) rendered under the sources table in
`BlocklistsPage.tsx`, inside the same `else` branch as the table — the note
describes the two skip columns, so it appears exactly when they do. "Always
visible" above means unconditional on values, never hidden at 0; it does not
mean the empty state (`blocklists.length === 0`) carries a paragraph about
columns that are not on screen. The empty state stays one instruction. Exact
copy:
> Both “Skipped” columns count lines nxdns read and did not take. Skipped
> regex lines are patterns nxdns accepts only from you — adopt one you trust
> as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate
> into a DNS decision: cosmetic element hiding, browser-only modifiers. A
> skipped unsupported count that dwarfs the domain count usually means the
> list is written for a browser extension, and its DNS or hosts variant will
> block more here.
`BlocklistsPage.test.tsx` asserts: both new headers render, the mock values
(`21`, and `17` after an update snapshot) render, and the note text is
present. No threshold logic, no badge, no coloring by magnitude (see
anti-requirements).
### 6. The docs explain both counters without contradicting what stands
- `docs/reference/configuration.md`, section `### blocklist_sources`, after
the exceptions paragraph (configuration.md:282-288), a new paragraph:
> Two counters report what a compile skipped, and they are different facts.
> `skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
> patterns only from the operator, so a regex line in a downloaded list is
> counted, skipped and surfaced — adopt the ones you trust as `regex` rules.
> `skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
> decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a
> `$` modifier (except `$important` on an exception line, tolerated above),
> scheme anchors, non-anchored `@@` forms — and, in a
> `domains`-format list, a line holding more than one field before its
> inline comment, which usually means the list is really a hosts file that
> was declared as `domains`. Neither is an error, and the two are never one
> number. A large `skipped_unsupported` beside a small `domain_count`
> usually means the list is written for browser extensions, and its DNS or
> hosts variant will block more here. Both appear per source in the
> blocklists UI and on `/api/blocklists`.
- `docs/tutorial/first-run.md`: the recorded JSON at first-run.md:223 gains
`"skipped_unsupported":0` after `"skipped_regex":0` (the endpoint now
returns it; the recorded values themselves stand). The paragraph at
first-run.md:226-231 becomes four zeros, keeps its `skipped_regex` sentence
as written, and **drops the "parts of this list that a hosts file cannot
have" framing** — a deliberate small widening ruled during implementation
review. The framing was false before this milestone touched it:
`parser_hosts.zig` recognizes regex lines (:16), reports a bare sink
address as unsupported (:21), and a `*.`-prefixed name compiles to a
wildcard in any format. Only `exceptions` is Adblock-Plus-only. The
paragraph now presents the zeros as facts about this particular download
and says so.
- The `$` modifier is named as skipped **with its one exception**: `$important`
on an anchored `@@` exception line is accepted and lands in
`exception_count` (`parser_abp.zig`, PLAN §2.2). The configuration.md
paragraph above, and the `SourceRow.skipped_unsupported_count` doc comment
in `sources_repo.zig` that mirrors it, both carry the qualifier — an
unqualified "rules carrying a `$` modifier" contradicts the parser.
- PLAN.md:101 (§3.8) currently reads "regex lines are counted + skipped
(counts in metadata → UI)". It becomes: regex lines *and* browser-syntax
lines are counted and skipped, both counts in metadata → UI. One sentence;
§2.2 (PLAN.md:36-37) already says unsupported forms "stay unsupported and
counted" and needs no edit.
- `docs/reference/files-and-directories.md` is untouched: the compiled-file
header already carried `# skipped_unsupported` before this milestone and
the file table there does not enumerate header lines.
### 7. The other three counters stay out, and the reasons differ per counter
The first draft claimed `invalid`, `long_lines` and `duplicates` were already
"header-and-error-path only". That is false:
- `invalid` reaches the compiled-file header (manager.zig:244) and the
`NoValidEntries` text (manager.zig:1127).
- `long_lines` reaches the `NoValidEntries` text only — not the header.
- `duplicates` reaches **nothing**: counted at compile, discarded on every
path.
They stay out of scope, and not for one shared reason — which is why the goal
above says "the silent drop that misleads the operator" rather than "the one
silent drop left":
- `duplicates` hides no failure. A deduplicated name still blocks; the count
measures input redundancy, not lost coverage. Discarding it discards a
curiosity, so "no silent drops" does not reach it — there is no drop.
- `invalid` and `long_lines` do measure dropped lines, and they remain only
partially surfaced. The catastrophic form — a download that is all rejects —
already fails loudly (`rejectedWithoutEntries`, state `no_valid_entries`);
the residual is a list that loses some lines and still loads, visible in
the header file and nowhere the UI reaches. That residual is real, it is
recorded here deliberately, and it is not this milestone: the two skip
counters name an action the operator can take (adopt the patterns; fetch
the DNS variant), while these two say only "the list is malformed", which
no column makes more actionable.
The decision is reversible; a later milestone can widen the table. What this
spec may not do is claim the three are visible when `duplicates` is not.
### 8. Export, import and reconcile change nothing
`nxdns export` / `import` carry configuration; the compile counters are facts
a running server produces. `model.BlocklistSource` holds only the four
configuration columns, the insert leaves runtime columns at their defaults
(sources_repo.zig:1-9, :48-61), and the dump helpers used by the import and
reconcile suites are `SELECT *` compared dump-to-dump
(`src/config/import.zig:167-186`, `src/config/reconcile.zig:988`), so no
golden text names columns and the new column appears in both sides of every
comparison. Ruling 3's reconcile-test extension is the only touch in
`src/config/`, plus the model.zig:14 comment from ruling 2.
## Sessions
Two sessions, strictly sequential: S1 then S2. No parallelism — deliberately.
The regenerated `contractSamples.gen.ts` typechecks only against a `types.ts`
that already carries the new fields, and the samples are produced by a Zig
integration run, so the generator and the interface it must satisfy sit on
opposite sides of the language boundary and cannot land independently. S2's
rendering assertions then read fields that only exist once S1 has landed both.
(The mocks are *not* part of this argument: they are untyped, so widening
`types.ts` does not break them. The first draft claimed otherwise.)
### Session S1: column, persistence, API, contract
Owns: `src/storage/config_schema.zig`, `src/storage/migrations.zig` (test
only), `src/storage/repositories/sources_repo.zig`, `src/filter/manager.zig`,
`src/filter/filter_integration_test.zig`, `src/config/reconcile.zig` (test
only), `src/config/model.zig` (comment only),
`src/web/handlers/blocklists.zig`, `src/web/openapi.yaml`,
`web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` (regenerated,
never hand-edited), `web/src/features/blocklists/BlocklistsPage.test.tsx`
(mock fields only — no rendering assertions),
`PLAN.md` (the §11.2 line and
the §3.8 sentence).
- S1.1 rulings 1 and 2: the DDL line in both copies, the repo columns, the
migrations and repo tests.
- S1.2 ruling 3: both `updateSourceStats` call sites, rehydration, test
literals, the reconcile preservation assertion.
- S1.3 ruling 4: `StatusView`, openapi.yaml, sample regeneration, `types.ts`,
mock fields.
Acceptance (S1):
- [ ] `zig build test` passes; the migrations baseline test proves
`blocklist_sources.skipped_unsupported_count` exists.
- [ ] A compile of the new **ABP-format** fixture carrying `##.ad-banner` and
`||ads.example^$third-party` persists `skipped_unsupported_count = 2`
through `updateSourceStats` and reads it back through `listSourceRows`.
The existing hosts-shaped fixture is unchanged, and every count it
already asserts still holds.
- [ ] A second compile whose written entries are byte-identical but whose
skipped lines differ produces the same checksum and still updates both
skip counters in the database; the test was watched failing with the
manager.zig:830 fix reverted, and the failure output is recorded.
- [ ] `applyLoadOutcomes` fed a row with `skipped_unsupported_count = 5` and
no live refresh yields `status.counts.skipped_unsupported == 5`.
- [ ] `zig build test -Dintegration` passes, and the regenerated
`contractSamples.gen.ts` carries `skipped_unsupported_count` in the
Blocklist sample and `skipped_unsupported` in the SourceStatus sample.
- [ ] `cd web && npm run typecheck && npm test` pass with the widened types.
### Session S2: UI and docs (needs S1)
Owns: `web/src/features/blocklists/BlocklistsPage.tsx`,
`web/src/features/blocklists/SourceStatusSection.tsx`,
`web/src/features/blocklists/BlocklistsPage.test.tsx` (rendering assertions),
`docs/reference/configuration.md`, `docs/tutorial/first-run.md`.
- S2.1 ruling 5: both columns, the note paragraph, the rendering assertions.
- S2.2 ruling 6: the two doc edits.
Acceptance (S2):
- [ ] `cd web && npm run typecheck && npm test && npm run lint` pass; the new
tests assert both `Skipped unsupported` headers, the mock values and the
note text.
- [ ] `npm run build` passes (`assert-css-layers.mjs` runs inside it).
- [ ] `docs/tutorial/first-run.md` no longer says "three zeros", its JSON
sample carries `skipped_unsupported`, and its `skipped_regex` sentence
is unchanged.
### Orchestrator
Verify S1 acceptance before starting S2. After S2, run the full gate set
(`zig build test`, `zig build test -Dintegration`, `test-aarch64` if qemu is
present, `cd web && npm test`, `npm run assert-bundled`), then a live smoke
against a scratch server with two real sources:
- `https://easylist.to/easylist/easylist.txt` — a browser-targeted list;
expect a `skipped_unsupported` several times its `domains` (do not assert an
exact number; assert the ratio and non-zero).
- `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` — expect
`skipped_unsupported` 0.
Verify the numbers appear in the `POST /api/blocklists/update` response, in
`GET /api/blocklists`, and in both UI tables. Then restart the server and
verify the counts survive into the status table without a refresh — that is
ruling 3's rehydration working against the real database. Record deviations
in `## Recorded (implementation)`.
## Module layout
New files: none. Deleted surface: none.
## File ownership
| File | Session |
| --- | --- |
| `src/storage/config_schema.zig` | S1 |
| `src/storage/migrations.zig` | S1 |
| `src/storage/repositories/sources_repo.zig` | S1 |
| `src/filter/manager.zig` | S1 |
| `src/filter/filter_integration_test.zig` | S1 |
| `src/config/reconcile.zig` (test), `src/config/model.zig` (comment) | S1 |
| `src/web/handlers/blocklists.zig`, `src/web/openapi.yaml` | S1 |
| `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` | S1 |
| `PLAN.md` (§11.2 line, §3.8 sentence) | S1 |
| `web/src/features/blocklists/BlocklistsPage.test.tsx` | S1 (mock fields), then S2 (assertions) — sequential, never concurrent |
| `web/src/features/blocklists/BlocklistsPage.tsx`, `web/src/features/blocklists/SourceStatusSection.tsx` | S2 |
| `docs/reference/configuration.md`, `docs/tutorial/first-run.md` | S2 |
## Acceptance (milestone complete)
- [ ] All session acceptance boxes above.
- [ ] `config_schema.ddl_v1` and PLAN §11.2 are byte-identical, both carrying
the new line; `migrations.steps` still holds exactly one step and
`target_version` is still 1.
- [ ] The live smoke: EasyList shows a large `skipped_unsupported` and
StevenBlack shows 0, in the API and in both UI tables, and both values
survive a server restart.
- [ ] `nxdns export` output is byte-identical before and after a refresh that
wrote the new column (runtime columns stay out of exports).
- [ ] The regenerated contract samples typecheck against `web/src/lib/types.ts`
— that is what the sample mechanism proves, and it covers server-to-
TypeScript agreement only.
- [ ] `src/web/openapi.yaml` is reviewed **by hand** against the two changed
response shapes, and the reviewer says so in `## Recorded`. No automated
guard covers this: `web_integration_test.zig:2089` checks that routes and
methods exist and `:2575` counts operations, but nothing compares a
component schema to a real response. An openapi.yaml that omits the new
field will pass every gate in this repo.
## Anti-requirements
- No migration step, no `ddl_v2`, no runtime schema probing. The baseline is
edited in place per §3.7; a pre-edit scratch database is deleted, not
reconciled.
- No merging of the two skip counters into one number, anywhere — not in the
API, not in a UI total, not in prose. They are different facts.
- No "this list targets browsers" heuristic: no threshold, badge, warning
color or ratio computation in the UI. The number plus the note paragraph is
the surface; a cutoff would be an invented policy.
- No conditional rendering of the new column. It shows at 0 like every other
counter column.
- No change to `nxdns export` / `import` ZON: compile statistics are not
configuration.
- No per-line diagnostics, no sample of skipped lines in logs, API or UI —
counters and health surfaces, not log spam (AGENTS.md).
- No change to `rejectedWithoutEntries` or `failNoValidEntries` semantics.
- No hand edits to `contractSamples.gen.ts`.
- No new columns beyond the one. See ruling 7 for what that leaves open and
why — the reason is a scope decision, not a claim that the other counters
are already visible.
## Addendum (post-`1bce81e`): the body checksum is framed
A filtering correctness bug found by the implementation review, predating this
milestone. Fixed as a follow-up commit; this addendum is its design record —
the user ruled it a follow-up, not a milestone 25.
### The defect
`bodyChecksum` (manager.zig:1660) and the compiler's incremental hashing
(compiler.zig:105-108) both digest the unframed concatenation
`list ++ wild ++ allow`. The compiler strips `*.` from a wildcard candidate
(compiler.zig:130-133), so upstream `a.example` (list `a.example\n`, wild
empty) and upstream `*.a.example` (list empty, wild `a.example\n`) hash the
same bytes. `diskBodiesMatch` (manager.zig:1085) recomputes with the same
function, so the refresh takes the unchanged-checksum branch and a list that
switches an exact block to a wildcard block never takes effect. Ruling 3's
entry-count argument rested on the digest distinguishing bodies; it does not,
and the strikethrough above records that.
### The fix: a `0x00` separator after each body
The digest becomes `SHA-256(list ‖ 00 ‖ wild ‖ 00 ‖ allow ‖ 00)` — one zero
byte fed to the hasher **after each of the three bodies**, same order as
today. Soundness: a compiled body holds only validated name bytes and `\n`;
`addCandidate` rejects any byte ≥ `0x80` or control byte
(compiler.zig:151-155), so `0x00` cannot occur in a body and the three
boundaries are unambiguous. Two distinct `(list, wild, allow)` triples cannot
produce one digest short of SHA-256 itself.
A separator, not a length prefix, because the compiler hashes while it emits
and does not know a body's length up front; a trailing byte needs no pre-pass.
Both producers move together or every refresh republishes forever:
`compiler.compile` feeds the byte after each `emit` call, and
`manager.bodyChecksum` feeds it after each body slice. Export the separator as
a `pub const` from `compiler.zig` and have `bodyChecksum` use it — two literal
`0`s in two files is how the next drift starts.
### Consequences, accepted
- Every stored checksum changes once. Zero installs; a development data
directory fails its startup checksum verification and the startup refresh
pass re-downloads and repairs it (or delete the data directory — the ruling
1 stance).
- Checksum compatibility with pre-exception digests — milestone 21 ruling 3's
"empty allow body reproduces the old digest" property — is dead, and its
rationale comments go with it: `Result.checksum` (compiler.zig:44-53), the
`Header` doc and `bodyChecksum` doc (manager.zig:218-225, 1645-1648),
`SourceStats.checksum` (sources_repo.zig), and the PLAN §3.8 sentence
"keeps the digest it had when only two existed" (PLAN.md:101). All are
rewritten to state the framed digest. The body order stays list, wild,
allow.
### The branch reads all five fresh
On the checksum-unchanged path, all five stat fields —
`domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
`skipped_unsupported_count` — now read from `compiled.result.counts`;
`checksum` keeps passing `stored`. With framing, fresh and stored entry counts
are provably equal, so this is not a correctness requirement — it removes the
per-field vouching argument from the code entirely, and any future digest
weakness then degrades to consistent stats rather than a split between
database and status table.
### Regression tests (each watched failing with the framing reverted)
- [ ] Compiler: compiling `a.example` and compiling `*.a.example` produce
**different** checksums. This is the collision itself.
- [ ] Manager: publish a source whose body is `a.example`; refresh it with
upstream bytes `*.a.example`. Assert the unchanged-checksum branch is
**not** taken: the on-disk `.wild` stripped body is `a.example\n`, the
`.list` body is empty, and the database row reads `domain_count = 0`,
`wildcard_count = 1`.
- [ ] Agreement: `bodyChecksum` over the three stripped on-disk bodies equals
`compile`'s reported checksum for a fixture where **all three bodies are
non-empty** (extend the existing agreement coverage if it exists; the
empty-allow case no longer exercises the third frame).
Report the observed failure output for each, per the ruling 3 convention.
+1 -10
View File
@@ -1527,16 +1527,7 @@ test "bare check with no config database exits 2 and says how to make one" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
// Not a bare count of "OK" over the whole text: this message embeds the
// temporary directory path, and `std.testing.tmpDir` names that directory
// with base64 over random bytes, so a run whose name happens to carry those
// two letters would fail a test that has nothing to do with naming. Every
// verdict this command prints — `OK:` and `OK upstreams[...]` alike — opens
// a line, so that is what to assert on.
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
try testing.expect(!std.mem.startsWith(u8, line, "OK"));
}
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
try testing.expectEqualStrings("", captured.err.written());
}
+8 -32
View File
@@ -1,9 +1,6 @@
//! The one *declarative* configuration model: loading, reconciliation, import,
//! export and the running server all speak this struct, and it is the whole
//! shape of a config file. It is not the only shape the repositories accept —
//! the API edits rows one at a time through narrower inputs such as
//! `RuleInput`, `ClientInput` and `ClientEdit`, so a field added here does not
//! reach those paths by itself.
//! The one configuration model. Bootstrap, import, export, the repositories and
//! the running server all speak this struct; nothing else describes nxdns
//! configuration.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
@@ -11,21 +8,11 @@
//! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! exception_count, skipped_regex_count, skipped_unsupported_count, checksum}`
//! are facts a running server produces, not configuration. Including them would make two exports taken
//! minutes apart differ, which would make the byte-stable round trip untestable
//! against a live server.
//!
//! Declarative configuration reaches the database through exactly one path:
//! `config/reconcile.zig`. `nxdns import` is a thin wrapper over it, and so is
//! `run --config`. Reconciliation asks what changed rather than replacing
//! wholesale, so a row the input still names keeps the runtime state attached
//! to it: a source matched by url keeps its id, checksum and counters, a rule
//! keeps its `created_at`, and a client keeps its first-seen and last-seen
//! stamps. The source id and checksum are the two that decide whether a
//! file-mode restart reuses the compiled bodies or downloads them again:
//! `loadSource` names the files after the id and accepts them only against the
//! stored checksum. The counters ride along as reported state.
//! skipped_regex_count, checksum}` are facts a running server produces, not
//! configuration. Including them would make two exports taken minutes apart
//! differ, which would make the byte-stable round trip untestable against a
//! live server. Import sets the timestamps to the import time and leaves the
//! counters at their column defaults.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -269,25 +256,20 @@ pub const BlocklistSource = struct {
pub const GroupSource = struct { group: []const u8, source_url: []const u8 };
/// The three spellings `CHECK(kind IN ('exact','wildcard','regex'))` admits
/// after migration step 4.
pub const RuleKind = enum {
exact,
wildcard,
regex,
pub fn toDb(self: RuleKind) []const u8 {
return switch (self) {
.exact => "exact",
.wildcard => "wildcard",
.regex => "regex",
};
}
pub fn fromDb(text: []const u8) ?RuleKind {
if (std.mem.eql(u8, text, "exact")) return .exact;
if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
if (std.mem.eql(u8, text, "regex")) return .regex;
return null;
}
};
@@ -807,12 +789,6 @@ test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(RecordType);
}
test "RuleKind carries the third kind through export and import" {
try testing.expectEqualStrings("regex", RuleKind.regex.toDb());
try testing.expectEqual(RuleKind.regex, RuleKind.fromDb("regex").?);
try testing.expect(RuleKind.fromDb("Regex") == null);
}
test "RecordType stores the uppercase DDL spelling" {
try testing.expectEqualStrings("A", RecordType.a.toDb());
try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
+4 -53
View File
@@ -3,10 +3,10 @@
//!
//! The defect this module exists to fix: `import.applyToDb` deletes and
//! reinserts every row, `blocklist_sources` included, and the compiled
//! blocklists are named after the source row id (`<id>.list` / `<id>.wild` /
//! `<id>.allow`). A configuration re-applied on every boot would therefore hand
//! every source a new id, orphan every compiled file, and re-download every
//! blocklist on every restart.
//! blocklists are named after the source row id (`<id>.list` / `<id>.wild`). A
//! configuration re-applied on every boot would therefore hand every source a
//! new id, orphan every compiled file, and re-download every blocklist on every
//! restart.
//!
//! So nothing is wiped. Every table has an identity; a row the file and the
//! database agree on is **updated in place**, keeping its row id and every
@@ -1100,9 +1100,7 @@ fn seedSourceStats(database: *db.Db, id: i64) !void {
.last_updated = 1_700_000_000,
.domain_count = 4321,
.wildcard_count = 21,
.exception_count = 9,
.skipped_regex_count = 7,
.skipped_unsupported_count = 33,
.checksum = "a" ** 64,
});
}
@@ -1171,9 +1169,6 @@ test "a source keeps its id, its checksum and its counters across a reconcile" {
try testing.expectEqualStrings("advertising", row.name);
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
try testing.expectEqual(@as(i64, 4321), row.domain_count);
try testing.expectEqual(@as(i64, 9), row.exception_count);
try testing.expectEqual(@as(i64, 7), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 33), row.skipped_unsupported_count);
try testing.expectEqualStrings("a" ** 64, row.checksum.?);
}
@@ -1555,50 +1550,6 @@ test "rules keep created_at across a reconcile, duplicates included" {
}
}
test "a regex rule declared in the file converges into the table and back out" {
var bench: Bench = undefined;
try bench.init();
defer bench.deinit();
// Under `.managed_file` authority the API refuses rule writes, so this is
// the only way a regex rule reaches the table in that mode. `reconcileRules`
// compares the whole tuple and needs no code of its own for the new kind.
const with_regex: [:0]const u8 =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .rules = .{
\\ .{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
\\ },
\\}
;
const first = try bench.apply(with_regex, 1_700_000_000);
try testing.expectEqual(@as(u32, 1), first.rules.inserted);
const gpa = testing.allocator;
var rows = try rules_repo.listRuleRows(&bench.database, gpa);
defer rows.deinit(gpa);
defer rules_repo.freeRuleRows(gpa, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqual(model.RuleKind.regex, rows.items[0].kind);
try testing.expectEqualStrings("^ad[0-9]+-", rows.items[0].pattern);
// Idempotent: the tuple matches itself, so a second pass writes nothing.
const second = try bench.apply(with_regex, 1_800_000_000);
try testing.expectEqual(@as(u32, 0), second.rules.total());
// And a file that stops declaring it takes the row with it.
const without: [:0]const u8 =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
const third = try bench.apply(without, 1_900_000_000);
try testing.expectEqual(@as(u32, 1), third.rules.deleted);
try testing.expectEqual(@as(i64, 0), try rules_repo.countRules(&bench.database));
}
test "dropping one of two identical rules removes exactly one row" {
var bench: Bench = undefined;
try bench.init();
+4 -111
View File
@@ -44,7 +44,6 @@ const Writer = std.Io.Writer;
const model = @import("model.zig");
const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("../upstream/transport.zig");
@@ -891,31 +890,13 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
for (cfg.rules, 0..) |rule, i| {
try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i});
// `null` is a good pattern; anything else is the sentence fragment that
// says which of the regex engine's limits refused it. The empty string
// is a plain syntax refusal, which is the only verdict the exact and
// wildcard kinds can reach.
const detail: ?[]const u8 = if (patternIsValid(scratch, rule.pattern, rule.kind)) |valid|
(if (valid) null else "")
else |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.BadPattern => "",
error.PatternTooLong => std.fmt.comptimePrint(
" (over {d} bytes)",
.{regex.max_pattern_len},
),
error.PatternTooComplex => std.fmt.comptimePrint(
" (over {d} compiled instructions)",
.{regex.max_program_len},
),
};
if (detail) |suffix| {
if (!try patternIsValid(scratch, rule.pattern, rule.kind)) {
try diags.add(
error.BadRulePattern,
"rules[{d}].pattern",
.{i},
"{f} is not a valid {s} pattern{s}",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb(), suffix },
"{f} is not a valid {s} pattern",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb() },
);
}
}
@@ -1115,18 +1096,11 @@ fn sourceUrlIsValid(url: []const u8) bool {
/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no
/// `*` at all, a wildcard pattern carries at least one label that is exactly
/// `*`, and every remaining label must survive `dns.name.fromText`.
///
/// A regex pattern is validated by compiling it, and its three refusals arrive
/// as errors rather than as `false` so the caller can name the one that fired.
/// The distinction is the operator's, not the compiler's: "not a valid regex
/// pattern" sends someone hunting for a typo in a pattern whose only fault is
/// that it is longer than `regex.max_pattern_len` or wider than
/// `regex.max_program_len`, and neither limit is visible in the pattern text.
fn patternIsValid(
scratch: Allocator,
pattern: []const u8,
kind: model.RuleKind,
) regex.Error!bool {
) error{OutOfMemory}!bool {
switch (kind) {
.exact => {
if (std.mem.findScalar(u8, pattern, '*') != null) return false;
@@ -1152,11 +1126,6 @@ fn patternIsValid(
_ = dns_name.fromText(substituted.items) catch return false;
return true;
},
.regex => {
var program = try regex.compile(scratch, pattern);
program.deinit(scratch);
return true;
},
}
}
@@ -2136,82 +2105,6 @@ test "rule patterns accept wildcards only when the kind says so" {
try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern");
}
/// The message of the first failure, so a test can assert the sentence an
/// operator reads and not only the error tag.
fn expectMessage(cfg: Config, expected: ValidateError, expected_message: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(expected, validate(cfg, &diags));
const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings(expected_message, failure.message);
}
/// The tail of the first failure's message. `quoteText` truncates the value it
/// quotes at `safe_url.max_len`, so a diagnostic about an over-long pattern
/// cannot be matched whole.
fn expectMessageSuffix(cfg: Config, expected: ValidateError, expected_suffix: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(expected, validate(cfg, &diags));
const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
if (!std.mem.endsWith(u8, failure.message, expected_suffix)) {
std.debug.print("message {s} does not end with {s}\n", .{ failure.message, expected_suffix });
return error.TestExpectedEqual;
}
}
fn regexRule(pattern: []const u8) [1]model.Rule {
return .{.{ .group = "default", .pattern = pattern, .kind = .regex, .action = .block }};
}
test "a regex rule is validated by compiling it" {
var cfg = baseConfig();
const good = regexRule("^ad[0-9]+-\\.(example|test)\\.com$");
cfg.rules = &good;
try expectClean(cfg);
// A regex is not a name: the wildcard and exact kinds reject `*`, and this
// one has to accept the characters that make a pattern a pattern.
const starred = regexRule("ads.*\\.example");
cfg.rules = &starred;
try expectClean(cfg);
}
test "each of the regex engine's three refusals names itself in the diagnostic" {
var cfg = baseConfig();
const unclosed = regexRule("(");
cfg.rules = &unclosed;
try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
try expectMessage(cfg, error.BadRulePattern, "'(' is not a valid regex pattern");
// Too long and too complex are the two an operator cannot see by reading
// the pattern, so the message has to carry the limit that fired.
const too_long = regexRule("a" ** (regex.max_pattern_len + 1));
cfg.rules = &too_long;
try expectMessageSuffix(
cfg,
error.BadRulePattern,
"is not a valid regex pattern (over 256 bytes)",
);
// Well inside 256 bytes of pattern, well past 1024 instructions of program.
const too_complex = regexRule("(abcdefghij){200}");
cfg.rules = &too_complex;
try expectMessage(
cfg,
error.BadRulePattern,
"'(abcdefghij){200}' is not a valid regex pattern (over 1024 compiled instructions)",
);
}
test "an empty regex pattern is refused rather than matching every name" {
var cfg = baseConfig();
const empty = regexRule("");
cfg.rules = &empty;
try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
}
test "parseResolver accepts udp and tcp with an IP literal and a port" {
const udp4 = try parseResolver("udp://192.168.1.1:53");
try testing.expectEqual(ResolverScheme.udp, udp4.scheme);
+44 -199
View File
@@ -1,15 +1,14 @@
//! Compiles a downloaded blocklist into the three bodies nxdns stores on disk:
//! a `.list` body of exact names, a `.wild` body of suffixes and an `.allow`
//! body of the names the list's `@@` exceptions lift.
//! Compiles a downloaded blocklist into the two bodies nxdns stores on disk:
//! a `.list` body of exact names and a `.wild` body of suffixes.
//!
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and
//! three `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body
//! is a pure function of (bytes, format), which is what makes two runs — and two
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and two
//! `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body is a
//! pure function of (bytes, format), which is what makes two runs — and two
//! permutations of the same input — byte-identical.
//!
//! Nothing but the sorted, deduplicated names is written: no header, no
//! timestamp, no counts. The header belongs to the caller, and the checksum
//! covers the three bodies only.
//! covers the two bodies only.
const std = @import("std");
const parsers = @import("parsers.zig");
@@ -24,12 +23,6 @@ pub const max_line_len: usize = 4096;
pub const Counts = struct {
domains: u32 = 0,
wildcards: u32 = 0,
/// Written, deduplicated `.allow` entries: the names this list's `@@`
/// exceptions lift out of what other lists block.
exceptions: u32 = 0,
/// Regex lines this list carried, counted and skipped. nxdns has an engine
/// for them now, but it stays reserved for operator rules: a downloaded list
/// is other people's patterns, and PLAN §2.2 keeps them out.
skipped_regex: u32 = 0,
skipped_unsupported: u32 = 0,
/// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
@@ -43,49 +36,30 @@ pub const Counts = struct {
pub const Result = struct {
counts: Counts,
/// Lowercase hex sha256 over the `.list` body, the `.wild` body and the
/// `.allow` body in that order, each followed by `body_separator`.
///
/// The separator is what makes the digest identify a compile. Without it
/// the three bodies concatenate ambiguously: a wildcard is stored with its
/// `*.` stripped, so an upstream that changes `a.example` to `*.a.example`
/// moves the same bytes from the `.list` body to the `.wild` body and
/// hashes to the same digest. `Manager.diskBodiesMatch` would then accept
/// the stale files, the refresh would keep them, and the wildcard would
/// never take effect.
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
checksum: [64]u8,
};
/// Fed to the checksum hasher after each of the three bodies, so the digest
/// reads them as three fields rather than one run of bytes.
///
/// `0x00` is sound as a separator because it can never occur inside a body:
/// `addCandidate` rejects every control byte and every byte at or above `0x80`,
/// so a body holds none. Any producer of this digest must use this constant —
/// `compiler.compile` hashes while it emits and `Manager.bodyChecksum` hashes
/// three finished buffers, and a one-byte disagreement between them would make
/// every refresh republish for ever.
pub const body_separator = "\x00";
pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };
/// Reads `r` to end of stream and writes the three compiled bodies.
/// Reads `r` to end of stream and writes the two compiled bodies.
///
/// `counts.domains`, `counts.wildcards` and `counts.exceptions` are the written,
/// deduplicated counts: they are what `blocklist_sources.domain_count`,
/// `wildcard_count` and `exception_count` store and what the UI shows.
/// `counts.domains` and `counts.wildcards` are the written, deduplicated
/// counts: they are what `blocklist_sources.domain_count` and `wildcard_count`
/// store and what the UI shows.
pub fn compile(
gpa: std.mem.Allocator,
r: *std.Io.Reader,
format: parsers.Format,
list_w: *std.Io.Writer,
wild_w: *std.Io.Writer,
allow_w: *std.Io.Writer,
) Error!Result {
var counts: Counts = .{};
var bodies: Bodies = .{};
defer bodies.deinit(gpa);
var list: Entries = .{};
defer list.deinit(gpa);
var wild: Entries = .{};
defer wild.deinit(gpa);
while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
const raw = switch (event) {
@@ -107,30 +81,32 @@ pub fn compile(
.domain => {
var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace);
while (fields.next()) |field| {
try addCandidate(gpa, field, parsed, &bodies, &counts);
try addCandidate(gpa, field, false, false, &list, &wild, &counts);
}
},
.wildcard, .exception => try addCandidate(gpa, parsed.text, parsed, &bodies, &counts),
.wildcard => try addCandidate(
gpa,
parsed.text,
true,
parsed.covers_apex,
&list,
&wild,
&counts,
),
}
}
// Each body is followed by `body_separator`, which is what keeps the digest
// from confusing a name in one body with the same name in another.
var hasher = Sha256.init(.{});
counts.domains = try emit(&bodies.list, list_w, &hasher, &counts.duplicates);
hasher.update(body_separator);
counts.wildcards = try emit(&bodies.wild, wild_w, &hasher, &counts.duplicates);
hasher.update(body_separator);
counts.exceptions = try emit(&bodies.allow, allow_w, &hasher, &counts.duplicates);
hasher.update(body_separator);
counts.domains = try emit(&list, list_w, &hasher, &counts.duplicates);
counts.wildcards = try emit(&wild, wild_w, &hasher, &counts.duplicates);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) };
}
/// Normalizes one whitespace-separated candidate of `line` and files it under
/// `.list`, `.wild`, `.allow`, or nowhere.
/// Normalizes one whitespace-separated candidate and files it under `.list`,
/// `.wild`, or neither.
///
/// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every
@@ -138,19 +114,20 @@ pub fn compile(
fn addCandidate(
gpa: std.mem.Allocator,
field: []const u8,
line: parsers.Line,
bodies: *Bodies,
from_wildcard_line: bool,
covers_apex: bool,
list: *Entries,
wild: *Entries,
counts: *Counts,
) Error!void {
var candidate = field;
var is_wildcard = line.kind == .wildcard;
var is_wildcard = from_wildcard_line;
if (std.mem.startsWith(u8, candidate, "*.")) {
is_wildcard = true;
candidate = candidate[2..];
}
// A '*' anywhere else makes this a pattern, and patterns belong to the
// `rules` table, where the operator writes them as a `.wildcard` or a
// `.regex`; a blocklist entry is a name or a suffix.
// `rules` table; a blocklist entry is a name or a suffix.
if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
counts.invalid += 1;
return;
@@ -186,17 +163,12 @@ fn addCandidate(
return;
}
// An exception needs no apex entry beside its suffix entry: the matcher
// walks the `.allow` set over the full name and every parent, so one entry
// lifts `x` and every subdomain of it at once.
if (line.kind == .exception) {
try bodies.allow.append(gpa, normalized);
} else if (is_wildcard) {
try bodies.wild.append(gpa, normalized);
if (is_wildcard) {
try wild.append(gpa, normalized);
// An ABP `||x^` rule covers `x` itself as well as its subdomains.
if (line.covers_apex) try bodies.list.append(gpa, normalized);
if (covers_apex) try list.append(gpa, normalized);
} else {
try bodies.list.append(gpa, normalized);
try list.append(gpa, normalized);
}
}
@@ -230,20 +202,6 @@ fn emit(
return written;
}
/// The three bodies under construction, in the order they are written and
/// hashed.
const Bodies = struct {
list: Entries = .{},
wild: Entries = .{},
allow: Entries = .{},
fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
self.list.deinit(gpa);
self.wild.deinit(gpa);
self.allow.deinit(gpa);
}
};
/// Length-prefixed candidate bytes plus the offsets that index them. Sorting
/// permutes the offsets, so the bytes never move.
const Entries = struct {
@@ -289,12 +247,10 @@ const Compiled = struct {
result: Result,
list_w: std.Io.Writer.Allocating,
wild_w: std.Io.Writer.Allocating,
allow_w: std.Io.Writer.Allocating,
fn deinit(self: *Compiled) void {
self.list_w.deinit();
self.wild_w.deinit();
self.allow_w.deinit();
}
fn list(self: *Compiled) []const u8 {
@@ -304,10 +260,6 @@ const Compiled = struct {
fn wild(self: *Compiled) []const u8 {
return self.wild_w.written();
}
fn allow(self: *Compiled) []const u8 {
return self.allow_w.written();
}
};
fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
@@ -320,10 +272,8 @@ fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Form
errdefer list_w.deinit();
var wild_w: std.Io.Writer.Allocating = .init(gpa);
errdefer wild_w.deinit();
var allow_w: std.Io.Writer.Allocating = .init(gpa);
errdefer allow_w.deinit();
const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer, &allow_w.writer);
return .{ .result = result, .list_w = list_w, .wild_w = wild_w, .allow_w = allow_w };
const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer);
return .{ .result = result, .list_w = list_w, .wild_w = wild_w };
}
const hosts_fixture =
@@ -389,110 +339,10 @@ test "abp apex rule lands in both bodies" {
try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
try testing.expectEqualStrings("x.com\n", c.wild());
try testing.expectEqualStrings("z.com\n", c.allow());
try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
try testing.expectEqual(@as(u32, 1), c.result.counts.exceptions);
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
}
test "an abp list's hosts-style lines reach the domain body through the split" {
// The case `parser_abp` defers here: it hands a whitespace-carrying bare
// candidate over whole, and only the tokenization in `compile` files the
// name out of it. A mixed list — `!` header and `||` rules, so `detectFormat`
// calls the whole file `abp`, plus the hosts lines such lists carry — reaches
// a compiled body no other way, and no parser test can see it happen.
const fixture =
"! Title: mixed\n" ++
"||blocked.example^\n" ++
"0.0.0.0 ads.example\n" ++
"127.0.0.1 localhost\n";
var c = try compileText(testing.allocator, fixture, .abp);
defer c.deinit();
// The address field is filed as a name of its own: abp lines have no hosts
// framing, so the compiler cannot know which field is the address. `0.0.0.0`
// and `127.0.0.1` are names nobody resolves, which is why the split is worth
// more than the two spurious entries cost.
try testing.expectEqualStrings(
"0.0.0.0\n127.0.0.1\nads.example\nblocked.example\n",
c.list(),
);
try testing.expectEqualStrings("blocked.example\n", c.wild());
try testing.expectEqual(@as(u32, 4), c.result.counts.domains);
// `localhost` is the one label the two-label minimum drops.
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
}
test "the allow body is sorted, deduplicated and normalized like the others" {
const fixture =
"@@||GOOD.ads.example^\n" ++
"@@||a.ads.example^$important\n" ++
"@@||good.ads.example\n" ++
"@@||localhost^\n" ++
"@@||bad*.ads.example^\n" ++
"||ads.example^\n";
var c = try compileText(testing.allocator, fixture, .abp);
defer c.deinit();
try testing.expectEqualStrings("a.ads.example\ngood.ads.example\n", c.allow());
try testing.expectEqual(@as(u32, 2), c.result.counts.exceptions);
try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
// `localhost` is one label, and the starred name is not a name at all.
try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
// The exceptions changed neither block body.
try testing.expectEqualStrings("ads.example\n", c.list());
try testing.expectEqualStrings("ads.example\n", c.wild());
}
test "moving a name between bodies changes the checksum" {
// The collision the separator exists to prevent. A wildcard is stored with
// its `*.` stripped, so both compiles write the bytes `a.example\n` — one
// into the `.list` body, one into the `.wild` body. Unframed, the two hash
// identically, `diskBodiesMatch` accepts the stale files, and an upstream
// that switched a name to a wildcard never takes effect.
var exact = try compileText(testing.allocator, "a.example\n", .domains);
defer exact.deinit();
var wild = try compileText(testing.allocator, "*.a.example\n", .domains);
defer wild.deinit();
try testing.expectEqualStrings("a.example\n", exact.list());
try testing.expectEqualStrings("", exact.wild());
try testing.expectEqualStrings("", wild.list());
try testing.expectEqualStrings("a.example\n", wild.wild());
try testing.expect(!std.mem.eql(u8, &exact.result.checksum, &wild.result.checksum));
}
test "the checksum of a source with exceptions covers all three bodies in order" {
const fixture =
"||ads.example^\n" ++
"@@||good.ads.example^\n";
var c = try compileText(testing.allocator, fixture, .abp);
defer c.deinit();
var hasher = Sha256.init(.{});
hasher.update(c.list());
hasher.update(body_separator);
hasher.update(c.wild());
hasher.update(body_separator);
hasher.update(c.allow());
hasher.update(body_separator);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &c.result.checksum);
// A non-empty allow body does move the digest, so a list that gains an
// exception is recompiled rather than silently kept.
var without = try compileText(testing.allocator, "||ads.example^\n", .abp);
defer without.deinit();
try testing.expect(!std.mem.eql(u8, &c.result.checksum, &without.result.checksum));
try testing.expectEqual(@as(u32, 2), c.result.counts.skipped_unsupported);
}
test "two runs of the same input are byte-identical" {
@@ -503,7 +353,6 @@ test "two runs of the same input are byte-identical" {
try testing.expectEqualStrings(a.list(), b.list());
try testing.expectEqualStrings(a.wild(), b.wild());
try testing.expectEqualStrings(a.allow(), b.allow());
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}
@@ -525,7 +374,6 @@ test "a permutation of the input compiles to the same bodies" {
try testing.expectEqualStrings(a.list(), b.list());
try testing.expectEqualStrings(a.wild(), b.wild());
try testing.expectEqualStrings(a.allow(), b.allow());
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}
@@ -641,17 +489,14 @@ test "carriage returns are stripped" {
try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
}
test "empty input produces empty bodies and the digest of three separators" {
test "empty input produces empty bodies and the sha256 of the empty string" {
var c = try compileText(testing.allocator, "", .domains);
defer c.deinit();
try testing.expectEqualStrings("", c.list());
try testing.expectEqualStrings("", c.wild());
try testing.expectEqualStrings("", c.allow());
// Three empty bodies still hash their three separators, so an empty compile
// has a digest of its own rather than the sha256 of the empty string.
try testing.expectEqualStrings(
"709e80c88487a2411e1ee4dfb9f22a861492d20c4765150c0c794abd70f8147c",
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
&c.result.checksum,
);
}
+26 -522
View File
@@ -33,13 +33,10 @@ const sources_repo = @import("../storage/repositories/sources_repo.zig");
const compiler = @import("compiler.zig");
const fetcher = @import("fetcher.zig");
const parsers = @import("parsers.zig");
const manager = @import("manager.zig");
const matcher = @import("matcher.zig");
const response = @import("response.zig");
const lookup = @import("../web/handlers/lookup.zig");
const forward_client = @import("../local/forward_client.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
@@ -63,11 +60,6 @@ const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awak
/// The forward-zone read timeout. Case 15 asserts a silent resolver gives up
/// inside twice this, so it has to be short enough to keep the run quick and
/// long enough that a loopback answer always beats it.
/// `/api/lookup` reports the local tables beside the filter decision; this
/// suite's cases are about the filter half, so both are empty here.
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const read_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);
@@ -119,88 +111,49 @@ const http_body =
const http_domains: i64 = 2;
const http_wildcards: i64 = 1;
const http_exceptions: i64 = 0;
const http_regex: i64 = 1;
/// Zero, and asserted rather than assumed: a hosts list carries no line a DNS
/// sinkhole cannot translate, which is what makes the abp fixture below a
/// separate source instead of two more lines in this one.
const http_unsupported: i64 = 0;
/// An ABP-format list: one element-hiding rule and one `$`-modifier rule that
/// nxdns counts and skips, beside two names it blocks. `detectFormat` assigns
/// one format to a whole source, so these lines cannot join `http_body` — a
/// single `##` there would re-parse every hosts line as ABP.
const abp_body =
"! small abp list\n" ++
"##.ad-banner\n" ++
"||ads.example^$third-party\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
/// `||blocked.example^` covers its own apex, so it writes one `.list` entry
/// beside its `.wild` one; `tracker.example` writes the second.
const abp_domains: i64 = 2;
const abp_wildcards: i64 = 1;
const abp_unsupported: i64 = 2;
/// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under
/// `dir`, exactly as the manager's compile stage does, and returns the
/// compiler's own result.
/// Compiles `text` into `<base>.list` and `<base>.wild` under `dir`, exactly as
/// the manager's compile stage does, and returns the compiler's own result.
fn compileToFiles(
gpa: std.mem.Allocator,
io: std.Io,
dir: std.Io.Dir,
base: []const u8,
text: []const u8,
format: parsers.Format,
) !compiler.Result {
var list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined;
var allow_name_buf: [64]u8 = undefined;
const list_name = try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base});
const wild_name = try std.fmt.bufPrint(&wild_name_buf, "{s}.wild", .{base});
const allow_name = try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base});
const list_file = try dir.createFile(io, list_name, .{ .permissions = .fromMode(0o600) });
defer list_file.close(io);
const wild_file = try dir.createFile(io, wild_name, .{ .permissions = .fromMode(0o600) });
defer wild_file.close(io);
const allow_file = try dir.createFile(io, allow_name, .{ .permissions = .fromMode(0o600) });
defer allow_file.close(io);
const buffers = try gpa.alloc(u8, 3 * 16 * 1024);
const buffers = try gpa.alloc(u8, 2 * 16 * 1024);
defer gpa.free(buffers);
var r: std.Io.Reader = .fixed(text);
var list_w = list_file.writer(io, buffers[0 .. 16 * 1024]);
var wild_w = wild_file.writer(io, buffers[16 * 1024 .. 32 * 1024]);
var allow_w = allow_file.writer(io, buffers[32 * 1024 ..]);
var wild_w = wild_file.writer(io, buffers[16 * 1024 ..]);
const result = try compiler.compile(
gpa,
&r,
format,
&list_w.interface,
&wild_w.interface,
&allow_w.interface,
);
const result = try compiler.compile(gpa, &r, .hosts, &list_w.interface, &wild_w.interface);
try list_w.interface.flush();
try wild_w.interface.flush();
try allow_w.interface.flush();
return result;
}
/// The three compiled bodies of one source, read back from disk with their
/// The two compiled bodies of one source, read back from disk with their
/// headers stripped, exactly as `Manager.reload` reads them.
const Bodies = struct {
list: []u8,
wild: []u8,
allow: []u8,
fn read(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, base: []const u8) !Bodies {
var list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined;
var allow_name_buf: [64]u8 = undefined;
const list = try dir.readFileAlloc(
io,
try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base}),
@@ -214,34 +167,21 @@ const Bodies = struct {
gpa,
file_limit,
);
errdefer gpa.free(wild);
const allow = try dir.readFileAlloc(
io,
try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base}),
gpa,
file_limit,
);
return .{ .list = list, .wild = wild, .allow = allow };
return .{ .list = list, .wild = wild };
}
fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
gpa.free(self.list);
gpa.free(self.wild);
gpa.free(self.allow);
self.* = undefined;
}
};
/// A one-group, one-source snapshot over the three compiled bodies.
fn snapshotOver(
gpa: std.mem.Allocator,
list_body: []const u8,
wild_body: []const u8,
allow_body: []const u8,
) !matcher.Snapshot {
/// A one-group, one-source snapshot over two compiled bodies.
fn snapshotOver(gpa: std.mem.Allocator, list_body: []const u8, wild_body: []const u8) !matcher.Snapshot {
const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
const compiled = [_]?matcher.Snapshot.Compiled{
.{ .list_body = list_body, .wild_body = wild_body, .allow_body = allow_body },
.{ .list_body = list_body, .wild_body = wild_body },
};
return matcher.Snapshot.build(gpa, .{
.groups = &.{.{ .name = "default" }},
@@ -258,17 +198,10 @@ fn snapshotOver(
});
}
/// The third producer of this digest, beside `compiler.compile` and
/// `Manager.bodyChecksum`. All three must frame the bodies the same way or the
/// manager reads its own files as damaged.
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 {
var hasher = Sha256.init(.{});
hasher.update(list_body);
hasher.update(compiler.body_separator);
hasher.update(wild_body);
hasher.update(compiler.body_separator);
hasher.update(allow_body);
hasher.update(compiler.body_separator);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
return std.fmt.bytesToHex(digest, .lower);
@@ -420,7 +353,7 @@ const Env = struct {
// fixtures: the loopback http server
// ---------------------------------------------------------------------------
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall };
/// How long the `stall` route holds a reply open when nothing releases it.
///
@@ -467,11 +400,6 @@ const oversize_length = "104857600";
const HttpFixture = struct {
server: net.Server,
body: []const u8,
/// What the `changed` route serves: the same list after its author edited
/// it. A test sets this before the serving task starts and reaches it by
/// switching the route, so the two bodies are read through the atomic that
/// selects them and never written beside a request in flight.
changed_body: []const u8,
route: std.atomic.Value(u8),
/// Connections accepted, whatever came over them. A test that claims a pass
/// downloaded nothing reads this rather than the route counters: a refetch
@@ -498,7 +426,6 @@ const HttpFixture = struct {
return .{
.server = try local.listen(io, .{ .reuse_address = true }),
.body = body,
.changed_body = "",
.route = .init(@intFromEnum(Route.body)),
.accepted = .init(0),
.flushed_parts = .init(0),
@@ -547,7 +474,6 @@ const HttpFixture = struct {
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
.body => try request.respond(self.body, .{ .keep_alive = false }),
.changed => try request.respond(self.changed_body, .{ .keep_alive = false }),
.redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
try request.respond(self.body, .{ .keep_alive = false })
else
@@ -753,7 +679,7 @@ test "1: a compiled hosts fixture loads into a snapshot that blocks its domains"
const text = try hostsFixture(gpa);
defer gpa.free(text);
const result = try compileToFiles(gpa, io, tmp.dir, "1", text, .hosts);
const result = try compileToFiles(gpa, io, tmp.dir, "1", text);
try testing.expectEqual(@as(u32, fixture_domains), result.counts.domains);
try testing.expectEqual(@as(u32, 1), result.counts.skipped_regex);
// The three single-label names are the only invalid candidates here.
@@ -766,7 +692,7 @@ test "1: a compiled hosts fixture loads into a snapshot that blocks its domains"
try testing.expect(std.mem.find(u8, bodies.list, bare) == null);
}
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild);
defer snapshot.deinit();
const group = snapshot.groupIndexByName("default").?;
@@ -806,8 +732,8 @@ test "2: recompiling the same fixture produces byte-identical files and checksum
var second_dir = try tmp.dir.createDirPathOpen(io, "second", .{});
defer second_dir.close(io);
const first = try compileToFiles(gpa, io, first_dir, "1", text, .hosts);
const second = try compileToFiles(gpa, io, second_dir, "1", text, .hosts);
const first = try compileToFiles(gpa, io, first_dir, "1", text);
const second = try compileToFiles(gpa, io, second_dir, "1", text);
try testing.expectEqualStrings(&first.checksum, &second.checksum);
try testing.expectEqual(first.counts, second.counts);
@@ -819,12 +745,11 @@ test "2: recompiling the same fixture produces byte-identical files and checksum
try testing.expectEqualSlices(u8, first_bodies.list, second_bodies.list);
try testing.expectEqualSlices(u8, first_bodies.wild, second_bodies.wild);
try testing.expectEqualSlices(u8, first_bodies.allow, second_bodies.allow);
// The checksum the compiler reported is the one over the three bodies it
// The checksum the compiler reported is the one over the two bodies it
// wrote, which is what the manager stores and compares against.
try testing.expectEqualStrings(
&bodyChecksum(first_bodies.list, first_bodies.wild, first_bodies.allow),
&bodyChecksum(first_bodies.list, first_bodies.wild),
&first.checksum,
);
}
@@ -856,10 +781,8 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.last_updated = 1_700_000_000,
.domain_count = 2,
.wildcard_count = 1,
.exception_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(good_list, good_wild, ""),
.checksum = &bodyChecksum(good_list, good_wild),
});
try env.mgr.reload(io);
@@ -889,10 +812,8 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.last_updated = 1_700_000_000,
.domain_count = 2,
.wildcard_count = 1,
.exception_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(unsorted_list, good_wild, ""),
.checksum = &bodyChecksum(unsorted_list, good_wild),
});
try testing.expectError(error.NotSorted, env.mgr.reload(io));
@@ -949,7 +870,6 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
try testing.expectEqual(http_domains, row.domain_count);
try testing.expectEqual(http_wildcards, row.wildcard_count);
try testing.expectEqual(http_regex, row.skipped_regex_count);
try testing.expectEqual(http_unsupported, row.skipped_unsupported_count);
try testing.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len);
try testing.expect(row.last_updated != null);
@@ -1108,9 +1028,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
.last_updated = 1_000,
.domain_count = http_domains,
.wildcard_count = http_wildcards,
.exception_count = http_exceptions,
.skipped_regex_count = http_regex,
.skipped_unsupported_count = http_unsupported,
.checksum = blk: {
var rows = try listRows(&env.database);
defer rows.deinit();
@@ -1151,9 +1069,6 @@ const ReloadTask = struct {
/// Writes one source's compiled files and records their checksum, without any
/// network: the swap and the orphan sweep care about files and rows, not about
/// where the bytes came from.
///
/// All three files, including an empty `.allow`, because that is what a publish
/// leaves: `publishOne` runs once per body and never skips the empty one.
fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []const u8) !void {
const io = env.io();
var dir = try env.blocklistDir();
@@ -1161,7 +1076,6 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
var list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined;
var allow_name_buf: [64]u8 = undefined;
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
.data = list_body,
@@ -1170,19 +1084,13 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
.sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
.data = wild_body,
});
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&allow_name_buf, "{d}.allow", .{id}),
.data = "",
});
try sources_repo.updateSourceStats(&env.database, id, .{
.last_updated = 1_700_000_000,
.domain_count = 1,
.wildcard_count = 0,
.exception_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, ""),
.checksum = &bodyChecksum(list_body, wild_body),
});
}
@@ -1238,21 +1146,14 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
var dir = try env.blocklistDir();
defer dir.close(io);
// Every name a source can own, spelled out: this fixture is what decides
// whether the sweep covers the whole set, so it enumerates
// `manager.source_file_suffixes` by hand rather than sharing it. A suffix
// added to the manager and not added here is swept by nothing and asserted
// by nothing.
try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "lifted.example.com\n" });
// Id 9999 has no `blocklist_sources` row, so no refresh can be writing for
// it: these temporaries are what a refresh that died mid-write leaves
// behind, and the sweep is the only thing that will ever remove them.
try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.list.tmp", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.wild.tmp", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
// The live source does have a row, so its temporary is a refresh in
// progress and must survive a sweep that runs beside it.
@@ -1264,15 +1165,12 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
var live_buf: [64]u8 = undefined;
try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.list", .{id}), .{});
try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.allow", .{id}), .{});
try dir.access(io, live_tmp, .{});
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list.tmp", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild.tmp", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));
}
test "10b: the scheduler sweeps orphans on its own, with no operator call" {
@@ -1292,10 +1190,8 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
.last_updated = std.Io.Clock.real.now(io).toSeconds(),
.domain_count = 1,
.wildcard_count = 0,
.exception_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, "", ""),
.checksum = &bodyChecksum(list_body, ""),
});
var dir = try env.blocklistDir();
@@ -1305,9 +1201,7 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
// either.
try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
// only one the server ever calls. A disabled update stops it after the
@@ -1318,9 +1212,7 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));
// The live source kept its files and is still filtering: the sweep did not
// take the snapshot the same pass had just published.
@@ -1484,7 +1376,6 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
try testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp"));
try testing.expect(!std.mem.endsWith(u8, entry.name, ".list"));
try testing.expect(!std.mem.endsWith(u8, entry.name, ".wild"));
try testing.expect(!std.mem.endsWith(u8, entry.name, ".allow"));
}
}
@@ -1519,13 +1410,10 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
defer dir.close(io);
var list_buf: [64]u8 = undefined;
var wild_buf: [64]u8 = undefined;
var allow_buf: [64]u8 = undefined;
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
const allow_name = try std.fmt.bufPrint(&allow_buf, "{d}.allow", .{id});
const list_before = try dir.statFile(io, list_name, .{});
const wild_before = try dir.statFile(io, wild_name, .{});
const allow_before = try dir.statFile(io, allow_name, .{});
// File mode, declaring exactly what the database already holds. The engine
// has to recognise the source by its url and leave the row where it is:
@@ -1571,19 +1459,14 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
// decision the pass made rather than a connection it could not have opened.
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
// The same three files: not recompiled, and not swept as orphans and
// written back. `.allow` is asserted with the other two because a restart
// that rewrote only the exception body would otherwise leave this test
// green while changing what the snapshot lets through.
// The same two files: not recompiled, and not swept as orphans and written
// back.
const list_after = try dir.statFile(io, list_name, .{});
const wild_after = try dir.statFile(io, wild_name, .{});
const allow_after = try dir.statFile(io, allow_name, .{});
try testing.expectEqual(list_before.inode, list_after.inode);
try testing.expectEqual(list_before.mtime, list_after.mtime);
try testing.expectEqual(wild_before.inode, wild_after.inode);
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
try testing.expectEqual(allow_before.inode, allow_after.inode);
try testing.expectEqual(allow_before.mtime, allow_after.mtime);
// The row kept the id those files are named after, and the snapshot the
// restart published is the one compiled from them.
@@ -1905,12 +1788,12 @@ test "17: each blocking mode synthesizes the documented blocked reply" {
// The reply is synthesized for a name the compiled files actually block, so
// this case covers the decision and the response together.
const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n", .hosts);
const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n");
try testing.expectEqual(@as(u32, 1), result.counts.domains);
var bodies = try Bodies.read(gpa, io, tmp.dir, "1");
defer bodies.deinit(gpa);
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild);
defer snapshot.deinit();
const group = snapshot.groupIndexByName("default").?;
@@ -2026,382 +1909,3 @@ test "18: a body streamed in flushed parts survives the fetcher's multi-read pum
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
}
}
// ---------------------------------------------------------------------------
// 1920: list exceptions (milestone 21)
// ---------------------------------------------------------------------------
/// A real ABP list: one domain anchor, the exception that lifts one subtree out
/// of it in each of the two accepted spellings, and two `@@` forms nxdns does
/// not honour.
const abp_exception_body =
"[Adblock Plus 2.0]\n" ++
"! Title: exceptions\n" ++
"||ads.example^\n" ++
"||tracker.example^\n" ++
"@@||good.ads.example^\n" ++
"@@||fine.tracker.example$important\n" ++
"@@||paid.ads.example^$third-party\n" ++
"@@partial.ads.example\n";
const abp_exception_domains: i64 = 2;
const abp_exception_wildcards: i64 = 2;
const abp_exception_exceptions: i64 = 2;
test "19: a downloaded list's exceptions lift its own blocks and nothing else" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, abp_exception_body);
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try env.mgr.reload(io);
// The `.allow` body is a third file beside the two, and the row and the
// status entry both carry its count — which is what a
// `POST /api/blocklists/update` response row reports as `exceptions`.
var dir = try env.blocklistDir();
defer dir.close(io);
var bodies = try Bodies.read(gpa, io, dir, "1");
defer bodies.deinit(gpa);
try testing.expectEqualStrings(
"fine.tracker.example\ngood.ads.example\n",
manager.stripHeader(bodies.allow),
);
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(abp_exception_domains, row.domain_count);
try testing.expectEqual(abp_exception_wildcards, row.wildcard_count);
try testing.expectEqual(abp_exception_exceptions, row.exception_count);
const status = try env.status(id);
try testing.expectEqual(manager.State.ok, status.state);
try testing.expect(status.loaded);
try testing.expectEqual(@as(u32, @intCast(abp_exception_exceptions)), status.counts.exceptions);
// The blocks the list makes still land, apex and subdomain alike.
for ([_][]const u8{ "ads.example", "x.ads.example", "paid.ads.example", "partial.ads.example" }) |blocked| {
const decision, _ = try env.evaluate(blocked);
try testing.expect(decision.blocked);
}
// The two exceptions lift the excepted name and everything under it.
for ([_][]const u8{
"good.ads.example",
"y.good.ads.example",
"fine.tracker.example",
"z.fine.tracker.example",
}) |lifted| {
const decision, _ = try env.evaluate(lifted);
try testing.expect(!decision.blocked);
try testing.expectEqual(matcher.Reason.blocklist_exception, decision.reason);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
// What `/api/lookup` answers, through the same function the handler calls:
// the reason names the exception and the source id names the list.
{
const handle = env.mgr.acquire(io) orelse return error.TestNoSnapshot;
defer handle.release(io);
const group_index = handle.snapshot.groupIndexByName("default") orelse
return error.TestGroupMissing;
const result = lookup.evaluate(
handle.snapshot,
group_index,
"y.good.ads.example",
&empty_records,
&empty_zones,
);
try testing.expect(!result.blocked);
try testing.expectEqual(matcher.Reason.blocklist_exception, result.reason);
try testing.expectEqualStrings("good.ads.example", result.matched);
try testing.expectEqual(@as(?i64, id), result.source_id);
const rendered = lookup.body("y.good.ads.example", result, url);
try testing.expectEqualStrings("blocklist_exception", rendered.reason);
try testing.expectEqualStrings(url, rendered.source_url.?);
}
// An operator block rule outranks the list's exception: a downloaded list
// may cancel what a list decided and never what the operator decided.
const group_id = (try groups_repo.groupId(&env.database, "default")) orelse
return error.TestGroupMissing;
_ = try rules_repo.insertRuleRow(&env.database, .{
.group_id = group_id,
.pattern = "good.ads.example",
.kind = .exact,
.action = .block,
}, 1_700_000_000);
try env.mgr.reload(io);
{
const decision, _ = try env.evaluate("good.ads.example");
try testing.expect(decision.blocked);
try testing.expectEqual(matcher.Reason.rule_block_exact, decision.reason);
}
}
test "20: a data directory with no .allow file loads, and an unframed checksum does not" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
const id = try seedSource(&env.database, source_url);
// Two compiled files and no `.allow` file, which is what a source that
// published before exceptions existed left on disk.
const list_body = "aaa.example.com\nbbb.example.com\n";
const wild_body = "ccc.example.com\n";
var dir = try env.blocklistDir();
defer dir.close(io);
var list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined;
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
.data = list_body,
});
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
.data = wild_body,
});
var stats: sources_repo.SourceStats = .{
.last_updated = 1_700_000_000,
.domain_count = 2,
.wildcard_count = 1,
.exception_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = undefined,
};
// The digest those two files carried before the bodies were framed. It is
// not the digest of any body layout the compiler produces now, so the load
// must report the mismatch instead of serving the files.
{
var hasher = Sha256.init(.{});
hasher.update(list_body);
hasher.update(wild_body);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
const unframed = std.fmt.bytesToHex(digest, .lower);
stats.checksum = &unframed;
try sources_repo.updateSourceStats(&env.database, id, stats);
try env.mgr.reload(io);
const status = try env.status(id);
try testing.expectEqual(manager.State.load_failed, status.state);
try testing.expect(!status.loaded);
try testing.expectEqualStrings("ChecksumMismatch", status.errorText());
}
// The same two files under the framed digest of three bodies, the third of
// them empty. The absent `.allow` file is that empty body, so the source
// loads and both entries filter.
{
const framed = bodyChecksum(list_body, wild_body, "");
stats.checksum = &framed;
try sources_repo.updateSourceStats(&env.database, id, stats);
try env.mgr.reload(io);
const status = try env.status(id);
try testing.expectEqual(manager.State.ok, status.state);
try testing.expect(status.loaded);
try testing.expectEqualStrings("", status.errorText());
const decision, _ = try env.evaluate("aaa.example.com");
try testing.expect(decision.blocked);
try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked);
}
}
// ---------------------------------------------------------------------------
// 2122: the unsupported count, from the compile to the database and back
// ---------------------------------------------------------------------------
test "21: an abp list's unsupported lines are counted, persisted and read back" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, abp_body);
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try env.mgr.reload(io);
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(abp_domains, row.domain_count);
try testing.expectEqual(abp_wildcards, row.wildcard_count);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(abp_unsupported, row.skipped_unsupported_count);
const status = try env.status(id);
try testing.expectEqual(manager.State.ok, status.state);
try testing.expectEqual(@as(u32, @intCast(abp_unsupported)), status.counts.skipped_unsupported);
// What the number costs the operator: the `$`-modifier rule named a domain
// and blocked nothing, while the two lines nxdns could translate did block.
try testing.expect(!(try env.evaluate("ads.example"))[0].blocked);
try testing.expect((try env.evaluate("blocked.example"))[0].blocked);
try testing.expect((try env.evaluate("tracker.example"))[0].blocked);
}
/// The same list before and after its author edited only lines nxdns skips.
/// The written entries are identical in both, so the two compiles produce one
/// checksum and the refresh takes the unchanged-checksum path.
const churn_before =
"! churn fixture\n" ++
"##.ad-one\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
const churn_after =
"! churn fixture\n" ++
"##.ad-one\n" ++
"##.ad-two\n" ++
"/ads[0-9]+/\n" ++
"||blocked.example^\n" ++
"tracker.example\n";
const boundary_before = "a.example\n";
const boundary_after = "*.a.example\n";
test "23: a name moving from the list body to the wild body forces a republish" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, boundary_before);
fixture.changed_body = boundary_after;
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
_ = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
var first_checksum: [64]u8 = undefined;
{
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(@as(i64, 1), row.domain_count);
try testing.expectEqual(@as(i64, 0), row.wildcard_count);
@memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
}
}
test "22: a list that changed only its skipped lines still updates both skip counters" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, churn_before);
fixture.changed_body = churn_after;
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
var first_checksum: [64]u8 = undefined;
{
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 1), row.skipped_unsupported_count);
@memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
}
// The edited list. Two more skipped lines and not one written entry moved,
// so the refresh finds its stored checksum and rewrites nothing on disk.
fixture.setRoute(.changed);
try testing.expect(!try refreshOnce(env, url));
{
var rows = try listRows(&env.database);
defer rows.deinit();
const row = try rows.byUrl(url);
try testing.expectEqualStrings(&first_checksum, row.checksum orelse return error.TestNoChecksum);
// The three written counts are the ones an unchanged checksum vouches
// for; the two skip counts are the ones it says nothing about.
try testing.expectEqual(abp_domains, row.domain_count);
try testing.expectEqual(abp_wildcards, row.wildcard_count);
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 2), row.skipped_unsupported_count);
}
const live = try env.status(id);
try testing.expectEqual(@as(u32, 1), live.counts.skipped_regex);
try testing.expectEqual(@as(u32, 2), live.counts.skipped_unsupported);
// The restart. A new manager over the same database and the same files
// carries nothing across in memory, so the status it publishes is what
// rehydration read out of the row — which is the only reason writing the
// fresh counts above matters.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
&env.database,
.{ .dir = env.tmp.dir },
&env.f,
.{ .enabled = false },
budget,
);
try env.mgr.reload(io);
const restored = try env.status(id);
try testing.expectEqual(manager.State.ok, restored.state);
try testing.expect(restored.loaded);
try testing.expectEqual(@as(u32, 1), restored.counts.skipped_regex);
try testing.expectEqual(@as(u32, 2), restored.counts.skipped_unsupported);
}
+73 -257
View File
@@ -38,10 +38,9 @@
//! publish: the download of one source, at up to 300 s each, and the compile
//! that follows it. It also covers blocklist-directory maintenance, because
//! those stages are the only writers of `.raw.tmp` / `.list.tmp` /
//! `.wild.tmp` / `.allow.tmp` and `pruneOrphans` must not sweep the
//! temporaries of a refresh that is still running. Two concurrent refreshes
//! would share the fetcher's buffers and, for one source, the same temporary
//! paths.
//! `.wild.tmp` and `pruneOrphans` must not sweep the temporaries of a refresh
//! that is still running. Two concurrent refreshes would share the fetcher's
//! buffers and, for one source, the same temporary paths.
//!
//! **Lock ordering: `refresh_lock` is never acquired while `writer_lock` is
//! held.** A path that needs both takes `refresh_lock` first. The public entry
@@ -95,7 +94,7 @@ const io_buf_len: usize = 64 * 1024;
/// would then be decided by almost no data.
const sample_buf_len: usize = parsers.sample_lines * (compiler.max_line_len + 1);
/// `<id>` is at most 20 characters and the longest suffix is `.allow.tmp`.
/// `<id>` is at most 20 characters and the longest suffix is `.list.tmp`.
const name_buf_len: usize = 48;
/// How one blocklist source is named in a log line: by its row id and its name,
@@ -216,12 +215,9 @@ pub const SourceStatus = struct {
};
/// The header every compiled file carries, ahead of the body. The `sha256`
/// covers the `.list` body, then the `.wild` body, then the `.allow` body, and
/// **not** the header, so it stays stable across a refetch of unchanged content
/// while `fetched_at` moves.
///
/// Each body is followed by a separator byte, so the digest identifies which
/// body a name sits in rather than only which names were written.
/// covers the `.list` body followed by the `.wild` body and **not** the header,
/// so it stays stable across a refetch of unchanged content while
/// `fetched_at` moves.
pub const Header = struct {
url: []const u8,
format: parsers.Format,
@@ -237,7 +233,6 @@ pub const Header = struct {
try w.print("# fetched_at {d}\n", .{self.fetched_at});
try w.print("# domains {d}\n", .{self.counts.domains});
try w.print("# wildcards {d}\n", .{self.counts.wildcards});
try w.print("# exceptions {d}\n", .{self.counts.exceptions});
try w.print("# skipped_regex {d}\n", .{self.counts.skipped_regex});
try w.print("# skipped_unsupported {d}\n", .{self.counts.skipped_unsupported});
try w.print("# invalid {d}\n", .{self.counts.invalid});
@@ -562,14 +557,12 @@ pub const Manager = struct {
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
var allow_buf: [name_buf_len]u8 = undefined;
const list_name = compiledName(&list_buf, row.id, ".list");
const wild_name = compiledName(&wild_buf, row.id, ".wild");
const allow_name = compiledName(&allow_buf, row.id, ".allow");
// Reserved before the reads, so no buffer can be orphaned by a failing
// append: `bodies` owns each one from the moment it is read.
try bodies.ensureUnusedCapacity(self.gpa, 3);
// Reserved before the reads, so neither buffer can be orphaned by a
// failing append: `bodies` owns each one from the moment it is read.
try bodies.ensureUnusedCapacity(self.gpa, 2);
// `error.Canceled` is the one-shot signal that this task is being torn
// down, and it is consumed by whoever catches it. Recording it as a
@@ -590,38 +583,18 @@ pub const Manager = struct {
};
bodies.appendAssumeCapacity(wild_bytes);
// A missing `.allow` file is an empty allow body, not a failure. The
// digest still covers three bodies, the third of them empty, so a
// source whose list carries no `@@` line matches whether its empty
// `.allow` file survived or not.
const allow_bytes: []const u8 = blk: {
const read = dir.readFileAlloc(io, allow_name, self.gpa, .limited(max_compiled_bytes)) catch |err| {
if (err == error.OutOfMemory) return error.OutOfMemory;
if (err == error.Canceled) return error.Canceled;
if (err == error.FileNotFound) break :blk "";
return loadFailure(row, allow_name, err);
};
bodies.appendAssumeCapacity(read);
break :blk read;
};
const list_body = stripHeader(list_bytes);
const wild_body = stripHeader(wild_bytes);
const allow_body = stripHeader(allow_bytes);
// The checksum covers the three bodies together, so a crash between the
// The checksum covers both bodies together, so a crash between the two
// `replace` calls — a new `.list` beside an old `.wild` — is caught
// here and refreshed, not served as a half-updated list.
if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body, allow_body))) {
if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body))) {
log.warn("blocklist {f}: compiled files do not match the stored checksum", .{SourceLabel.of(row)});
return .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } };
}
return .{ .loaded = .{
.list_body = list_body,
.wild_body = wild_body,
.allow_body = allow_body,
} };
return .{ .loaded = .{ .list_body = list_body, .wild_body = wild_body } };
}
// -----------------------------------------------------------------------
@@ -662,28 +635,23 @@ pub const Manager = struct {
var raw_buf: [name_buf_len]u8 = undefined;
var list_tmp_buf: [name_buf_len]u8 = undefined;
var wild_tmp_buf: [name_buf_len]u8 = undefined;
var allow_tmp_buf: [name_buf_len]u8 = undefined;
const raw_name = compiledName(&raw_buf, row.id, ".raw.tmp");
const tmp: TempNames = .{
.list = compiledName(&list_tmp_buf, row.id, ".list.tmp"),
.wild = compiledName(&wild_tmp_buf, row.id, ".wild.tmp"),
.allow = compiledName(&allow_tmp_buf, row.id, ".allow.tmp"),
};
const list_tmp = compiledName(&list_tmp_buf, row.id, ".list.tmp");
const wild_tmp = compiledName(&wild_tmp_buf, row.id, ".wild.tmp");
// Installed before the calls that create these files, not after: an
// `error.Canceled` or `error.OutOfMemory` returned straight out of
// `download` or `compileTo` would outrun a later `defer` and leave a
// temporary behind. Deleting a name that was never created is a no-op.
defer self.deleteQuietly(io, dir, raw_name);
defer self.deleteQuietly(io, dir, tmp.list);
defer self.deleteQuietly(io, dir, tmp.wild);
defer self.deleteQuietly(io, dir, tmp.allow);
defer self.deleteQuietly(io, dir, list_tmp);
defer self.deleteQuietly(io, dir, wild_tmp);
// The half that takes the time: one download of up to `total_budget`
// and one compile of everything it returned. `refresh_lock` alone is
// held here, so a rule save, a settings change or any other web
// mutation that ends in `reload` runs beside it instead of behind it.
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, tmp);
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, list_tmp, wild_tmp);
// The half that publishes. The compiled files, the runtime columns and
// the status entry land under one `writer_lock`, so a reload never
@@ -691,7 +659,7 @@ pub const Manager = struct {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, list_tmp, wild_tmp);
self.commitStatus(io, status);
return replaced;
}
@@ -722,14 +690,6 @@ pub const Manager = struct {
return self.reload(io);
}
/// The three temporary files one refresh compiles into, before the header
/// is prepended and each is renamed over the file it replaces.
const TempNames = struct {
list: []const u8,
wild: []const u8,
allow: []const u8,
};
/// What the fetch-and-compile half of a refresh produced. `.failed` needs
/// no publish and has already recorded why in the status entry.
const Prepared = union(enum) {
@@ -752,7 +712,8 @@ pub const Manager = struct {
row: sources_repo.SourceRow,
status: *SourceStatus,
raw_name: []const u8,
tmp: TempNames,
list_tmp: []const u8,
wild_tmp: []const u8,
) Error!Prepared {
self.download(io, dir, raw_name, row) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
@@ -772,7 +733,7 @@ pub const Manager = struct {
},
};
const result = self.compileTo(io, dir, raw_name, format, tmp) catch |err| switch (err) {
const result = self.compileTo(io, dir, raw_name, format, list_tmp, wild_tmp) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled,
else => {
@@ -801,7 +762,8 @@ pub const Manager = struct {
row: sources_repo.SourceRow,
status: *SourceStatus,
prepared: Prepared,
tmp: TempNames,
list_tmp: []const u8,
wild_tmp: []const u8,
) Error!bool {
const compiled = switch (prepared) {
.failed => return false,
@@ -820,21 +782,11 @@ pub const Manager = struct {
if (std.mem.eql(u8, stored, &compiled.result.checksum) and
self.diskBodiesMatch(io, dir, row.id, stored))
{
// Every count comes from the compile that just ran, not from
// the row. The two skip counts have to: a skipped line lands in
// no body, so a list that changed only its regex or
// browser-syntax lines reaches here with a stale row. The three
// written counts equal the row's anyway once the digest is
// framed, so reading them from the compile costs nothing and
// leaves no field whose freshness rests on an argument about
// what the checksum covers.
try sources_repo.updateSourceStats(self.database, row.id, .{
.last_updated = now,
.domain_count = compiled.result.counts.domains,
.wildcard_count = compiled.result.counts.wildcards,
.exception_count = compiled.result.counts.exceptions,
.skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.domain_count = row.domain_count,
.wildcard_count = row.wildcard_count,
.skipped_regex_count = row.skipped_regex_count,
.checksum = stored,
});
status.succeed(now, compiled.result.counts);
@@ -849,7 +801,7 @@ pub const Manager = struct {
.counts = compiled.result.counts,
.checksum = &compiled.result.checksum,
};
self.publish(io, dir, row.id, header, tmp) catch |err| switch (err) {
self.publish(io, dir, row.id, header, list_tmp, wild_tmp) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled,
else => {
@@ -862,9 +814,7 @@ pub const Manager = struct {
.last_updated = now,
.domain_count = compiled.result.counts.domains,
.wildcard_count = compiled.result.counts.wildcards,
.exception_count = compiled.result.counts.exceptions,
.skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.checksum = &compiled.result.checksum,
});
status.succeed(now, compiled.result.counts);
@@ -963,7 +913,7 @@ pub const Manager = struct {
return parsers.detectFormat(sample.buffered());
}
/// Compiles into three plain temporary files. The compiled bodies cannot go
/// Compiles into two plain temporary files. The compiled bodies cannot go
/// straight into the final files: the header carries counts that only exist
/// once the whole input has been compiled, and the loader requires the
/// header first.
@@ -973,24 +923,22 @@ pub const Manager = struct {
dir: std.Io.Dir,
raw_name: []const u8,
format: parsers.Format,
tmp: TempNames,
list_tmp: []const u8,
wild_tmp: []const u8,
) !compiler.Result {
const raw = try dir.openFile(io, raw_name, .{});
defer raw.close(io);
const list_file = try dir.createFile(io, tmp.list, .{ .permissions = .fromMode(0o600) });
const list_file = try dir.createFile(io, list_tmp, .{ .permissions = .fromMode(0o600) });
defer list_file.close(io);
const wild_file = try dir.createFile(io, tmp.wild, .{ .permissions = .fromMode(0o600) });
const wild_file = try dir.createFile(io, wild_tmp, .{ .permissions = .fromMode(0o600) });
defer wild_file.close(io);
const allow_file = try dir.createFile(io, tmp.allow, .{ .permissions = .fromMode(0o600) });
defer allow_file.close(io);
const buffers = try self.gpa.alloc(u8, 4 * io_buf_len);
const buffers = try self.gpa.alloc(u8, 3 * io_buf_len);
defer self.gpa.free(buffers);
var fr = raw.reader(io, buffers[0..io_buf_len]);
var list_w = list_file.writer(io, buffers[io_buf_len .. 2 * io_buf_len]);
var wild_w = wild_file.writer(io, buffers[2 * io_buf_len .. 3 * io_buf_len]);
var allow_w = allow_file.writer(io, buffers[3 * io_buf_len ..]);
var wild_w = wild_file.writer(io, buffers[2 * io_buf_len ..]);
const result = compiler.compile(
self.gpa,
@@ -998,22 +946,18 @@ pub const Manager = struct {
format,
&list_w.interface,
&wild_w.interface,
&allow_w.interface,
) catch |err| switch (err) {
// `compiler.Error` names the direction; the concrete cause is on
// the stream that failed.
error.ReadFailed => return fr.err orelse err,
error.WriteFailed => return list_w.err orelse
(wild_w.err orelse (allow_w.err orelse err)),
error.WriteFailed => return list_w.err orelse (wild_w.err orelse err),
else => return err,
};
try list_w.interface.flush();
try wild_w.interface.flush();
try allow_w.interface.flush();
try list_file.sync(io);
try wild_file.sync(io);
try allow_file.sync(io);
return result;
}
@@ -1026,17 +970,16 @@ pub const Manager = struct {
dir: std.Io.Dir,
id: i64,
header: Header,
tmp: TempNames,
list_tmp: []const u8,
wild_tmp: []const u8,
) !void {
const buffers = try self.gpa.alloc(u8, 2 * io_buf_len);
defer self.gpa.free(buffers);
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
var allow_buf: [name_buf_len]u8 = undefined;
try publishOne(io, dir, compiledName(&list_buf, id, ".list"), tmp.list, header, buffers);
try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), tmp.wild, header, buffers);
try publishOne(io, dir, compiledName(&allow_buf, id, ".allow"), tmp.allow, header, buffers);
try publishOne(io, dir, compiledName(&list_buf, id, ".list"), list_tmp, header, buffers);
try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), wild_tmp, header, buffers);
}
fn publishOne(
@@ -1071,18 +1014,12 @@ pub const Manager = struct {
try af.replace(io);
}
/// Whether the compiled files on disk hash to `expected`. A missing,
/// Whether the two compiled files on disk hash to `expected`. A missing,
/// unreadable or corrupt file answers false, which sends the caller down
/// the rewrite path — the only path that can repair it.
///
/// A missing `.allow` file is the one exception, and it is the same one
/// `loadSource` makes: it reads as an empty allow body, which is what a list
/// with no `@@` line compiles to anyway. Answering false there would rewrite
/// such a list on every refresh for no change in content.
fn diskBodiesMatch(self: *Manager, io: std.Io, dir: std.Io.Dir, id: i64, expected: []const u8) bool {
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
var allow_buf: [name_buf_len]u8 = undefined;
const limit: std.Io.Limit = .limited(max_compiled_bytes);
const list_bytes = dir.readFileAlloc(io, compiledName(&list_buf, id, ".list"), self.gpa, limit) catch
@@ -1092,11 +1029,7 @@ pub const Manager = struct {
return false;
defer self.gpa.free(wild_bytes);
const allow_bytes = dir.readFileAlloc(io, compiledName(&allow_buf, id, ".allow"), self.gpa, limit) catch |err|
if (err == error.FileNotFound) @as([]u8, &.{}) else return false;
defer self.gpa.free(allow_bytes);
return compiledBodiesMatch(list_bytes, wild_bytes, allow_bytes, expected);
return compiledBodiesMatch(list_bytes, wild_bytes, expected);
}
fn reportFetchFailure(
@@ -1298,12 +1231,12 @@ pub const Manager = struct {
/// sweeps to nothing.
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void {
// `refresh_lock` first, and for the reason it exists: the download and
// the compile are the only writers of `.raw.tmp`, `.list.tmp`,
// `.wild.tmp` and `.allow.tmp`, and they hold it for as long as they
// run. Without it here, a source deleted through the API would sweep
// the temporaries of a refresh still writing them — the row is gone, so
// nothing else in this function would spare them — and the pass would
// fail on a raw file that vanished under it.
// the compile are the only writers of `.raw.tmp`, `.list.tmp` and
// `.wild.tmp`, and they hold it for as long as they run. Without it
// here, a source deleted through the API would sweep the temporaries of
// a refresh still writing them — the row is gone, so nothing else in
// this function would spare them — and the pass would fail on a raw
// file that vanished under it.
//
// `writer_lock` second, in the one order this file ever takes them,
// because the rows this reads and the compiled files it deletes are
@@ -1544,7 +1477,7 @@ fn applyLoadOutcomes(
entry.loaded = true;
// Two states survive a successful load. `.ok`, because a
// refresh in this process already filled the counters the
// compile produced and the five database columns are a subset
// compile produced and the three database columns are a subset
// of them. And any refresh failure, because the files that just
// loaded are exactly the ones the failed refresh could not
// replace, so the operator must still see why.
@@ -1552,9 +1485,7 @@ fn applyLoadOutcomes(
entry.succeed(row.last_updated orelse 0, .{
.domains = countOf(row.domain_count),
.wildcards = countOf(row.wildcard_count),
.exceptions = countOf(row.exception_count),
.skipped_regex = countOf(row.skipped_regex_count),
.skipped_unsupported = countOf(row.skipped_unsupported_count),
});
},
.failed => |reason| {
@@ -1624,67 +1555,43 @@ fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteF
}
}
/// Whether three compiled files carry the bodies `expected` was taken over.
fn compiledBodiesMatch(
list_bytes: []const u8,
wild_bytes: []const u8,
allow_bytes: []const u8,
expected: []const u8,
) bool {
return std.mem.eql(u8, expected, &bodyChecksum(
stripHeader(list_bytes),
stripHeader(wild_bytes),
stripHeader(allow_bytes),
));
/// Whether two compiled files carry the bodies `expected` was taken over.
fn compiledBodiesMatch(list_bytes: []const u8, wild_bytes: []const u8, expected: []const u8) bool {
return std.mem.eql(u8, expected, &bodyChecksum(stripHeader(list_bytes), stripHeader(wild_bytes)));
}
/// A compile that produced no entry at all while rejecting lines is an error
/// page, a compressed body or a format the sniff got wrong — not a blocklist.
/// Publishing it would replace a working list with nothing and report `ok`. An
/// input that rejected nothing is an empty list, which is legal.
///
/// A list of nothing but exceptions is loadable: an allow-only list published
/// beside a blocking one is a shape operators use, and it produces entries.
fn rejectedWithoutEntries(counts: compiler.Counts) bool {
if (counts.domains != 0 or counts.wildcards != 0 or counts.exceptions != 0) return false;
if (counts.domains != 0 or counts.wildcards != 0) return false;
return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0;
}
/// The digest the `.list`, `.wild` and `.allow` bodies share, in that order,
/// each followed by `compiler.body_separator`.
///
/// Must stay byte-for-byte what `compiler.compile` produces, separators
/// included: this is the other half of the same digest, and the two are
/// compared against each other on every refresh.
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 {
var hasher = Sha256.init(.{});
hasher.update(list_body);
hasher.update(compiler.body_separator);
hasher.update(wild_body);
hasher.update(compiler.body_separator);
hasher.update(allow_body);
hasher.update(compiler.body_separator);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
return std.fmt.bytesToHex(digest, .lower);
}
fn compiledName(buf: *[name_buf_len]u8, id: i64, suffix: []const u8) []const u8 {
// An `i64` prints in at most 20 characters and the longest suffix is ten,
// An `i64` prints in at most 20 characters and the longest suffix is nine,
// so `name_buf_len` cannot be exceeded.
return std.fmt.bufPrint(buf, "{d}{s}", .{ id, suffix }) catch unreachable;
}
/// Every name `compiledName` can produce, longest suffix first so `.list.tmp`
/// is never read as `.list`.
const source_file_suffixes = [_][]const u8{
".allow.tmp", ".list.tmp", ".wild.tmp", ".raw.tmp", ".allow", ".list", ".wild",
};
const source_file_suffixes = [_][]const u8{ ".list.tmp", ".wild.tmp", ".raw.tmp", ".list", ".wild" };
/// The source id a file under the blocklist directory belongs to, or null when
/// the name is not one of ours.
///
/// The four temporaries count. A refresh that dies between writing one and
/// The three temporaries count. A refresh that dies between writing one and
/// renaming it leaves a file no later refresh reuses and no `defer` reaches, so
/// excluding them from the sweep means nothing ever removes them. Matching them
/// is safe because `pruneOrphans` holds `refresh_lock` for its whole body:
@@ -1923,25 +1830,18 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
const list_body = "aaa.example.com\n";
const wild_body = "";
const allow_body = "";
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io);
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
var allow_buf: [name_buf_len]u8 = undefined;
try dir.writeFile(io, .{ .sub_path = compiledName(&list_buf, id, ".list"), .data = list_body });
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_body });
// Present rather than absent, so the third read is a real one: `loadSource`
// treats a missing `.allow` as an empty body and would never open it.
try dir.writeFile(io, .{ .sub_path = compiledName(&allow_buf, id, ".allow"), .data = allow_body });
try sources_repo.updateSourceStats(&database, id, .{
.last_updated = 1_700_000_000,
.domain_count = 1,
.wildcard_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.exception_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, allow_body),
.checksum = &bodyChecksum(list_body, wild_body),
});
// The baseline every assertion below is against: one clean reload, one
@@ -1953,12 +1853,11 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
try testing.expect(out[0].loaded);
const published = mgr.generation;
// Every catch site, in the order `loadSource` reads the three files. A
// Both catch sites, in the order `loadSource` reads the two files. A
// cancellation is consumed by whoever catches it, so folding it into a load
// failure would spend the shutdown signal and leave a status row reading
// "Canceled" behind. The `.allow` read is the one that can get this wrong
// twice over: it also has to keep `FileNotFound` apart from a cancellation.
for ([_][]const u8{ ".list", ".wild", ".allow" }) |suffix| {
// "Canceled" behind.
for ([_][]const u8{ ".list", ".wild" }) |suffix| {
var vtable: std.Io.VTable = undefined;
const canceling = cancelingIo(io, suffix, &vtable);
try testing.expectError(error.Canceled, mgr.reload(canceling));
@@ -2022,7 +1921,6 @@ test "the header writer produces the documented text" {
.counts = .{
.domains = 12,
.wildcards = 3,
.exceptions = 7,
.skipped_regex = 2,
.skipped_unsupported = 1,
.invalid = 5,
@@ -2040,7 +1938,6 @@ test "the header writer produces the documented text" {
\\# fetched_at 1700000000
\\# domains 12
\\# wildcards 3
\\# exceptions 7
\\# skipped_regex 2
\\# skipped_unsupported 1
\\# invalid 5
@@ -2063,7 +1960,6 @@ test "the log label names a source without printing what its url carries" {
.domain_count = 0,
.wildcard_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = null,
};
const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{
@@ -2146,51 +2042,32 @@ test "a success clears the recorded error" {
try testing.expectEqualStrings("", status.errorText());
}
comptime {
// The two tests below spell every suffix out instead of looping over
// `source_file_suffixes`: a test that reads the table moves with it, so a
// name dropped from the table would take the assertion that covers it along.
// An eighth suffix breaks the build here until both are extended.
std.debug.assert(source_file_suffixes.len == 7);
}
test "compiledName spells every file name of a source" {
test "compiledName spells the four file names of a source" {
var buf: [name_buf_len]u8 = undefined;
try testing.expectEqualStrings("42.list", compiledName(&buf, 42, ".list"));
try testing.expectEqualStrings("42.wild", compiledName(&buf, 42, ".wild"));
try testing.expectEqualStrings("42.allow", compiledName(&buf, 42, ".allow"));
try testing.expectEqualStrings("42.raw.tmp", compiledName(&buf, 42, ".raw.tmp"));
try testing.expectEqualStrings("42.list.tmp", compiledName(&buf, 42, ".list.tmp"));
try testing.expectEqualStrings("42.wild.tmp", compiledName(&buf, 42, ".wild.tmp"));
try testing.expectEqualStrings("42.allow.tmp", compiledName(&buf, 42, ".allow.tmp"));
}
test "sourceFileId matches every name a refresh writes, including the temporaries" {
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow"));
// A temporary left by a refresh that died belongs to its source id, so the
// sweep can tell whether that source still has a row.
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.raw.tmp"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list.tmp"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild.tmp"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.allow.tmp"));
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.list"));
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow"));
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.raw.tmp"));
try testing.expectEqual(@as(?i64, null), sourceFileId("notes.allow.tmp"));
try testing.expectEqual(@as(?i64, null), sourceFileId("7.tmp"));
try testing.expectEqual(@as(?i64, null), sourceFileId("7.raw"));
try testing.expectEqual(@as(?i64, null), sourceFileId("7.allowed"));
try testing.expectEqual(@as(?i64, null), sourceFileId("README"));
}
test "every name compiledName writes is a name the sweep can attribute" {
// A round-trip over the table, not a coverage check: this loop reads the
// same array the code reads, so it cannot notice a missing entry. The two
// tests above are what pins the set.
var buf: [name_buf_len]u8 = undefined;
for (source_file_suffixes) |suffix| {
try testing.expectEqual(@as(?i64, 42), sourceFileId(compiledName(&buf, 42, suffix)));
@@ -2236,9 +2113,7 @@ fn testRow(id: i64, enabled: bool) sources_repo.SourceRow {
.last_updated = 1_700_000_000,
.domain_count = 9,
.wildcard_count = 4,
.exception_count = 2,
.skipped_regex_count = 1,
.skipped_unsupported_count = 5,
.checksum = "0" ** 64,
};
}
@@ -2349,9 +2224,6 @@ test "a load of a source this process never refreshed takes the row counters" {
try testing.expectEqual(@as(u32, 9), statuses[0].counts.domains);
try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards);
try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex);
// Rehydration: a restart reads this from the row and nowhere else, because
// no path reparses a compiled file's header.
try testing.expectEqual(@as(u32, 5), statuses[0].counts.skipped_unsupported);
}
test "a status borrows nothing, so a copy outlives the table it came from" {
@@ -2388,59 +2260,17 @@ test "SourceStatus truncates a long url at max_url_len" {
test "compiledBodiesMatch verifies the bodies, not the presence of the files" {
const list_body = "a.example.com\nb.example.com\n";
const wild_body = "c.example.com\n";
const allow_body = "d.example.com\n";
const expected = bodyChecksum(list_body, wild_body, allow_body);
const expected = bodyChecksum(list_body, wild_body);
const header =
"# nxdns blocklist\n" ++
"# url https://lists.example/hosts.txt\n";
try testing.expect(compiledBodiesMatch(
header ++ list_body,
header ++ wild_body,
header ++ allow_body,
&expected,
));
try testing.expect(compiledBodiesMatch(header ++ list_body, header ++ wild_body, &expected));
// The corruption a reload reports as `ChecksumMismatch`: the file is there,
// its body is not what the checksum was taken over. An allow body that lost
// its entry counts, because a dropped exception silently restores a block.
try testing.expect(!compiledBodiesMatch(
header ++ "a.example.com\nb.exa",
header ++ wild_body,
header ++ allow_body,
&expected,
));
try testing.expect(!compiledBodiesMatch(header ++ list_body, header ++ wild_body, "", &expected));
try testing.expect(!compiledBodiesMatch("", "", "", &expected));
}
test "bodyChecksum separates the three bodies" {
const list_body = "a.example.com\nb.example.com\n";
const wild_body = "c.example.com\n";
var hasher = Sha256.init(.{});
hasher.update(list_body);
hasher.update(compiler.body_separator);
hasher.update(wild_body);
hasher.update(compiler.body_separator);
hasher.update(compiler.body_separator);
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
const expected = std.fmt.bytesToHex(digest, .lower);
try testing.expectEqualStrings(&expected, &bodyChecksum(list_body, wild_body, ""));
// No `.allow` file: what `loadSource` and `diskBodiesMatch` pass for one.
// It is an empty body, and an empty body still gets its separator.
try testing.expect(compiledBodiesMatch(list_body, wild_body, "", &expected));
// The framing itself: the same bytes in a different body is a different
// digest. Unframed these two are equal, and a stale `.list` survives an
// upstream that switched the name to a wildcard.
try testing.expect(!std.mem.eql(
u8,
&bodyChecksum("a.example\n", "", ""),
&bodyChecksum("", "a.example\n", ""),
));
// its body is not what the checksum was taken over.
try testing.expect(!compiledBodiesMatch(header ++ "a.example.com\nb.exa", header ++ wild_body, &expected));
try testing.expect(!compiledBodiesMatch("", "", &expected));
}
test "rejectedWithoutEntries fails a compile that produced nothing usable" {
@@ -2561,28 +2391,14 @@ test "collectSample steps over a line that does not fit the reader buffer" {
try testing.expectEqualStrings("ads.example.com\n", w.buffered());
}
test "bodyChecksum covers the list body, then the wild body, then the allow body" {
const all = bodyChecksum("a.example.com\n", "b.example.com\n", "c.example.com\n");
test "bodyChecksum covers the list body followed by the wild body" {
const both = bodyChecksum("a.example.com\n", "b.example.com\n");
var hasher = Sha256.init(.{});
hasher.update("a.example.com\n");
hasher.update(compiler.body_separator);
hasher.update("b.example.com\n");
hasher.update(compiler.body_separator);
hasher.update("c.example.com\n");
hasher.update(compiler.body_separator);
hasher.update("a.example.com\nb.example.com\n");
var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest);
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &all);
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &both);
// Order matters: the three parts are not interchangeable.
try testing.expect(!std.mem.eql(
u8,
&all,
&bodyChecksum("b.example.com\n", "a.example.com\n", "c.example.com\n"),
));
try testing.expect(!std.mem.eql(
u8,
&all,
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
));
// Order matters: the two halves are not interchangeable.
try testing.expect(!std.mem.eql(u8, &both, &bodyChecksum("b.example.com\n", "a.example.com\n")));
}
+11 -348
View File
@@ -27,11 +27,6 @@ pub const Reason = enum {
rule_block_exact,
rule_allow_wildcard,
rule_block_wildcard,
rule_allow_regex,
rule_block_regex,
/// An `@@` exception from a downloaded list. It cancels what another list
/// blocks and never what a rule decides — see `evaluate`.
blocklist_exception,
blocklist_domain,
blocklist_wildcard,
};
@@ -39,14 +34,12 @@ pub const Reason = enum {
pub const Decision = struct {
blocked: bool,
reason: Reason,
/// The candidate (for the exact, exception and blocklist levels) or the
/// pattern (for the wildcard and regex levels) that decided it. Borrowed
/// from the caller's normalized buffer or from the snapshot. "" when
/// `reason == .none`.
/// The candidate (for the exact and blocklist levels) or the pattern (for
/// the wildcard levels) that decided it. Borrowed from the caller's
/// normalized buffer or from the snapshot. "" when `reason == .none`.
matched: []const u8,
/// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and
/// the UI can name the list that blocked the query — or, for
/// `.blocklist_exception`, the list that lifted it.
/// the UI can name the list that blocked the query.
source: ?u32 = null,
};
@@ -102,10 +95,6 @@ pub const SourceSets = struct {
name: []const u8,
domains: domain_set.DomainSet,
wildcards: domain_set.DomainSet,
/// The names this source's `@@` exceptions lift. One entry covers the name
/// and every subdomain of it, because `evaluate` walks the full name and
/// each parent against this set.
exceptions: domain_set.DomainSet,
};
pub const Group = struct {
@@ -133,15 +122,7 @@ pub const Snapshot = struct {
/// can tell which generation answered a query.
generation: u64,
/// The compiled bodies of one source. `allow_body` defaults to empty
/// because a source compiled before exceptions were honoured has no
/// `.allow` file at all. A source refreshed since then always has one,
/// empty when its list carries no `@@` line.
pub const Compiled = struct {
list_body: []const u8,
wild_body: []const u8,
allow_body: []const u8 = "",
};
pub const Compiled = struct { list_body: []const u8, wild_body: []const u8 };
pub const Input = struct {
groups: []const model.Group,
@@ -211,7 +192,6 @@ pub const Snapshot = struct {
.name = try arena.dupe(u8, row.name),
.domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
.wildcards = try domain_set.DomainSet.build(arena, bodies.wild_body, input.seed),
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, input.seed),
};
}
@@ -241,11 +221,7 @@ pub const Snapshot = struct {
.id = id,
.name = try arena.dupe(u8, row.name),
.safe_search = row.safe_search,
// `arena` retains, `gpa` scratches: an arena reclaims only its
// most recent allocation, so a rule build's temporaries taken
// from it would outlive the build and go unreported by
// `memoryBytes`.
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, input.seed),
.rules = try rules.RuleSet.build(arena, group_rules.items, input.seed),
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
};
}
@@ -293,9 +269,7 @@ pub const Snapshot = struct {
/// PLAN §3.10 precedence, allow winning at equal specificity:
/// 1. exact/parent allow rules 2. exact/parent block rules
/// 3. wildcard allow rules 4. wildcard block rules
/// 5. regex allow rules 6. regex block rules
/// 7. blocklist exceptions
/// 8. blocklist domains 9. blocklist wildcards
/// 5. blocklist domains 6. blocklist wildcards
///
/// The order is level-by-level over the whole candidate chain, not
/// candidate-by-candidate over the levels: level 1 is checked against every
@@ -303,24 +277,10 @@ pub const Snapshot = struct {
/// allow rule on the parent beat a block rule on the child, which is the
/// behaviour an allow list is written for.
///
/// Levels 5 and 6 are last among the operator rules because they are the
/// only ones that cost more than a hash lookup or a label walk: a regex is
/// reached only once every set-shaped level has missed. They are matched
/// against the full name alone — a pattern that should cover subdomains
/// says so, which is what an unanchored regex already does.
///
/// Level 7 is where a downloaded list's `@@` exceptions are honoured, and
/// its position is the whole safety argument: every operator rule has
/// already returned by the time it runs, so an exception can cancel a block
/// levels 8 and 9 would have made and nothing else. No downloaded list can
/// open an allow hole the operator did not open. It walks the full name and
/// every parent, because one `@@||x^` entry lifts `x` together with its
/// subdomains.
///
/// Level 8 tests only the full name and level 9 tests only proper parents:
/// Level 5 tests only the full name and level 6 tests only proper parents:
/// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y`
/// means. All three list levels walk the group's sources in ascending index
/// order, so the reported source is stable for a given snapshot.
/// means. Both walk the group's sources in ascending index order, so the
/// reported source is stable for a given snapshot.
///
/// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision {
@@ -347,27 +307,6 @@ pub const Snapshot = struct {
return .{ .blocked = true, .reason = .rule_block_wildcard, .matched = pattern };
}
if (rules.matchRegex(g.rules.regex_allow, domain)) |pattern| {
return .{ .blocked = false, .reason = .rule_allow_regex, .matched = pattern };
}
if (rules.matchRegex(g.rules.regex_block, domain)) |pattern| {
return .{ .blocked = true, .reason = .rule_block_regex, .matched = pattern };
}
var exceptions: Candidates = .init(domain);
while (exceptions.next()) |candidate| {
for (g.sources) |index| {
if (self.sources[index].exceptions.contains(candidate)) {
return .{
.blocked = false,
.reason = .blocklist_exception,
.matched = candidate,
.source = index,
};
}
}
}
for (g.sources) |index| {
if (self.sources[index].domains.contains(domain)) {
return .{
@@ -434,8 +373,7 @@ pub const Snapshot = struct {
var total: usize = 0;
for (self.sources) |*source| {
total += @sizeOf(SourceSets) + source.name.len +
source.domains.memoryBytes() + source.wildcards.memoryBytes() +
source.exceptions.memoryBytes();
source.domains.memoryBytes() + source.wildcards.memoryBytes();
}
for (self.groups) |*group| {
total += @sizeOf(Group) + group.name.len +
@@ -648,71 +586,6 @@ test "precedence: a block wildcard with no allow blocks" {
try testing.expectEqualStrings("*.example.com", decision.matched);
}
test "precedence: a block regex with no allow blocks, and reports its pattern" {
const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ad42-tracker.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_regex, decision.reason);
// `matched` is the pattern the operator wrote, which is what the query log
// has to name for the block to be explicable.
try testing.expectEqualStrings("^ad[0-9]+-", decision.matched);
// Unanchored at the tail, anchored at the head: the digits must lead.
try testing.expect(!snapshot.evaluate(0, "x.ad42-tracker.example.com").blocked);
try testing.expect(!snapshot.evaluate(0, "ads.example.com").blocked);
}
test "precedence: an allow regex beats a block regex that matches the same name" {
const rows = [_]model.Rule{
rule("tracker", .regex, .block),
rule("^good\\.", .regex, .allow),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "good.tracker.example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
try testing.expectEqualStrings("^good\\.", decision.matched);
try testing.expect(snapshot.evaluate(0, "bad.tracker.example.com").blocked);
}
test "precedence: both wildcard levels beat an allow regex that matches" {
// The adjacent pair either side of the wildcard/regex boundary. A regex is
// the most expensive level and therefore the last operator level, so a
// wildcard decides first whichever way it decides.
for ([_]model.Rule{
rule("*.example.com", .wildcard, .allow),
rule("*.example.com", .wildcard, .block),
}) |wild| {
const rows = [_]model.Rule{ wild, rule("example", .regex, .allow) };
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "a.example.com");
try testing.expectEqual(wild.action == .block, decision.blocked);
try testing.expect(decision.reason == .rule_allow_wildcard or
decision.reason == .rule_block_wildcard);
}
}
test "precedence: an exact allow rule beats a block regex" {
const rows = [_]model.Rule{
rule("tracker", .regex, .block),
rule("good.tracker.example.com", .exact, .allow),
};
var snapshot = try build(testing.allocator, .{ .rules = &rows });
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "good.tracker.example.com");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
}
const one_source = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
const one_source_id = [_]i64{11};
const one_link = [_]model.GroupSource{
@@ -728,14 +601,6 @@ const Lists = struct {
return .{ .compiled = .{.{ .list_body = list_body, .wild_body = wild_body }} };
}
fn initWithExceptions(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) Lists {
return .{ .compiled = .{.{
.list_body = list_body,
.wild_body = wild_body,
.allow_body = allow_body,
}} };
}
fn fixture(self: *const Lists) Fixture {
return .{
.sources = &one_source,
@@ -804,208 +669,6 @@ test "precedence: an allow rule beats a wild entry" {
try testing.expectEqual(Reason.rule_allow_exact, decision.reason);
}
// --- list exceptions (milestone 21 ruling 2) --------------------------------
/// The fixture ruling 2 is written against: one list that blocks `ads.example`
/// and its subdomains, and lifts `good.ads.example` back out.
const exception_lists: Lists = .initWithExceptions(
"ads.example\n",
"ads.example\n",
"good.ads.example\n",
);
test "precedence: a list exception beats a list domain entry" {
var snapshot = try build(testing.allocator, exception_lists.fixture());
defer snapshot.deinit();
// The apex is blocked by the `.list` entry; the excepted name is not, even
// though the same source blocks it through `.wild`.
try testing.expect(snapshot.evaluate(0, "ads.example").blocked);
const decision = snapshot.evaluate(0, "good.ads.example");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
try testing.expectEqualStrings("good.ads.example", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: a list exception beats a list domain entry on the same name" {
// The boundary the fixture above cannot pin: `good.ads.example` is not in
// its `.list` body, so that test compares the exception against the
// wildcard level. Here one name is carried by both `.allow` and `.list`,
// which is the only way level 7 and level 8 are reached by one query.
const lists: Lists = .initWithExceptions(
"good.ads.example\n",
"",
"good.ads.example\n",
);
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "good.ads.example");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
try testing.expectEqualStrings("good.ads.example", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: a list domain entry beats a list wildcard entry" {
// Level 8 over level 9: the name is its own `.list` entry and a subdomain
// of a `.wild` entry, so both would block and the reported reason is what
// separates them.
const lists: Lists = .init("x.ads.example\n", "ads.example\n");
var snapshot = try build(testing.allocator, lists.fixture());
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "x.ads.example");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.blocklist_domain, decision.reason);
try testing.expectEqualStrings("x.ads.example", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: a list exception beats a list wildcard entry" {
var snapshot = try build(testing.allocator, exception_lists.fixture());
defer snapshot.deinit();
try testing.expect(snapshot.evaluate(0, "x.ads.example").blocked);
// The parent walk: one `@@||good.ads.example^` entry covers the subdomains
// of the excepted name as well as the name itself.
const decision = snapshot.evaluate(0, "y.good.ads.example");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
try testing.expectEqualStrings("good.ads.example", decision.matched);
try testing.expectEqual(@as(?u32, 0), decision.source);
}
test "precedence: an operator block rule beats a list exception" {
// The property the exception level's position exists for: a downloaded list
// may cancel what another list blocks and may never cancel what the
// operator decided. All three operator block levels are checked, because
// all three sit above the exception level.
for ([_]model.Rule{
rule("good.ads.example", .exact, .block),
rule("*.ads.example", .wildcard, .block),
rule("^good\\.ads\\.example$", .regex, .block),
}) |blocking| {
var fixture = exception_lists.fixture();
const rows = [_]model.Rule{blocking};
fixture.rules = &rows;
var snapshot = try build(testing.allocator, fixture);
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "good.ads.example");
try testing.expect(decision.blocked);
try testing.expect(decision.reason == .rule_block_exact or
decision.reason == .rule_block_wildcard or
decision.reason == .rule_block_regex);
}
}
test "the regex reasons render as the wire strings the API and the query log carry" {
// `web/handlers/lookup.zig` renders a reason as `@tagName`, and
// `storage/logger.zig` stores one in a 32-byte `max_reason_len` buffer.
// Neither can be reached from this file — pure core imports no web and no
// storage — so the tag names and their length are pinned here.
try testing.expectEqualStrings("rule_allow_regex", @tagName(Reason.rule_allow_regex));
try testing.expectEqualStrings("rule_block_regex", @tagName(Reason.rule_block_regex));
inline for (@typeInfo(Reason).@"enum".fields) |field| {
try testing.expect(field.name.len <= 32);
}
}
test "precedence: an allow regex beats every list level" {
// The other side of the same boundary: an operator allow rule lifts a list
// block, whichever of the three list levels made it.
const rows = [_]model.Rule{rule("ads\\.example$", .regex, .allow)};
var fixture = exception_lists.fixture();
fixture.rules = &rows;
var snapshot = try build(testing.allocator, fixture);
defer snapshot.deinit();
// `.list` blocks the apex and `.wild` blocks the subdomains; the regex
// covers both, and answers before either is consulted.
for ([_][]const u8{ "ads.example", "x.ads.example" }) |domain| {
const decision = snapshot.evaluate(0, domain);
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.rule_allow_regex, decision.reason);
try testing.expectEqual(@as(?u32, null), decision.source);
}
}
test "precedence: a block regex blocks a name no list carries" {
const rows = [_]model.Rule{rule("^ad[0-9]+-", .regex, .block)};
var fixture = exception_lists.fixture();
fixture.rules = &rows;
var snapshot = try build(testing.allocator, fixture);
defer snapshot.deinit();
const decision = snapshot.evaluate(0, "ad7-cdn.other.example");
try testing.expect(decision.blocked);
try testing.expectEqual(Reason.rule_block_regex, decision.reason);
try testing.expectEqual(@as(?u32, null), decision.source);
}
test "precedence: a list exception is scoped to the groups the source is in" {
const groups = [_]model.Group{ .{ .name = "default" }, .{ .name = "kids" } };
const ids = [_]i64{ 1, 2 };
var snapshot = try build(testing.allocator, .{
.groups = &groups,
.group_ids = &ids,
.sources = &one_source,
.source_ids = &one_source_id,
.group_sources = &one_link,
.compiled = &exception_lists.compiled,
});
defer snapshot.deinit();
const kids = snapshot.groupIndexByName("kids").?;
try testing.expectEqual(
Reason.blocklist_exception,
snapshot.evaluate(snapshot.default_group, "good.ads.example").reason,
);
// `kids` is linked to no source, so neither the block nor the exception
// reaches it.
try testing.expectEqual(Reason.none, snapshot.evaluate(kids, "good.ads.example").reason);
}
test "precedence: an exception in one list lifts the block another list made" {
const sources = [_]model.BlocklistSource{
.{ .url = "https://lists.test/a", .name = "list a" },
.{ .url = "https://lists.test/b", .name = "list b" },
};
const source_ids = [_]i64{ 11, 12 };
const links = [_]model.GroupSource{
.{ .group = "default", .source_url = "https://lists.test/a" },
.{ .group = "default", .source_url = "https://lists.test/b" },
};
const compiled = [_]?Snapshot.Compiled{
.{ .list_body = "ads.example\n", .wild_body = "ads.example\n" },
.{ .list_body = "", .wild_body = "", .allow_body = "good.ads.example\n" },
};
var snapshot = try build(testing.allocator, .{
.sources = &sources,
.source_ids = &source_ids,
.group_sources = &links,
.compiled = &compiled,
});
defer snapshot.deinit();
try testing.expect(snapshot.evaluate(0, "ads.example").blocked);
const decision = snapshot.evaluate(0, "good.ads.example");
try testing.expect(!decision.blocked);
try testing.expectEqual(Reason.blocklist_exception, decision.reason);
// The source reported is the one that lifted the block, not the one that
// made it.
try testing.expectEqual(@as(?u32, 1), decision.source);
}
test "precedence: nothing configured allows with reason none" {
var snapshot = try build(testing.allocator, .{});
defer snapshot.deinit();
+8 -132
View File
@@ -1,17 +1,9 @@
//! The Adblock Plus filter syntax, restricted to what a DNS sinkhole can
//! honour: domain anchors and bare names. Pure, `std` only.
//!
//! An exception rule is `.exception` in exactly two spellings, `@@||name^` and
//! `@@||name`, each of which may carry the literal `$important` behind it. Every
//! other `@@` form stays `.unsupported`: a bare `@@name`, a path, a scheme, any
//! other modifier.
//!
//! What makes honouring them safe is where they land, not what they say. A list
//! exception is evaluated below every operator rule (PLAN §3.10), so it can
//! cancel a block another list made and nothing else. No downloaded list can
//! open an allow hole the operator did not open, which is why the allow surface
//! stays the `rules` table — including its `.regex` kind, which is the one
//! regex dialect nxdns evaluates and which no downloaded list can reach.
//! Exception rules (`@@`) are `.unsupported` rather than an allow entry. The
//! allow surface is the `rules` table, and a downloaded list that could quietly
//! allow a domain across every group is a policy hole the operator did not open.
const std = @import("std");
const parsers = @import("parsers.zig");
@@ -20,31 +12,6 @@ const parsers = @import("parsers.zig");
/// this syntax and only a trailing one is meaningful for a domain rule.
const rule_tokens = "*^|/$";
/// The one modifier an exception line may carry. AdGuard-authored lists write it
/// on most of their `@@` rules and it changes nothing here: these exceptions
/// already sit below every operator rule, so "important" cannot raise one above
/// the decisions it is not allowed to reach.
const important_modifier = "$important";
/// A candidate the compiler could turn into a name, for the two anchored forms
/// only. `rule_tokens` covers the syntax characters; this also refuses
/// whitespace, which is neither a rule token nor a control byte and so used to
/// survive into a compiled body as an entry only a query carrying the same
/// space could match. That is true of `||name` and `@@||name` because
/// `compiler.zig` hands a `.wildcard` or `.exception` text to `addCandidate`
/// whole. A `.domain` text is tokenized on whitespace first and each field
/// filed separately, so the bare form does not come through here: refusing a
/// space there would drop the hosts-style lines that a mixed list classified
/// `abp` by `detectFormat` still contributes.
fn isNameCandidate(candidate: []const u8) bool {
if (candidate.len == 0) return false;
if (std.mem.findAny(u8, candidate, rule_tokens) != null) return false;
for (candidate) |c| {
if (std.ascii.isWhitespace(c) or std.ascii.isControl(c)) return false;
}
return true;
}
pub fn parseLine(line: []const u8) parsers.Line {
const text = std.mem.trim(u8, line, &std.ascii.whitespace);
if (text.len == 0) return .{ .kind = .ignore };
@@ -52,14 +19,15 @@ pub fn parseLine(line: []const u8) parsers.Line {
if (text[0] == '[') return .{ .kind = .ignore };
if (parsers.isElementHiding(text)) return .{ .kind = .unsupported };
if (text[0] == '#') return .{ .kind = .ignore };
if (std.mem.startsWith(u8, text, "@@")) return parseException(text[2..]);
if (std.mem.startsWith(u8, text, "@@")) return .{ .kind = .unsupported };
if (text[0] == '/') return .{ .kind = .regex };
if (std.mem.findScalar(u8, text, '$') != null) return .{ .kind = .unsupported };
if (std.mem.startsWith(u8, text, "||")) {
var candidate = text[2..];
if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
if (candidate.len == 0) return .{ .kind = .unsupported };
if (std.mem.findAny(u8, candidate, rule_tokens) != null) return .{ .kind = .unsupported };
// A domain anchor covers the domain itself as well as its subdomains,
// so the compiler emits an apex entry beside the wildcard one.
return .{ .kind = .wildcard, .text = candidate, .covers_apex = true };
@@ -69,24 +37,6 @@ pub fn parseLine(line: []const u8) parsers.Line {
return .{ .kind = .domain, .text = text };
}
/// One exception line, past its `@@`. The domain anchor and the trailing `^` get
/// the same treatment they get on a block rule, so `@@||x^` and `||x^` accept
/// and reject the same names.
///
/// `$important` is stripped before the anchor is read, because the `$` would
/// otherwise be a rule token and refuse the whole line.
fn parseException(rest: []const u8) parsers.Line {
if (!std.mem.startsWith(u8, rest, "||")) return .{ .kind = .unsupported };
var candidate = rest[2..];
if (std.mem.endsWith(u8, candidate, important_modifier)) {
candidate = candidate[0 .. candidate.len - important_modifier.len];
}
if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
if (!isNameCandidate(candidate)) return .{ .kind = .unsupported };
return .{ .kind = .exception, .text = candidate, .covers_apex = true };
}
const testing = std.testing;
test "a bang comment is ignored" {
@@ -119,46 +69,8 @@ test "a modifier list is unsupported" {
try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com^$third-party").kind);
}
test "an exception rule is an exception that covers its apex" {
const line = parseLine("@@||example.com^");
try testing.expectEqual(parsers.Kind.exception, line.kind);
try testing.expectEqualStrings("example.com", line.text);
try testing.expect(line.covers_apex);
}
test "an exception rule without a separator is still an exception" {
const line = parseLine("@@||example.com");
try testing.expectEqual(parsers.Kind.exception, line.kind);
try testing.expectEqualStrings("example.com", line.text);
try testing.expect(line.covers_apex);
}
test "an exception rule tolerates the important modifier" {
for ([_][]const u8{ "@@||example.com^$important", "@@||example.com$important" }) |text| {
const line = parseLine(text);
try testing.expectEqual(parsers.Kind.exception, line.kind);
try testing.expectEqualStrings("example.com", line.text);
try testing.expect(line.covers_apex);
}
}
test "every exception form outside the two anchored ones is unsupported" {
for ([_][]const u8{
// No domain anchor: this is a substring rule in browser syntax, and
// reading it as a name would allow far more than it says.
"@@example.com",
"@@|http://example.com",
"@@||example.com/path^",
"@@||example.com^$third-party",
"@@||example.com^$important$third-party",
"@@||example.com^$dnstype=A",
"@@||^",
"@@||$important",
"@@",
"@@||ads*.example.com^",
}) |text| {
try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
}
test "an exception rule is unsupported" {
try testing.expectEqual(parsers.Kind.unsupported, parseLine("@@||example.com^").kind);
}
test "element hiding is unsupported" {
@@ -182,42 +94,6 @@ test "a bare name is a domain" {
try testing.expectEqualStrings("example.com", line.text);
}
test "a candidate carrying whitespace is unsupported in the anchored forms" {
// A space is not a rule token and it is not a control byte, so it used to
// reach the compiler, which lowercases and length-checks but does not
// reject it. An anchored form's text is filed whole, so the entry it wrote
// could only ever match a query name carrying the same space.
for ([_][]const u8{
"||good.example bad.example^",
"@@||good.example bad.example^",
"||good.example\tbad.example",
"@@||good.example\tbad.example",
}) |text| {
try testing.expectEqual(parsers.Kind.unsupported, parseLine(text).kind);
}
}
test "a bare candidate carrying whitespace stays a domain" {
// Not the same case: `compiler.zig` tokenizes a `.domain` text on
// whitespace and files each field. Refusing it here would drop the
// hosts-style lines of a mixed list, which `detectFormat` classifies `abp`
// as a whole and which only reach a compiled body through that split.
//
// What this test pins is the parser half — the kind and the untouched text.
// The split itself belongs to the compiler and is asserted there, by
// "an abp list's hosts-style lines reach the domain body through the split":
// a tokenizer removed from `compile` would leave every assertion below true.
for ([_][]const u8{
"good.example bad.example",
"0.0.0.0 ads.example",
"good.example\tbad.example",
}) |text| {
const line = parseLine(text);
try testing.expectEqual(parsers.Kind.domain, line.kind);
try testing.expectEqualStrings(text, line.text);
}
}
test "a rule token outside the supported forms is unsupported" {
try testing.expectEqual(parsers.Kind.unsupported, parseLine("ads*.example.com").kind);
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com^").kind);
+4 -12
View File
@@ -22,15 +22,10 @@ pub const Kind = enum {
domain,
/// `text` holds one candidate suffix; every proper subdomain of it matches.
wildcard,
/// A regex line in a downloaded list. Counted, skipped, never compiled: the
/// engine exists for rules the operator wrote, not for lists (PLAN §2.2).
/// A regex rule. Counted, skipped, never compiled (PLAN §2.2).
regex,
/// `text` holds one candidate name an ABP exception rule (`@@||x^`) lifts:
/// the name itself and every subdomain of it. Only the ABP parser emits it.
exception,
/// Syntactically a rule of this format, but one nxdns cannot honour:
/// an ABP modifier list, an exception form outside `@@||x^`, element
/// hiding, a scheme anchor.
/// an ABP modifier list, an exception rule, element hiding, a scheme anchor.
unsupported,
};
@@ -38,11 +33,8 @@ pub const Line = struct {
kind: Kind,
/// Borrowed from the caller's line. Not lowercased, not validated.
text: []const u8 = "",
/// `.wildcard` and `.exception` only: the rule covers the anchored name
/// itself as well as its subdomains, which is what ABP `||x^` and `@@||x^`
/// mean. The compiler acts on it for a `.wildcard` line, by emitting an
/// additional `.list` entry; an `.exception` line needs no second entry,
/// because the allow walk tests the full name as well as its parents.
/// `.wildcard` only. ABP `||x^` covers `x` itself as well as its subdomains,
/// so the compiler emits an additional `.list` entry when this is set.
covers_apex: bool = false,
};
-1101
View File
File diff suppressed because it is too large Load Diff
+62 -339
View File
@@ -1,4 +1,4 @@
//! One group's explicit rules (PLAN §3.10 levels 16), compiled once into an
//! One group's explicit rules (PLAN §3.10 levels 14), compiled once into an
//! immutable form the query path can read without allocating.
//!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
@@ -7,13 +7,7 @@
//! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap.
//!
//! Regex patterns are compiled here, once per snapshot, into the linear-time
//! programs of `regex.zig` and scanned the same way. `max_regex_per_group` caps
//! them far lower, at 256: a regex costs a whole VM run where a wildcard costs a
//! label comparison, and the matcher reaches them only after every hash and
//! wildcard level has missed.
//!
//! Pure: allocators and plain values, no `std.Io`, no clock, no entropy
//! Pure: an allocator and plain values, no `std.Io`, no clock, no entropy
//! source. The hash seed arrives as a parameter.
const std = @import("std");
@@ -23,40 +17,14 @@ const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const regex = @import("regex.zig");
const wildcard = @import("wildcard.zig");
pub const Error = error{
OutOfMemory,
BadPattern,
TooManyWildcards,
TooManyRegexRules,
} || domain_set.DomainSet.Error;
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error;
/// Both wildcard lists of one group together. The cap exists so a rules table
/// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096;
/// Both regex lists of one group together, capped well below the wildcards: a
/// miss at this level runs every program to its end.
pub const max_regex_per_group: usize = 256;
/// A compiled operator regex beside the text it was written as. The text is what
/// `Decision.matched` reports, so the query log names the rule the operator
/// wrote rather than an instruction count.
pub const RegexRule = struct {
pattern: []const u8,
program: regex.Program,
/// Frees through a copy of the program: the slices holding these rules are
/// `const`, and `Program.deinit` wants a mutable pointer only to blank the
/// struct it is finished with.
fn free(self: RegexRule, gpa: Allocator) void {
var program = self.program;
program.deinit(gpa);
}
};
pub const RuleSet = struct {
exact_allow: domain_set.DomainSet = .empty,
exact_block: domain_set.DomainSet = .empty,
@@ -64,115 +32,75 @@ pub const RuleSet = struct {
/// order and the same first match.
wildcard_allow: []const []const u8 = &.{},
wildcard_block: []const []const u8 = &.{},
/// Sorted and deduplicated like the wildcards, so the first regex to match a
/// name is the same one on every rebuild of the same rows.
regex_allow: []const RegexRule = &.{},
regex_block: []const RegexRule = &.{},
/// One block holding the pattern bytes of all four lists; freed as a unit.
pattern_bytes: []const u8 = &.{},
/// One block holding the bytes of both wildcard lists; freed as a unit.
wildcard_bytes: []const u8 = &.{},
pub const empty: RuleSet = .{};
/// `rows` are one group's rules only; splitting `listRules` output by group
/// belongs to the caller, which is the only holder of the group table.
///
/// Name patterns are normalized (lowercase over ASCII, one trailing dot
/// Patterns are normalized (lowercase over ASCII, one trailing dot
/// stripped) and validated: `.exact` through `dns.name.fromText`,
/// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An
/// invalid pattern is `error.BadPattern`, not a skipped row — every pattern
/// passed `config/validate.zig` on the way in, so an invalid one here means
/// the rows were edited underneath nxdns and a silently dropped allow rule
/// `.wildcard` through `wildcard.validate`. An invalid pattern is
/// `error.BadPattern`, not a skipped row — every pattern passed
/// `config/validate.zig` on the way in, so an invalid one here means the
/// rows were edited underneath nxdns and a silently dropped allow rule
/// would block a domain the operator unblocked.
///
/// Two allocators, because the caller's `perm` is a snapshot arena: an
/// arena reclaims only its most recent allocation, so every temporary taken
/// from it would live as long as the snapshot and go unreported by
/// `memoryBytes`. `perm` owns what the returned set retains and is what
/// `deinit` frees; `scratch` owns the build's working storage, which is
/// released by the time `build` returns. Passing one allocator as both is
/// correct wherever freeing works normally.
pub fn build(
perm: Allocator,
scratch: Allocator,
rows: []const model.Rule,
seed: u64,
) Error!RuleSet {
pub fn build(gpa: Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet {
if (rows.len == 0) return .empty;
var joined: std.ArrayList(u8) = .empty;
defer joined.deinit(scratch);
var spans: [6]std.ArrayList(Span) = @splat(.empty);
defer for (&spans) |*bucket| bucket.deinit(scratch);
var scratch: std.ArrayList(u8) = .empty;
defer scratch.deinit(gpa);
var spans: [4]std.ArrayList(Span) = .{ .empty, .empty, .empty, .empty };
defer for (&spans) |*bucket| bucket.deinit(gpa);
var wildcards: usize = 0;
var regexes: usize = 0;
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const pattern = switch (row.kind) {
.exact => blk: {
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
_ = name.fromText(text) catch return error.BadPattern;
break :blk text;
},
.wildcard => blk: {
const text = normalize(row.pattern, &buf) catch return error.BadPattern;
wildcard.validate(text) catch return error.BadPattern;
const pattern = normalize(row.pattern, &buf) catch return error.BadPattern;
switch (row.kind) {
.exact => _ = name.fromText(pattern) catch return error.BadPattern,
.wildcard => {
wildcard.validate(pattern) catch return error.BadPattern;
wildcards += 1;
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
break :blk text;
},
// A regex is not a name, so `normalize` must not touch it: it
// strips a trailing `.`, which here is the any-byte atom, and it
// lowercases, which turns the rejected `\D` into the accepted
// `\d`. Either would silently change what the rule matches. The
// bytes stay as the operator wrote them — the same bytes
// `config/validate.zig` compiled at the edge. Compiling waits
// until after the sort, so a duplicate is compiled once.
.regex => blk: {
regexes += 1;
if (regexes > max_regex_per_group) return error.TooManyRegexRules;
break :blk row.pattern;
},
};
}
const bucket = &spans[bucketOf(row.kind, row.action)];
try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len });
try joined.appendSlice(scratch, pattern);
try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len });
try scratch.appendSlice(gpa, pattern);
}
// `joined` stops growing here, so spans can become slices of it.
var sorted: [6]std.ArrayList([]const u8) = @splat(.empty);
defer for (&sorted) |*bucket| bucket.deinit(scratch);
// `scratch` stops growing here, so spans can become slices of it.
var sorted: [4]std.ArrayList([]const u8) = .{ .empty, .empty, .empty, .empty };
defer for (&sorted) |*bucket| bucket.deinit(gpa);
for (&spans, &sorted) |*bucket, *out| {
try out.ensureTotalCapacityPrecise(scratch, bucket.items.len);
try out.ensureTotalCapacityPrecise(gpa, bucket.items.len);
for (bucket.items) |span| {
out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]);
out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]);
}
std.mem.sort([]const u8, out.items, {}, lessThanBytes);
dedupSorted(out);
}
var self: RuleSet = .empty;
errdefer self.deinit(perm);
errdefer self.deinit(gpa);
self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed);
self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed);
self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed);
self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed);
const wild_allow = sorted[bucketOf(.wildcard, .allow)].items;
const wild_block = sorted[bucketOf(.wildcard, .block)].items;
const re_allow = sorted[bucketOf(.regex, .allow)].items;
const re_block = sorted[bucketOf(.regex, .block)].items;
const allow = sorted[bucketOf(.wildcard, .allow)].items;
const block = sorted[bucketOf(.wildcard, .block)].items;
var total: usize = 0;
for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| {
for (list) |pattern| total += pattern.len;
}
for (allow) |pattern| total += pattern.len;
for (block) |pattern| total += pattern.len;
const bytes = try perm.alloc(u8, total);
self.pattern_bytes = bytes;
const bytes = try gpa.alloc(u8, total);
self.wildcard_bytes = bytes;
var at: usize = 0;
self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at);
self.wildcard_block = try copyPatterns(perm, wild_block, bytes, &at);
self.regex_allow = try compilePatterns(perm, scratch, re_allow, bytes, &at);
self.regex_block = try compilePatterns(perm, scratch, re_block, bytes, &at);
self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at);
self.wildcard_block = try copyPatterns(gpa, block, bytes, &at);
return self;
}
@@ -182,35 +110,18 @@ pub const RuleSet = struct {
self.exact_block.deinit(gpa);
gpa.free(self.wildcard_allow);
gpa.free(self.wildcard_block);
freeRules(gpa, self.regex_allow);
freeRules(gpa, self.regex_block);
gpa.free(self.pattern_bytes);
gpa.free(self.wildcard_bytes);
self.* = .empty;
}
pub fn memoryBytes(self: *const RuleSet) usize {
var programs: usize = 0;
for (self.regex_allow) |item| programs += item.program.memoryBytes();
for (self.regex_block) |item| programs += item.program.memoryBytes();
return self.exact_allow.memoryBytes() +
self.exact_block.memoryBytes() +
self.pattern_bytes.len +
programs +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) +
(self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule);
self.wildcard_bytes.len +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8);
}
};
/// The first regex of `list` that matches `domain`, or null. `list` is sorted,
/// so "first" is stable across rebuilds of the same rows. The caller checks the
/// allow list before the block list, as it does for wildcards.
pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 {
for (list) |item| {
if (regex.matches(&item.program, domain)) return item.pattern;
}
return null;
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
@@ -220,16 +131,15 @@ pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 {
const Span = struct { offset: usize, len: usize };
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
const kind_base: usize = switch (kind) {
const kind_bit: usize = switch (kind) {
.exact => 0,
.wildcard => 2,
.regex => 4,
};
const action_offset: usize = switch (action) {
const action_bit: usize = switch (action) {
.allow => 0,
.block => 1,
};
return kind_base + action_offset;
return kind_bit + action_bit;
}
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
@@ -249,31 +159,26 @@ fn dedupSorted(list: *std.ArrayList([]const u8)) void {
list.shrinkRetainingCapacity(kept);
}
fn buildSet(
perm: Allocator,
scratch: Allocator,
patterns: []const []const u8,
seed: u64,
) Error!domain_set.DomainSet {
fn buildSet(gpa: Allocator, patterns: []const []const u8, seed: u64) Error!domain_set.DomainSet {
if (patterns.len == 0) return .empty;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(scratch);
defer body.deinit(gpa);
for (patterns) |pattern| {
try body.appendSlice(scratch, pattern);
try body.append(scratch, '\n');
try body.appendSlice(gpa, pattern);
try body.append(gpa, '\n');
}
return domain_set.DomainSet.build(perm, body.items, seed);
return domain_set.DomainSet.build(gpa, body.items, seed);
}
fn copyPatterns(
perm: Allocator,
gpa: Allocator,
patterns: []const []const u8,
bytes: []u8,
at: *usize,
) Error![]const []const u8 {
if (patterns.len == 0) return &.{};
const out = try perm.alloc([]const u8, patterns.len);
const out = try gpa.alloc([]const u8, patterns.len);
for (out, patterns) |*slot, pattern| {
@memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = bytes[at.*..][0..pattern.len];
@@ -282,49 +187,6 @@ fn copyPatterns(
return out;
}
/// Copies the pattern texts into `bytes` like `copyPatterns` and compiles each
/// one. A compile failure is `error.BadPattern` whichever of the engine's three
/// refusals fired: the pattern already passed `config/validate.zig`, which names
/// the limit, so a row that fails here was written around that check.
///
/// Compiling into `scratch` and cloning across is what keeps a parse-time AST
/// out of `perm`: `regex.compile` builds the AST, the child lists and the
/// growing instruction buffer through the allocator it returns the program on.
fn compilePatterns(
perm: Allocator,
scratch: Allocator,
patterns: []const []const u8,
bytes: []u8,
at: *usize,
) Error![]const RegexRule {
if (patterns.len == 0) return &.{};
const out = try perm.alloc(RegexRule, patterns.len);
var built: usize = 0;
errdefer {
for (out[0..built]) |item| item.free(perm);
perm.free(out);
}
for (out, patterns) |*slot, pattern| {
var compiled = regex.compile(scratch, pattern) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.BadPattern,
};
defer compiled.deinit(scratch);
const program = try compiled.clone(perm);
@memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = .{ .pattern = bytes[at.*..][0..pattern.len], .program = program };
at.* += pattern.len;
built += 1;
}
return out;
}
fn freeRules(gpa: Allocator, list: []const RegexRule) void {
for (list) |item| item.free(gpa);
gpa.free(list);
}
const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
@@ -361,7 +223,7 @@ test "exact rules land in the matching set" {
rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
@@ -377,7 +239,7 @@ test "wildcard rules land in the matching list, sorted" {
rule("*.a.example.com", .wildcard, .block),
rule("*.allowed.example.com", .wildcard, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
@@ -392,7 +254,7 @@ test "patterns are normalized to lowercase without a trailing dot" {
rule("ADS.Example.COM.", .exact, .block),
rule("*.Tracker.NET.", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com"));
@@ -406,7 +268,7 @@ test "duplicate rows collapse to one entry" {
rule("*.x.example.com", .wildcard, .block),
rule("*.x.example.com", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 1), set.exact_block.count);
@@ -416,98 +278,14 @@ test "duplicate rows collapse to one entry" {
test "an invalid exact pattern is an error" {
for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .exact, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
}
}
test "regex rules land in their own buckets, compiled and sorted" {
const rows = [_]model.Rule{
rule("^zz", .regex, .block),
rule("^aa", .regex, .block),
rule("ok$", .regex, .allow),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.regex_block.len);
try testing.expectEqualStrings("^aa", set.regex_block[0].pattern);
try testing.expectEqualStrings("^zz", set.regex_block[1].pattern);
try testing.expectEqual(@as(usize, 1), set.regex_allow.len);
try testing.expectEqualStrings("ok$", set.regex_allow[0].pattern);
try testing.expectEqualStrings("^aa", matchRegex(set.regex_block, "aabb.example").?);
try testing.expect(matchRegex(set.regex_block, "bbaa.example") == null);
try testing.expectEqualStrings("ok$", matchRegex(set.regex_allow, "example.ok").?);
}
test "a regex pattern keeps the bytes the operator wrote" {
// `normalize` would strip the trailing dot and lowercase the escape, and
// either edit would change what the pattern matches. The exact and wildcard
// kinds still normalize; only this one is exempt.
const rows = [_]model.Rule{
rule("ADS\\.Example\\.", .regex, .block),
rule("ADS.Example.", .exact, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqualStrings("ADS\\.Example\\.", set.regex_block[0].pattern);
try testing.expect(set.exact_block.contains("ads.example"));
}
test "duplicate regex rows collapse to one compiled program" {
const rows = [_]model.Rule{
rule("^ad[0-9]+-", .regex, .block),
rule("^ad[0-9]+-", .regex, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), set.regex_block.len);
}
test "a regex pattern the engine refuses is an error, not a skipped row" {
for ([_][]const u8{ "(", "", "a+?", "[z-a]", "\\s" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
}
// The size limits arrive as `BadPattern` too: which one fired is
// `config/validate.zig`'s to report, and by here the row is simply wrong.
const long = [_]model.Rule{rule("a" ** 300, .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &long, 0));
const complex = [_]model.Rule{rule("(abcdefghij){200}", .regex, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &complex, 0));
}
test "too many regex rules is an error" {
const gpa = testing.allocator;
const rows = try gpa.alloc(model.Rule, max_regex_per_group + 1);
defer gpa.free(rows);
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
for (rows, 0..) |*row, i| {
const pattern = try std.fmt.allocPrint(gpa, "^n{d}-", .{i});
try patterns.append(gpa, pattern);
row.* = rule(pattern, .regex, .block);
}
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
// The cap counts both actions together, like the wildcard one.
rows[0].action = .allow;
try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
try testing.expectEqual(@as(usize, 256), max_regex_per_group);
}
test "an invalid wildcard pattern is an error" {
for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0));
}
}
@@ -527,11 +305,11 @@ test "too many wildcards is an error" {
row.* = rule(pattern, .wildcard, .block);
}
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0));
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0));
}
test "an empty rule list builds the empty set" {
var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0);
var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0);
defer set.deinit(testing.allocator);
try testing.expect(!set.exact_block.contains("ads.example.com"));
@@ -550,63 +328,11 @@ test "memoryBytes counts every part" {
rule("ads.example.com", .exact, .block),
rule("*.tracker.net", .wildcard, .block),
};
var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
var set = try RuleSet.build(testing.allocator, &rows, 0);
defer set.deinit(testing.allocator);
try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
try testing.expect(set.memoryBytes() >= "*.tracker.net".len);
// A compiled program is the largest thing a rule set holds, so leaving it
// out would make the snapshot's memory report a fiction.
const with_regex = [_]model.Rule{ rows[0], rows[1], rule("^ad[0-9]+-", .regex, .block) };
var wider = try RuleSet.build(testing.allocator, testing.allocator, &with_regex, 0);
defer wider.deinit(testing.allocator);
try testing.expect(wider.memoryBytes() > set.memoryBytes() + "^ad[0-9]+-".len);
try testing.expect(wider.memoryBytes() >= wider.regex_block[0].program.memoryBytes());
}
test "the build's temporaries stay out of the permanent allocator" {
// The property the two-allocator split exists for. An arena reclaims only
// its most recent allocation, so a temporary taken from `perm` would live
// as long as the arena and be invisible to `memoryBytes`. Two checks, one
// per direction: `testing.allocator` fails the test if anything the set
// retains was taken from `scratch`, and the arena's capacity fails it if
// the build's working storage was taken from `perm`.
const gpa = testing.allocator;
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |p| gpa.free(p);
patterns.deinit(gpa);
}
var rows: std.ArrayList(model.Rule) = .empty;
defer rows.deinit(gpa);
var i: usize = 0;
while (i < 64) : (i += 1) {
const regex_pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.ads\\.invalid$", .{i});
try patterns.append(gpa, regex_pattern);
try rows.append(gpa, rule(regex_pattern, .regex, .block));
const wild = try std.fmt.allocPrint(gpa, "*.w{d:0>5}.example.com", .{i});
try patterns.append(gpa, wild);
try rows.append(gpa, rule(wild, .wildcard, .block));
const exact = try std.fmt.allocPrint(gpa, "e{d:0>5}.example.com", .{i});
try patterns.append(gpa, exact);
try rows.append(gpa, rule(exact, .exact, .block));
}
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
const set = try RuleSet.build(arena.allocator(), gpa, rows.items, 0x5eed);
try testing.expectEqual(@as(usize, 64), set.regex_block.len);
try testing.expect(set.exact_block.contains("e00007.example.com"));
// Whole-arena capacity against what the set says it holds. The slack is the
// allocator's page rounding; the defect this guards against was a factor of
// twelve.
try testing.expect(arena.queryCapacity() < 2 * set.memoryBytes());
}
fn buildUnderFailure(gpa: Allocator) !void {
@@ -615,14 +341,11 @@ fn buildUnderFailure(gpa: Allocator) !void {
rule("good.example.com", .exact, .allow),
rule("*.tracker.net", .wildcard, .block),
rule("*.ok.tracker.net", .wildcard, .allow),
rule("^ad[0-9]+-", .regex, .block),
rule("\\.ok\\.", .regex, .allow),
};
var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed);
var set = try RuleSet.build(gpa, &rows, 0x5eed);
defer set.deinit(gpa);
try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
try testing.expectEqualStrings("^ad[0-9]+-", set.regex_block[0].pattern);
}
test "build leaks nothing under allocation failure" {
+3 -5
View File
@@ -4,8 +4,7 @@
//! A pattern is a domain name in which one or more labels are exactly `*`.
//! Each `*` label matches one or more labels of the queried name. Partial-label
//! globbing (`ad*.example.com`) is deliberately absent: it is regex by another
//! name, and PLAN §2.2 keeps one regex dialect rather than two. An operator who
//! needs one writes a `.regex` rule, which `filter/regex.zig` compiles.
//! name, which PLAN §2.2 rules out.
const std = @import("std");
@@ -19,9 +18,8 @@ pub const PatternError = error{
/// No label is exactly "*".
NoWildcard,
/// A label contains '*' but is not exactly "*". Partial-label globbing
/// (`ad*.example.com`) is out of scope: PLAN §3.9 defines the wildcard as a
/// label pattern, and the `.regex` kind covers what partial globbing was
/// wanted for.
/// (`ad*.example.com`) is out of scope: it is regex by another name, and
/// PLAN §3.9 defines the wildcard as a label pattern.
PartialWildcardLabel,
EmptyLabel,
LabelTooLong,
+20 -106
View File
@@ -47,7 +47,7 @@ const licenses = @import("licenses_files");
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
/// a CommonJS module. Every other devDependency left `web/dist` untouched.
/// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "vite" };
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so
/// the inventory names them in a note rather than carrying their licence. The
@@ -57,23 +57,7 @@ const npm_generators = [_][]const u8{ "rolldown", "vite" };
/// This list is a claim about the build, and the build is what settles it: a
/// name here that turns up in the recorded `web/dist` bundle fails, so a
/// package that starts shipping cannot stay on this list quietly.
const npm_not_shipped = [_][]const u8{
"cookie-es",
"isbot",
"seroval",
"seroval-plugins",
"css-mediaquery",
"invariant",
"js-tokens",
"loose-envify",
"@internationalized/date",
"@internationalized/number",
"@react-types/shared",
"@swc/helpers",
"aria-hidden",
"client-only",
"tslib",
};
const npm_not_shipped = [_][]const u8{ "cookie-es", "isbot", "seroval", "seroval-plugins" };
/// Components the shipped artifacts contain that no dependency file mentions,
/// so no automatic check would ever notice their disappearance from the
@@ -87,6 +71,7 @@ const required_components = [_][]const u8{
"p256-m",
"React",
"TanStack",
"Tailwind",
"Vite",
"Rolldown",
"Mozilla CA certificate bundle",
@@ -118,61 +103,15 @@ const ZigDependencyVersion = struct {
},
};
/// The licence each npm package of the recorded sets carries today. The identity
/// file records a licence token per package; nothing used to read it, so a
/// package that relicensed passed as long as its version had not moved.
///
/// MIT is the default because almost every package here is MIT, so only the
/// exceptions are written down. A package whose recorded token stops matching
/// its expectation fails — including the packages below, which is the point of
/// naming them rather than waving them through: a new licence on any of them is
/// a new decision, not a fact to absorb quietly.
///
/// Adding a name here is a human review, not a paste. A licence this project has
/// not taken before needs its own text under `licenses/`, an inventory entry
/// that says who accepted it and when, and whatever that licence's attribution
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
/// Section 4 asks for.
///
/// Packages that put no byte in `web/dist` are here too. They redistribute
/// nothing today, so their licence carries no obligation today — but "not
/// shipped" is a claim about the build that a future build can falsify, and
/// recording the licence now means the answer is already reviewed when it does.
/// The licence every *shipped* npm package carries today, and the licence of
/// every npm text under `licenses/`. The identity file records a licence token
/// per package; nothing used to read it, so a package that relicensed passed as
/// long as its version had not moved. A token other than this one needs its own
/// licence text and a decision about EUPL-1.2 compatibility, which is a human
/// review, not a paste. The `npm_not_shipped` packages are exempt — they
/// redistribute nothing, and one of them is Unlicense.
const npm_expected_licence = "MIT";
const npm_licence_exceptions = [_]NpmLicence{
// Shipped: React Aria and the one @internationalized package it pulls into
// the bundle.
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
.{ .name = "react-aria", .licence = "Apache-2.0" },
.{ .name = "react-stately", .licence = "Apache-2.0" },
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
// Not shipped: the rest of the React Aria closure.
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
.{ .name = "tslib", .licence = "0BSD" },
// Not shipped: reached only through @tanstack/router-core's server paths
// and @stylexjs/stylex's compiler.
.{ .name = "isbot", .licence = "Unlicense" },
.{ .name = "css-mediaquery", .licence = "BSD" },
};
const NpmLicence = struct {
name: []const u8,
licence: []const u8,
};
/// The reviewed licence for one package: its exception, or MIT.
fn expectedLicence(name: []const u8) []const u8 {
for (npm_licence_exceptions) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
}
return npm_expected_licence;
}
/// The two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
@@ -542,21 +481,22 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
}
}
test "every npm package in the recorded closure carries the licence the inventory expects" {
test "every npm package in the recorded closure ships under the licence the inventory assumes" {
for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
const expected = expectedLicence(pkg.name);
if (!std.mem.eql(u8, pkg.licence, expected)) {
// A package that puts no byte in web/dist redistributes nothing, so
// its licence carries no obligation. isbot is Unlicense and is one
// of these.
if (isNotShipped(pkg.name)) continue;
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
std.debug.print(
"npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" it a text under licenses/ and an inventory entry naming who accepted it if" ++
" this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++
" licenses/ is an {s} text, so this package needs one of its own and a" ++
" decision about EUPL-1.2 compatibility.\n",
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence },
);
return error.NpmLicenceChanged;
}
@@ -564,32 +504,6 @@ test "every npm package in the recorded closure carries the licence the inventor
}
}
test "every npm licence exception names a package the identity file records" {
const closure = try recordedSection(npm_section);
const generators = try recordedSection(generator_section);
for (npm_licence_exceptions) |entry| {
// A stale exception is how a package quietly regains the MIT default
// after it leaves the tree and comes back under a different licence.
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
std.debug.print(
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
" records. Drop the exception when the package leaves the closure.\n",
.{entry.name},
);
return error.StaleLicenceException;
}
}
/// Whether a recorded `<package> <version> <licence>` section names a package.
fn recordsPackage(section: []const u8, name: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, section, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse continue;
if (std.mem.eql(u8, pkg.name, name)) return true;
}
return false;
}
test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse {
+5 -14
View File
@@ -1,15 +1,9 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1.
//!
//! Until nxdns reaches v0.1 this baseline is **editable**. nxdns has no
//! installs, so a schema change edits this string and PLAN §11.2 together — it
//! does not append a migration step. A step exists to reconcile a database
//! somebody already has, and nobody has one.
//!
//! At v0.1 this string freezes and every later change becomes an append-only
//! step. That is a deliberate act, not a rule the code already lives under.
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
//! never edits it in place. A schema change is a *new* step with new DDL, so
//! this string stays byte-identical to PLAN §11.2 forever.
const std = @import("std");
@@ -46,8 +40,7 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE,
\\ priority INTEGER NOT NULL DEFAULT 100,
\\ enabled INTEGER NOT NULL DEFAULT 1,
\\ tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
\\ enabled INTEGER NOT NULL DEFAULT 1
\\);
\\
\\CREATE TABLE blocklist_sources (
@@ -59,9 +52,7 @@ pub const ddl_v1: [:0]const u8 =
\\ last_updated INTEGER,
\\ domain_count INTEGER NOT NULL DEFAULT 0,
\\ wildcard_count INTEGER NOT NULL DEFAULT 0,
\\ exception_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_regex_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
\\ checksum TEXT
\\);
\\
@@ -75,7 +66,7 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY,
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ pattern TEXT NOT NULL,
\\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
\\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
\\ action TEXT NOT NULL CHECK(action IN ('allow','block')),
\\ created_at INTEGER NOT NULL
\\);
+48 -55
View File
@@ -18,18 +18,19 @@ const log = std.log.scoped(.migrations);
pub const Step = struct { version: u32, sql: [:0]const u8 };
/// One baseline, no steps. Until v0.1 a schema change edits
/// `config_schema.ddl_v1` in place, because nxdns has no installs and there is
/// no database in the world for a step to reconcile.
///
/// At v0.1 the baseline freezes and this list becomes append-only: editing a
/// released step would make a fresh database and an upgraded one disagree, and
/// nothing would detect it. `migrateSteps` already implements that discipline
/// and its tests already pin it against injected step lists.
/// Append only. Editing a released step — or `config_schema.ddl_v1` — would make
/// a fresh database and an upgraded one disagree, and nothing would detect it.
pub const steps = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = ddl_v2 },
};
/// The DoT verification name (`upstreams.tls_name`). Empty keeps the pre-step-2
/// behavior: verify the certificate against the url host.
const ddl_v2: [:0]const u8 =
\\ALTER TABLE upstreams ADD COLUMN tls_name TEXT NOT NULL DEFAULT '';
;
/// The schema version this binary expects. A database `readVersion` reports
/// below this needs `nxdns run` to migrate it; above it is `error.SchemaTooNew`
/// and needs a newer nxdns.
@@ -111,7 +112,7 @@ pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 {
///
/// Reads only, so it works on a connection opened `.read_only` or
/// `.immutable`. That is what it is public for: `nxdns check` may not migrate
/// (ruling F-c), and "at version 0, this binary expects 1" tells an operator
/// (ruling F-c), and "at version 1, this binary expects 2" tells an operator
/// what to do where a bare SQLite error message does not.
pub fn readVersion(database: *db.Db) Error!u32 {
const present = try database.queryInt(
@@ -263,62 +264,52 @@ fn columnExists(database: *db.Db, table: []const u8, column: []const u8) !bool {
return stmt.columnInt(0) != 0;
}
test "a fresh database reaches the baseline with every v1 column and rule kind" {
test "a fresh database reaches version 2 with the tls_name column" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expectEqual(@as(u32, 1), target_version);
try testing.expectEqual(@as(u32, 2), try migrate(&database));
try testing.expectEqual(@as(u32, 2), target_version);
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));
try database.exec(
\\INSERT INTO rules (group_id, pattern, kind, action, created_at)
\\VALUES (1, '^ad[0-9]+-', 'regex', 'block', 100);
);
try testing.expectError(error.Constraint, database.exec(
\\INSERT INTO rules (group_id, pattern, kind, action, created_at)
\\VALUES (1, 'x', 'glob', 'block', 100);
));
}
test "the baseline rules table cascades from its group" {
test "a version 1 database upgrades to 2 and keeps its rows with an empty tls_name" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
try database.exec(
\\INSERT INTO groups (id, name) VALUES (2, 'kids');
\\INSERT INTO rules (id, group_id, pattern, kind, action, created_at) VALUES
\\ (9, 2, '*.tracker.net', 'wildcard', 'allow', 2000);
);
try database.exec("DELETE FROM groups WHERE id = 2;");
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM rules WHERE group_id = 2"),
);
}
test "a failing step rolls back an upgrade of a populated database" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
try testing.expect(!try columnExists(&database, "upstreams", "tls_name"));
try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);");
// Rollback of an *upgrade* is a different case from rollback of the initial
// creation ("a failing step rolls the whole migration back"): here a
// populated database must come back untouched, not cease to exist.
const broken = steps ++ [_]Step{
.{ .version = target_version + 1, .sql = "CREATE TABLE second (id INTEGER PRIMARY KEY);" },
.{ .version = target_version + 2, .sql = "CREATE TABLE third (" },
try testing.expectEqual(@as(u32, 2), try migrate(&database));
try testing.expectEqual(@as(u32, 2), try readVersion(&database));
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
var stmt = try database.prepare("SELECT url, tls_name FROM upstreams");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqualStrings("tls://1.1.1.1:853", stmt.columnText(0));
try testing.expectEqualStrings("", stmt.columnText(1));
}
test "a failing step after step 2 rolls back the whole upgrade from version 1" {
var database = try openMigrated();
defer database.close();
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
_ = try migrateSteps(&database, &first);
const broken = [_]Step{
steps[0],
steps[1],
.{ .version = 3, .sql = "CREATE TABLE third (" },
};
try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));
// One transaction: the step that did succeed went back with the one that did not.
try testing.expect(!try tableExists(&database, "second"));
try testing.expectEqual(target_version, try readVersion(&database));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// One transaction: the ALTER TABLE of step 2 went back with step 3.
try testing.expect(!try columnExists(&database, "upstreams", "tls_name"));
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}
test "readVersion reports 0 before a migration and target_version after it" {
@@ -342,18 +333,20 @@ test "readVersion reads a file database through an immutable open, writing nothi
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/config.db", .{ tmp_prefix, &tmp.sub_path });
// `check` reads the stamped version without migrating (ruling F-c), so the
// read has to work through a connection that cannot write at all.
// A database an older nxdns left at version 1. `check` must report that, not
// migrate it (ruling F-c).
{
var database = try db.Db.open(path, .{ .mode = .read_write_create });
defer database.close();
try db.applyPragmas(&database, .{});
try testing.expectEqual(target_version, try migrate(&database));
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
}
var database = try db.Db.open(path, .{ .mode = .{ .immutable = testing.io } });
defer database.close();
try testing.expectEqual(target_version, try readVersion(&database));
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
try testing.expectEqual(@as(u32, 2), target_version);
// A write through this connection is refused by SQLite, not by convention.
try testing.expectError(error.ReadOnly, database.exec("DELETE FROM schema_version;"));
-25
View File
@@ -281,31 +281,6 @@ test "rules round-trip in group, kind, action, pattern, id order" {
);
}
test "a regex rule round-trips through the table with its pattern untouched" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroupIds();
defer ids.deinit(testing.allocator);
// Uppercase, a trailing metacharacter and a backslash escape: everything
// the name-shaped kinds normalize away and this one must not.
const pattern = "^AD[0-9]+-\\.example\\.";
try insertRule(&database, .{
.group = "default",
.pattern = pattern,
.kind = .regex,
.action = .block,
}, .{ .now = 1700000000, .group_ids = &ids });
var items = try listRules(&database, testing.allocator);
defer items.deinit(testing.allocator);
defer freeRules(testing.allocator, items.items);
try testing.expectEqual(@as(usize, 1), items.items.len);
try testing.expectEqual(model.RuleKind.regex, items.items[0].kind);
try testing.expectEqualStrings(pattern, items.items[0].pattern);
}
test "a duplicate rule is accepted and stays deterministically ordered by id" {
var database = try openMigrated();
defer database.close();
+7 -43
View File
@@ -1,10 +1,9 @@
//! `blocklist_sources`.
//!
//! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
//! `skipped_unsupported_count` and `checksum` are facts a running server
//! produces; an insert leaves them at their column defaults so two exports taken
//! minutes apart stay identical.
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! The import path is list / insert / deleteAll / count; the runtime columns and
//! the REST surface follow it, both keyed by row id.
@@ -89,17 +88,7 @@ pub const SourceRow = struct {
last_updated: ?i64,
domain_count: i64,
wildcard_count: i64,
/// Written `.allow` entries: the `@@||name^` exceptions the list carries.
/// Defaulted for the same reason `is_suggested` is — the blocklist manager
/// builds `SourceRow` values from the refresh columns alone.
exception_count: i64 = 0,
skipped_regex_count: i64,
/// Lines the compiler read and could not translate into a DNS decision:
/// cosmetic element hiding, `$`-modifier rules (save the tolerated
/// `$important` exception suffix, which lands in `exception_count`), scheme
/// anchors. Counted and not written, like `skipped_regex_count` and unlike
/// the three counts above.
skipped_unsupported_count: i64,
checksum: ?[]const u8,
};
@@ -107,19 +96,15 @@ pub const SourceStats = struct {
last_updated: i64,
domain_count: i64,
wildcard_count: i64,
exception_count: i64,
skipped_regex_count: i64,
skipped_unsupported_count: i64,
/// Lowercase hex sha256 over the `.list` body, then the `.wild` body, then
/// the `.allow` body, each followed by `compiler.body_separator` so the
/// digest cannot confuse a name in one body with the same name in another.
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
checksum: []const u8,
};
const row_columns_sql =
\\SELECT id, url, name, enabled, last_updated,
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
\\ is_suggested, exception_count, skipped_unsupported_count
\\ is_suggested
\\ FROM blocklist_sources
;
@@ -148,9 +133,7 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6),
.exception_count = stmt.columnInt(10),
.skipped_regex_count = stmt.columnInt(7),
.skipped_unsupported_count = stmt.columnInt(11),
.checksum = checksum,
};
}
@@ -167,8 +150,7 @@ pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
const update_stats_sql =
\\UPDATE blocklist_sources
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
\\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7,
\\ skipped_unsupported_count = ?8
\\ skipped_regex_count = ?5, checksum = ?6
\\ WHERE id = ?1
;
@@ -183,8 +165,6 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
try stmt.bindInt(4, stats.wildcard_count);
try stmt.bindInt(5, stats.skipped_regex_count);
try stmt.bindText(6, stats.checksum);
try stmt.bindInt(7, stats.exception_count);
try stmt.bindInt(8, stats.skipped_unsupported_count);
try stmt.exec();
}
@@ -324,13 +304,7 @@ test "insertBlocklistSource leaves the runtime columns at their defaults" {
);
try testing.expectEqual(
@as(i64, 0),
try database.queryInt(
// Every runtime counter, summed only because this asserts they are
// all 0. No production query may add the two skip counters to the
// three written ones: a skipped line was never written.
"SELECT sum(domain_count + wildcard_count + exception_count + skipped_regex_count" ++
" + skipped_unsupported_count) FROM blocklist_sources",
),
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"),
);
}
@@ -381,9 +355,7 @@ test "listSourceRows returns row ids and the runtime columns in url order" {
try testing.expectEqual(@as(?[]const u8, null), row.checksum);
try testing.expectEqual(@as(i64, 0), row.domain_count);
try testing.expectEqual(@as(i64, 0), row.wildcard_count);
try testing.expectEqual(@as(i64, 0), row.exception_count);
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 0), row.skipped_unsupported_count);
}
}
@@ -401,9 +373,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
.last_updated = 1_700_000_000,
.domain_count = 4321,
.wildcard_count = 21,
.exception_count = 9,
.skipped_regex_count = 7,
.skipped_unsupported_count = 15,
.checksum = "a" ** 64,
});
@@ -419,9 +389,7 @@ test "updateSourceStats writes the runtime columns of one source only" {
try testing.expectEqual(@as(?i64, 1_700_000_000), updated.last_updated);
try testing.expectEqual(@as(i64, 4321), updated.domain_count);
try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
try testing.expectEqual(@as(i64, 9), updated.exception_count);
try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
try testing.expectEqual(@as(i64, 15), updated.skipped_unsupported_count);
try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
// The two untouched rows kept their defaults.
@@ -437,9 +405,7 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
.last_updated = 1,
.domain_count = 2,
.wildcard_count = 3,
.exception_count = 5,
.skipped_regex_count = 4,
.skipped_unsupported_count = 6,
.checksum = "b" ** 64,
});
@@ -504,9 +470,7 @@ test "updateSource leaves the runtime columns where the refresh path left them"
.last_updated = 1_700_000_000,
.domain_count = 12,
.wildcard_count = 3,
.exception_count = 2,
.skipped_regex_count = 1,
.skipped_unsupported_count = 4,
.checksum = "c" ** 64,
});
-1
View File
@@ -58,7 +58,6 @@ comptime {
_ = @import("filter/parser_domains.zig");
_ = @import("filter/parser_abp.zig");
_ = @import("filter/wildcard.zig");
_ = @import("filter/regex.zig");
_ = @import("filter/domain_set.zig");
_ = @import("filter/rules.zig");
_ = @import("filter/matcher.zig");
+1 -18
View File
@@ -59,9 +59,7 @@ pub const StatusView = struct {
last_error: []const u8,
domains: u32,
wildcards: u32,
exceptions: u32,
skipped_regex: u32,
skipped_unsupported: u32,
pub fn from(status: *const manager_mod.SourceStatus) StatusView {
return .{
@@ -74,9 +72,7 @@ pub const StatusView = struct {
.last_error = status.errorText(),
.domains = status.counts.domains,
.wildcards = status.counts.wildcards,
.exceptions = status.counts.exceptions,
.skipped_regex = status.counts.skipped_regex,
.skipped_unsupported = status.counts.skipped_unsupported,
};
}
};
@@ -321,9 +317,7 @@ test "editing a blocklist keeps the counters the refresh wrote" {
.last_updated = 1700,
.domain_count = 42,
.wildcard_count = 3,
.exception_count = 2,
.skipped_regex_count = 1,
.skipped_unsupported_count = 8,
.checksum = "abc",
});
@@ -338,8 +332,6 @@ test "editing a blocklist keeps the counters the refresh wrote" {
try testing.expectEqualStrings("renamed", row.name);
try testing.expect(!row.enabled);
try testing.expectEqual(@as(i64, 42), row.domain_count);
try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
try testing.expectEqual(@as(i64, 8), row.skipped_unsupported_count);
try testing.expectEqual(@as(usize, 2), bench.reloads);
}
@@ -388,13 +380,7 @@ test "a status becomes the flat shape the API answers with" {
const message = "connection refused";
@memcpy(status.last_error[0..message.len], message);
status.last_error_len = message.len;
status.counts = .{
.domains = 10,
.wildcards = 2,
.exceptions = 4,
.skipped_regex = 1,
.skipped_unsupported = 6,
};
status.counts = .{ .domains = 10, .wildcards = 2, .skipped_regex = 1 };
const view: StatusView = .from(&status);
try testing.expectEqual(@as(i64, 7), view.id);
@@ -403,7 +389,4 @@ test "a status becomes the flat shape the API answers with" {
try testing.expectEqualStrings(url, view.url);
try testing.expectEqualStrings(message, view.last_error);
try testing.expectEqual(@as(u32, 10), view.domains);
try testing.expectEqual(@as(u32, 4), view.exceptions);
try testing.expectEqual(@as(u32, 1), view.skipped_regex);
try testing.expectEqual(@as(u32, 6), view.skipped_unsupported);
}
+1 -4
View File
@@ -38,8 +38,6 @@ pub const Body = struct {
reason: []const u8,
/// The rule or list entry that decided it; "" when nothing matched.
matched: []const u8,
/// The list that decided it, which for `blocklist_exception` is the list
/// whose `@@` rule lifted the block rather than one that made it.
source_url: ?[]const u8,
safe_search_rewrite: ?[]const u8,
};
@@ -52,8 +50,7 @@ pub const Result = struct {
blocked: bool,
reason: matcher.Reason,
matched: []const u8,
/// `blocklist_sources` row id of the list that matched, whether it blocked
/// the name or lifted it through an `@@` exception.
/// `blocklist_sources` row id of the list that matched.
source_id: ?i64,
safe_search_rewrite: ?[]const u8,
};
+2 -44
View File
@@ -39,7 +39,7 @@ const Created = union(enum) { id: i64, fail: Failure };
/// not understood.
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
const kind = model.RuleKind.fromDb(body.kind) orelse
return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } };
return .{ .fail = .{ .invalid = "kind must be 'exact' or 'wildcard'" } };
const action = model.RuleAction.fromDb(body.action) orelse
return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
return .{ .input = .{
@@ -308,7 +308,7 @@ test "an unknown kind or action is a 400 before anything is written" {
try testing.expect(toInput(.{
.group_id = 1,
.pattern = "ads.example",
.kind = "glob",
.kind = "regex",
.action = "block",
}).fail == .invalid);
@@ -327,46 +327,4 @@ test "an unknown kind or action is a 400 before anything is written" {
});
try testing.expectEqual(model.RuleKind.wildcard, good.input.kind);
try testing.expectEqual(model.RuleAction.allow, good.input.action);
const third = toInput(.{
.group_id = 1,
.pattern = "^ad[0-9]+-",
.kind = "regex",
.action = "block",
});
try testing.expectEqual(model.RuleKind.regex, third.input.kind);
}
test "a regex rule is stored, and a pattern the engine refuses is a 400 that names it" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.group_id = 1,
.pattern = "^ad[0-9]+-",
.kind = .regex,
.action = .block,
});
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
try testing.expectEqual(model.RuleKind.regex, row.kind);
// Stored verbatim: a regex is not a name, so nothing lowercases or
// dot-strips it on the way to the table.
try testing.expectEqualStrings("^ad[0-9]+-", row.pattern);
const unclosed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.group_id = 1,
.pattern = "(",
.kind = .regex,
.action = .block,
});
try testing.expectEqualStrings(
"rules[0].pattern: '(' is not a valid regex pattern",
unclosed.fail.invalid,
);
// The refused pattern reached no table, and the good one is still the only
// row: a 400 costs no write and no reload.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM rules"));
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
+5 -9
View File
@@ -1876,7 +1876,7 @@ components:
Blocklist:
type: object
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum]
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, skipped_regex_count, checksum]
properties:
id: { type: integer }
url: { type: string }
@@ -1888,9 +1888,7 @@ components:
nullable: true
domain_count: { type: integer }
wildcard_count: { type: integer }
exception_count: { type: integer }
skipped_regex_count: { type: integer }
skipped_unsupported_count: { type: integer }
checksum:
type: string
nullable: true
@@ -1920,7 +1918,7 @@ components:
SourceStatus:
type: object
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported]
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, skipped_regex]
properties:
id: { type: integer }
state:
@@ -1935,9 +1933,7 @@ components:
description: Empty when the last attempt succeeded.
domains: { type: integer }
wildcards: { type: integer }
exceptions: { type: integer }
skipped_regex: { type: integer }
skipped_unsupported: { type: integer }
Rule:
type: object
@@ -1949,7 +1945,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard, regex]
enum: [exact, wildcard]
action:
type: string
enum: [allow, block]
@@ -1963,7 +1959,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard, regex]
enum: [exact, wildcard]
action:
type: string
enum: [allow, block]
@@ -1977,7 +1973,7 @@ components:
pattern: { type: string }
kind:
type: string
enum: [exact, wildcard, regex]
enum: [exact, wildcard]
action:
type: string
enum: [allow, block]
-12
View File
@@ -1665,9 +1665,6 @@ fn createdId(body: []const u8) !i64 {
return std.fmt.parseInt(i64, rest[0..end], 10);
}
/// The three files a refresh publishes for one source. The `.allow` file is
/// written here too: the delete path has to take every compiled body, and a
/// sweep that missed one would leave an orphan this test could not see.
fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
var buf: [64]u8 = undefined;
try dir.writeFile(io, .{
@@ -1678,10 +1675,6 @@ fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
.sub_path = try std.fmt.bufPrint(&buf, "{d}.wild", .{id}),
.data = "",
});
try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&buf, "{d}.allow", .{id}),
.data = "",
});
}
fn accessCompiled(io: std.Io, dir: std.Io.Dir, id: i64) !void {
@@ -1733,11 +1726,6 @@ fn deleteSweepsCompiledFiles(io: std.Io, env: *Env) anyerror!void {
try std.fmt.bufPrint(&name_buf, "{d}.wild", .{doomed}),
.{},
));
try testing.expectError(error.FileNotFound, dir.access(
io,
try std.fmt.bufPrint(&name_buf, "{d}.allow", .{doomed}),
.{},
));
try accessCompiled(io, dir, kept);
}
+3 -24
View File
@@ -9,10 +9,8 @@
//!
//! - `Line.text` is always a slice of the caller's line, never a copy and
//! never a dangling pointer into a temporary;
//! - `covers_apex` is set only on a `.wildcard` or an `.exception` line,
//! because those are the two anchored forms it describes; the compiler acts
//! on it for `.wildcard`, where it emits the apex entry beside the suffix
//! one;
//! - `covers_apex` is set only on a `.wildcard` line, because the compiler
//! reads it only there;
//! - `wildcard.matches` terminates for any pattern, validated or not, and a
//! match implies the domain has at least as many labels as the pattern,
//! since every pattern label consumes at least one domain label.
@@ -77,11 +75,6 @@ fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void {
const parsed = parsers.parseLine(format, line);
try expectBorrowed(parsed, line);
// Exception syntax belongs to the ABP parser alone. A hosts or domains line
// that produced one would open an allow hole in a format that has no way to
// write one.
if (format != .abp) try std.testing.expect(parsed.kind != .exception);
}
/// The sniffer reads whole files, so this one keeps the line breaks.
@@ -120,12 +113,7 @@ fn wildcardTarget(_: void, smith: *Smith) anyerror!void {
/// The parser contract: `text` is a window into the caller's line, so the
/// compiler may keep it for the length of that line and no longer.
fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void {
if (parsed.covers_apex) {
try std.testing.expect(parsed.kind == .wildcard or parsed.kind == .exception);
}
// An exception with no name would compile to an empty allow entry, which
// `addCandidate` would then reject as invalid rather than honour.
if (parsed.kind == .exception) try std.testing.expect(parsed.text.len != 0);
if (parsed.covers_apex) try std.testing.expectEqual(parsers.Kind.wildcard, parsed.kind);
if (parsed.text.len == 0) return;
const start = @intFromPtr(parsed.text.ptr);
@@ -158,12 +146,6 @@ const hosts_line = "0.0.0.0 ads.example.com tracker.example.com # advertising";
/// An ABP domain rule, which covers the apex as well as the subdomains.
const abp_line = "||ads.example.net^";
/// The exception forms: the two anchored spellings, the one tolerated modifier,
/// and a bare `@@` name, which stays unsupported.
const abp_exception = "@@||good.ads.example.net^";
const abp_exception_important = "@@||good.ads.example.net^$important";
const abp_exception_unanchored = "@@good.ads.example.net";
/// A regex rule, which every parser counts and skips (PLAN §2.2).
const regex_line = "/^ads[0-9]+\\.example\\.org$/";
@@ -179,9 +161,6 @@ const scheme_anchor = "|https://ads.example.com/track";
const corpus = [_][]const u8{
sliceInput(hosts_line),
sliceInput(abp_line),
sliceInput(abp_exception),
sliceInput(abp_exception_important),
sliceInput(abp_exception_unanchored),
sliceInput(regex_line),
sliceInput(long_line),
sliceInput(element_hiding),
+1 -8
View File
@@ -94,8 +94,6 @@ fn compileOnce(
var list_w: std.Io.Writer.Discarding = .init(&list_sink);
var wild_sink: [0]u8 = .{};
var wild_w: std.Io.Writer.Discarding = .init(&wild_sink);
var allow_sink: [0]u8 = .{};
var allow_w: std.Io.Writer.Discarding = .init(&allow_sink);
return compiler.compile(
std.testing.allocator,
@@ -103,7 +101,6 @@ fn compileOnce(
format,
&list_w.writer,
&wild_w.writer,
&allow_w.writer,
) catch |err| switch (err) {
error.OutOfMemory,
error.TooManyDomains,
@@ -125,13 +122,12 @@ fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void {
// A candidate is a non-empty whitespace-separated field or a whole wildcard
// line, so every candidate consumes at least one byte of the input, and a
// written name is a candidate that survived.
const candidates = @as(u64, counts.domains) + counts.wildcards + counts.exceptions +
const candidates = @as(u64, counts.domains) + counts.wildcards +
counts.duplicates + counts.invalid;
try std.testing.expect(candidates <= bytes.len + 1);
try std.testing.expect(counts.domains <= compiler.max_domains);
try std.testing.expect(counts.wildcards <= compiler.max_domains);
try std.testing.expect(counts.exceptions <= compiler.max_domains);
}
// ---------------------------------------------------------------------------
@@ -158,9 +154,6 @@ const corpus = [_][]const u8{
sliceInput(long_line_terminated),
sliceInput("# a hosts list\n0.0.0.0 ads.example.com # advertising\n"),
sliceInput("||ads.example.net^\n@@||allow.example.net^\n/re[0-9]+/\n"),
// The three exception shapes: the two accepted spellings with the one
// tolerated modifier, and a form that stays unsupported.
sliceInput("@@||a.example.net^$important\n@@||b.example.net\n@@c.example.net\n"),
sliceInput("*.wild.example.org\nlocalhost\nAdS.Example.COM.\n"),
};
-149
View File
@@ -1,149 +0,0 @@
//! Fuzz target for the regex engine (`src/filter/regex.zig`, milestone-21
//! ruling 10).
//!
//! The contract: any byte string is a legal pattern, so `compile` may reject it
//! however it likes but must return — never panic, never loop forever, never
//! read out of bounds. Where it returns a program the target then checks what
//! the filter is entitled to rely on:
//!
//! - the program obeys ruling 5's limits: it is non-empty, at most
//! `max_program_len` instructions, and it came from a pattern of at most
//! `max_pattern_len` bytes;
//! - `matches` terminates on any input, and the VM's step count never exceeds
//! program length × (input length + 1), which is the linearity claim the
//! whole design rests on;
//! - compile-then-match is deterministic: the same pattern compiled twice
//! gives the same program length and the same verdict on the same input,
//! and two runs of one program agree step for step.
//!
//! `regex.zig` imports only `std`, so this target's module roots directly at
//! that file — no aggregator needed.
//!
//! Runner semantics: under a plain `zig build test` the target runs once per
//! corpus entry plus once on empty input, which makes the corpus a regression
//! suite. `zig build test --fuzz=<n>` gives it `n` generated inputs.
const std = @import("std");
const regex = @import("regex");
const smith_encode = @import("smith_encode.zig");
const sliceInput = smith_encode.sliceInput;
const pairInput = smith_encode.pairInput;
const Smith = std.testing.Smith;
/// Twice `regex.max_pattern_len`, so `error.PatternTooLong` is reachable rather
/// than the only thing the target ever sees.
const max_pattern = 2 * regex.max_pattern_len;
/// Past the 253 bytes of the longest text name, which is the longest input the
/// filter ever hands the engine.
const max_name = 512;
/// `Smith` entity ids: the pattern and the name it is matched against.
const pattern_hash: u32 = 1;
const name_hash: u32 = 2;
const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus };
test "fuzz regex.compile and regex.matches" {
try std.testing.fuzz({}, regexTarget, fuzz_options);
}
fn regexTarget(_: void, smith: *Smith) anyerror!void {
var pattern_buf: [max_pattern]u8 = undefined;
var name_buf: [max_name]u8 = undefined;
const pattern = pattern_buf[0..smith.sliceWithHash(&pattern_buf, pattern_hash)];
const input = name_buf[0..smith.sliceWithHash(&name_buf, name_hash)];
var prog = (try compileOnce(pattern)) orelse return;
defer prog.deinit(std.testing.allocator);
// Ruling 5's limits, read off the program the compiler agreed to build.
try std.testing.expect(pattern.len <= regex.max_pattern_len);
try std.testing.expect(prog.insts.len > 0);
try std.testing.expect(prog.insts.len <= regex.max_program_len);
const first = regex.run(&prog, input);
try expectLinear(first, prog.insts.len, input.len);
try std.testing.expectEqual(first.matched, regex.matches(&prog, input));
// The empty name is the cheapest way to reach the position-zero closure with
// no consuming step behind it, so every pattern is run against it too.
try expectLinear(regex.run(&prog, ""), prog.insts.len, 0);
const again = regex.run(&prog, input);
try std.testing.expectEqual(first.matched, again.matched);
try std.testing.expectEqual(first.steps, again.steps);
// Compiling is a pure function of the pattern bytes: the second program
// matches the first instruction for instruction and answers the same.
var second = (try compileOnce(pattern)) orelse return error.TestSecondCompileFailed;
defer second.deinit(std.testing.allocator);
try std.testing.expectEqual(prog.insts.len, second.insts.len);
try std.testing.expectEqual(prog.classes.len, second.classes.len);
try std.testing.expectEqual(first.matched, regex.matches(&second, input));
}
/// One compile, or null when the engine rejected the pattern. Every member of
/// `regex.Error` is a legitimate rejection: unparsable syntax, a pattern past
/// the byte limit, a program past the instruction limit, and an allocator that
/// ran out.
fn compileOnce(pattern: []const u8) anyerror!?regex.Program {
return regex.compile(std.testing.allocator, pattern) catch |err| switch (err) {
error.OutOfMemory,
error.BadPattern,
error.PatternTooLong,
error.PatternTooComplex,
=> null,
};
}
fn expectLinear(result: regex.Run, program_len: usize, input_len: usize) !void {
try std.testing.expect(result.steps <= program_len * (input_len + 1));
}
// ---------------------------------------------------------------------------
// corpus
// ---------------------------------------------------------------------------
//
// `Smith` does not consume a corpus entry as raw input, so every entry below
// goes through the `smith_encode.zig` encoders. An entry that carries only the
// pattern leaves the name empty, which is the position-zero closure on its own.
/// The backtracker killers: exponential for a backtracking engine, linear here.
const nested_plus = "(a+)+b";
const nested_alternation = "^(a|aa)+$";
const nested_star = "^(a*)*(b*)*$";
/// An epsilon cycle: the loop body consumes nothing, so only the VM's
/// one-admission-per-position rule ends the walk.
const empty_loop = "^((a*)*)*$";
/// A name long enough that a quadratic step count would show against the bound.
const long_name = "a" ** 252 ++ "X";
/// Emits nothing at all, so only the emitter's visit budget ends the compile.
const empty_body_blowup = "((((x{0}){900}){900}){900}){900}";
const corpus = [_][]const u8{
pairInput(nested_plus, "a" ** 20 ++ "X"),
pairInput(nested_alternation, long_name),
pairInput(nested_star, long_name),
pairInput(empty_loop, long_name),
pairInput("^ad[0-9]+-", "ad42-serve.example.com"),
pairInput("^(ads|track)\\.example\\.(com|net)$", "track.example.net"),
pairInput("[^.]+\\.doubleclick\\.net$", "static.doubleclick.net"),
pairInput("^\\w{1,8}\\.\\d{2}\\.example$", "ads_42.13.example"),
// Every rejection path, so the corpus replays them rather than waiting on a
// discovery: bad syntax, an over-long pattern, an over-large program, and a
// compile that only the visit budget stops.
sliceInput("(a"),
sliceInput("[z-a]"),
sliceInput("a{2,1}"),
sliceInput("ads\\"),
sliceInput("(?:ab)"),
sliceInput("a+?"),
sliceInput("a" ** (regex.max_pattern_len + 1)),
sliceInput("(abcd){400}"),
sliceInput(empty_body_blowup),
};
+7 -40
View File
@@ -10,9 +10,8 @@
//! What each suite measures:
//! - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op — the handler's
//! filtering work — against a snapshot built from `--domains` generated exact
//! entries, a small wildcard body and `bench_regex_rules` operator regex
//! rules. Query mix cycles hit, miss and parent-walk. Target p95 < 1 ms;
//! VmRSS < 100 MiB with the list loaded.
//! entries plus a small wildcard body. Query mix cycles hit, miss and
//! parent-walk. Target p95 < 1 ms; VmRSS < 100 MiB with the list loaded.
//! - `cache`: `buildKey` + `DnsCache.get` + `packet.setId` — the handler's
//! cache-hit path, TTL aging included — on a 10k-entry cache prefilled with a
//! realistic response. Query mix alternates hit and miss. Target p95 < 5 ms.
@@ -39,11 +38,6 @@ const rss_target_bytes: usize = 100 * 1024 * 1024;
const cache_entries: u32 = 10_000;
/// Operator regex rules the filter snapshot carries. A household writes a
/// handful; 32 is the pessimistic end of plausible, and `max_regex_per_group`
/// allows eight times as many.
const bench_regex_rules: usize = 32;
/// Byte-for-byte copy of `response` in tests/fuzz/corpus.zig (a copy on
/// purpose, same as the corpus itself: a bench input that changes whenever a
/// test fixture is edited is a benchmark that silently shifts). A CNAME to
@@ -153,23 +147,6 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
try body.appendSlice(gpa, text);
}
// None of these matches the generated query mix, which is the expensive
// case rather than the cheap one: the regex levels sit below every hash and
// wildcard level, so a query that no regex matches is the query that runs
// all of them to their end. Every op in this suite pays that.
var patterns: std.ArrayList([]u8) = .empty;
defer {
for (patterns.items) |pattern| gpa.free(pattern);
patterns.deinit(gpa);
}
var regex_rules: [bench_regex_rules]model.Rule = undefined;
for (&regex_rules, 0..) |*row, i| {
const pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.(ads|track)\\.invalid$", .{i});
errdefer gpa.free(pattern);
try patterns.append(gpa, pattern);
row.* = .{ .group = "default", .pattern = pattern, .kind = .regex, .action = .block };
}
const sources = [_]model.BlocklistSource{.{ .url = "bench://list", .name = "bench" }};
const links = [_]model.GroupSource{.{ .group = "default", .source_url = "bench://list" }};
var snapshot = try matcher.Snapshot.build(gpa, .{
@@ -178,7 +155,7 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
.group_sources = &links,
.sources = &sources,
.source_ids = &.{1},
.rules = &regex_rules,
.rules = &.{},
.clients = &.{},
.prefixes = &.{},
.compiled = &.{.{ .list_body = body.items, .wild_body = wild_body }},
@@ -228,10 +205,9 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
const pct = percentiles(samples);
const rss = vmRssBytes(io);
try printRow(w, "filter", opts.iters, pct);
try w.print(
" blocked {d}/{d}, {d} regex rules, Snapshot.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n",
.{ blocked, opts.iters, regex_rules.len, mib(snapshot.memoryBytes()), mib(rss) },
);
try w.print(" blocked {d}/{d}, Snapshot.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n", .{
blocked, opts.iters, mib(snapshot.memoryBytes()), mib(rss),
});
var exceeded: u32 = 0;
exceeded += try printTarget(w, "p95 < 1ms", pct.p95 < filter_p95_target_ns);
@@ -311,20 +287,11 @@ fn runCompile(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !void {
var reader = std.Io.Reader.fixed(body.items);
var list_buf: [4096]u8 = undefined;
var wild_buf: [4096]u8 = undefined;
var allow_buf: [4096]u8 = undefined;
var list_out: Writer.Discarding = .init(&list_buf);
var wild_out: Writer.Discarding = .init(&wild_buf);
var allow_out: Writer.Discarding = .init(&allow_buf);
const t0 = std.Io.Clock.awake.now(io);
const result = compiler.compile(
gpa,
&reader,
.hosts,
&list_out.writer,
&wild_out.writer,
&allow_out.writer,
) catch |err| {
const result = compiler.compile(gpa, &reader, .hosts, &list_out.writer, &wild_out.writer) catch |err| {
std.process.fatal("compiler.compile failed: {t}", .{err});
};
const t1 = std.Io.Clock.awake.now(io);
+330 -1113
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"build": "vite build && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts",
"format": "prettier --write .",
@@ -25,15 +25,13 @@
"trailingComma": "all"
},
"dependencies": {
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
"@stylexjs/unplugin": "0.19.0",
"@tailwindcss/vite": "4.3.3",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.1",
@@ -43,7 +41,8 @@
"jsdom": "29.1.1",
"oxlint": "1.75.0",
"prettier": "3.9.6",
"typescript": "7.0.2",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"vite": "8.1.5",
"vitest": "4.1.10"
}
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env node
// Every rule in the built stylesheet must sit inside a cascade layer
// (milestone 23). Unlayered author CSS outranks every layer whatever its
// selector says, so a single unlayered rule silently beats the StyleX atomic
// rules it was written to sit under. That failure renders wrong and passes
// every other gate: no test asserts computed style, and the bundler is happy.
// It also checks the layer ORDER, which is the invariant that actually matters:
// a later layer beats an earlier one, so `reset` has to be declared first.
//
// What it does not catch, so nobody reads more into a pass than is there: an
// unlayered rule that sets only custom properties is allowed, because StyleX
// emits its token `:root` block exactly that way and this cannot tell that
// block from an override of it; a declaration value containing `@layer` or a
// brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document.
//
// This check runs from web/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
if (sheets.length === 0) {
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
process.exit(1);
}
// At-rules that describe a resource or a name rather than styling an element.
// They carry no cascade priority against a layer, so being outside one is
// correct, and StyleX emits `@property` for its custom properties.
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
/** Strip comments, then every balanced block and statement the rule above allows. */
function outsideLayers(css) {
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (;;) {
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
if (at === -1) break;
let depth = 0;
let end = rest.indexOf("{", at);
for (let i = end; i < rest.length; i += 1) {
if (rest[i] === "{") depth += 1;
else if (rest[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
rest = rest.slice(0, at) + rest.slice(end + 1);
}
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
}
/**
* A rule that only sets custom properties styles nothing on its own StyleX
* emits its token `:root` block that way, ahead of its layers, and a variable
* is consumed through `var()` rather than competing with a layered rule.
*/
function stylesSomething(body) {
return body
.split(";")
.map((declaration) => declaration.trim())
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
}
/**
* Layer names in the order their position is fixed, which is where each name is
* first mentioned a later block under an already-named layer does not move it.
*/
function layerOrder(css) {
const seen = [];
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
for (const name of names.split(",")) {
const trimmed = name.trim();
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
}
}
return seen;
}
let failed = false;
for (const sheet of sheets) {
const css = readFileSync(join(distDir, sheet), "utf8");
// Order is the whole point: a later layer wins, so the reset has to be first.
const order = layerOrder(css);
if (order.length > 0 && order[0] !== "reset") {
console.error(
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")}` +
`'reset' must come first or it outranks the StyleX rules written against it.`,
);
failed = true;
}
const leftover = outsideLayers(css);
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!stylesSomething(body)) continue;
console.error(
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
);
failed = true;
break;
}
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
process.exit(1);
}
console.log(
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
);
+10 -59
View File
@@ -1,57 +1,8 @@
import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
page: {
display: "flex",
minHeight: "100dvh",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
color: colors.text,
padding: "1rem",
},
card: {
width: "100%",
maxWidth: "24rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
probing: {
marginTop: "1rem",
color: colors.textMuted,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
submit: {
width: "100%",
},
error: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
});
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/";
@@ -125,15 +76,15 @@ export default function LoginPage() {
}
return (
<main {...stylex.props(styles.page)}>
<section {...stylex.props(styles.card)}>
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<section className="w-full max-w-sm">
<h1 className="text-2xl font-semibold">nxdns</h1>
{authRequired !== true ? (
<p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
<p className="mt-4 text-zinc-500">Checking whether a password is required</p>
) : (
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
<div>
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
@@ -144,20 +95,20 @@ export default function LoginPage() {
required
value={password}
onChange={(event) => setPassword(event.target.value)}
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
className={`w-full ${largePrimaryButtonClass}`}
>
{busy ? "Logging in…" : "Log in"}
</button>
</form>
)}
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
{errorMessage(error, remaining)}
</p>
)}
+12 -54
View File
@@ -1,48 +1,10 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancel: {
fontWeight: 500,
},
});
/**
* Drops the rejection the page already renders inline below the form. Anything
* else is a bug in this component and must reach the console instead of dying
@@ -84,10 +46,10 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
}
return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<div>
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="blocklist-url" className="block text-sm font-medium">
URL
</label>
<input
@@ -96,11 +58,11 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required
value={url}
onChange={(event) => setUrl(event.target.value)}
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="blocklist-name" className="block text-sm font-medium">
Name
</label>
<input
@@ -109,33 +71,29 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<label {...stylex.props(styles.checkboxLabel)}>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
Enabled
</label>
<div {...stylex.props(styles.buttonRow)}>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
>
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
Cancel
</button>
)}
@@ -17,9 +17,7 @@ const BLOCKLISTS = {
last_updated: 1700000000,
domain_count: 1000,
wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc",
},
{
@@ -31,9 +29,7 @@ const BLOCKLISTS = {
last_updated: null,
domain_count: 0,
wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null,
},
],
@@ -45,20 +41,14 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -102,9 +92,7 @@ const SNAPSHOT = {
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
],
};
@@ -118,15 +106,8 @@ test("renders the source table and the status empty state", async () => {
expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText("1000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true);
@@ -160,9 +141,7 @@ test("update now disables the button, then replaces the status section from the
last_error: "",
domains: 1200,
wildcards: 12,
exceptions: 9,
skipped_regex: 4,
skipped_unsupported: 17,
},
{
id: 2,
@@ -174,9 +153,7 @@ test("update now disables the button, then replaces the status section from the
last_error: "connect timed out",
domains: 0,
wildcards: 0,
exceptions: 0,
skipped_regex: 0,
skipped_unsupported: 0,
},
],
};
@@ -189,10 +166,7 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("connect timed out")).toBeTruthy();
expect(screen.getByText("1200")).toBeTruthy();
expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/);
@@ -253,27 +227,3 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+49 -134
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
@@ -14,79 +13,21 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -116,10 +57,10 @@ export default function BlocklistsPage() {
});
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
function deleteBlocklist(b: Blocklist) {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
remove.mutate(b.id);
}
}
const formError = editing === null ? create.error : save.error;
@@ -127,58 +68,60 @@ export default function BlocklistsPage() {
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p {...stylex.props(styles.done)} role="status">
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<th className={thClass}>Name</th>
<th className={thClass}>URL</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last updated</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<td className={tdClass}>
<span className="font-medium">{b.name}</span>
{b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
{b.url}
</span>
</td>
<td {...stylex.props(shared.td)}>
<td className={tdClass}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
@@ -186,40 +129,32 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
</td>
<td {...stylex.props(shared.td)}>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={tdClass}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<td className={tdClass}>
<div className="flex gap-3">
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(b)}
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
className={dangerLinkButtonClass}
>
Delete
</button>
@@ -229,13 +164,6 @@ export default function BlocklistsPage() {
))}
</tbody>
</table>
<p {...stylex.props(styles.note)}>
Both Skipped columns count lines nxdns read and did not take. Skipped regex lines are patterns
nxdns accepts only from you adopt one you trust as a regex rule. Skipped unsupported lines are
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
written for a browser extension, and its DNS or hosts variant will block more here.
</p>
</div>
)}
<InlineError error={tableError} />
@@ -251,19 +179,6 @@ export default function BlocklistsPage() {
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
@@ -1,8 +1,6 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { tdClass, thClass } from "@/ui/classes";
function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -13,114 +11,61 @@ interface SourceStatusSectionProps {
namesById: ReadonlyMap<number, string>;
}
const styles = stylex.create({
section: {
marginTop: "2rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.5rem",
overflowX: "auto",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
url: {
marginTop: "0.125rem",
display: "block",
maxWidth: "16rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
/** Green has no token: a loaded source is the only success state in the app. */
loaded: {
color: {
default: "oklch(52.7% 0.154 150.069)",
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
},
},
failed: {
color: colors.danger,
},
absent: {
color: colors.textMuted,
},
});
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Source status</h2>
<section className="mt-8">
<h2 className="text-lg font-medium">Source status</h2>
{sources === null ? (
<p {...stylex.props(styles.note)}>
<p className="mt-2 text-zinc-500">
No status snapshot yet run Update now to fetch status for every enabled source.
</p>
) : sources.length === 0 ? (
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className="mt-2 overflow-x-auto">
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>State</th>
<th {...stylex.props(shared.th)}>Last attempt</th>
<th {...stylex.props(shared.th)}>Last success</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last error</th>
<th className={thClass}>Source</th>
<th className={thClass}>State</th>
<th className={thClass}>Last attempt</th>
<th className={thClass}>Last success</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last error</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>
{namesById.get(source.id) ?? source.url}
<td className={tdClass}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
{source.url}
</span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
<td className={tdClass}>
<span
className={
source.loaded
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{source.state}
</span>
</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{source.skipped_unsupported}
</td>
<td {...stylex.props(shared.td)}>
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
<td className={tdClass}>{formatAttempt(source.last_success)}</td>
<td className={`${tdClass} tabular-nums`}>{source.domains}</td>
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
<td className={tdClass}>
{source.last_error === "" ? (
<span {...stylex.props(styles.absent)}></span>
<span className="text-zinc-400"></span>
) : (
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
<span className="text-red-600 dark:text-red-400">{source.last_error}</span>
)}
</td>
</tr>
+60 -76
View File
@@ -1,12 +1,9 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -15,34 +12,7 @@ interface Props {
onClose: () => void;
}
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
@@ -52,50 +22,64 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig();
return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit client ${client.ip}`}
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
>
<label {...stylex.props(styles.fieldLabel)}>
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/>
</label>
<Select
label="Group"
variant="compactField"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
</div>
</form>
</Dialog>
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<label className="block text-sm font-medium">
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={dialogInputClass}
autoFocus
/>
</label>
<label className="block text-sm font-medium">
Group
<select
value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))}
className={dialogInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
</form>
</div>
</div>
);
}
+5 -20
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -106,25 +106,10 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
// The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(dialog).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
});
test("prefix editor starts clean and dirties on add", async () => {
+34 -124
View File
@@ -1,16 +1,16 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
@@ -18,83 +18,6 @@ import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -107,46 +30,46 @@ export default function ClientsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
<h1 className="text-2xl font-semibold">Clients</h1>
{clients.length === 0 ? (
<p {...stylex.props(styles.empty)}>
<p className="mt-4 text-zinc-500">
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm">
<thead>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
<th className={cellClass}>IP</th>
<th className={cellClass}>Name</th>
<th className={cellClass}>Group</th>
<th className={cellClass}>First seen</th>
<th className={cellClass}>Last seen</th>
<th className={cellClass}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name === "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
client.name
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
<td className={`${cellClass} font-mono`}>{client.ip}</td>
<td className={cellClass}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name}
{client.hand_edited && (
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
edited
</span>
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
<td className={cellClass}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}>
{confirmingId === client.id ? (
<span {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-zinc-500">
Deleted clients re-materialize on their next DNS query.
</span>
<button
@@ -155,34 +78,26 @@ export default function ClientsPage() {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
{...stylex.props(shared.smallButton, shared.focusRing)}
className={smallButtonClass}
>
Cancel
</button>
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
<span className="inline-flex gap-2">
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${smallButtonClass} disabled:opacity-50`}
>
Edit
</button>
@@ -195,12 +110,7 @@ export default function ClientsPage() {
? DECLARED_CLIENT_NOTE
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
+26 -91
View File
@@ -1,14 +1,11 @@
import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -16,72 +13,6 @@ interface Props {
groups: Group[];
}
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -90,7 +21,6 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
const problem = firstProblem(state.rows);
@@ -102,18 +32,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
<section className="mt-10">
<h2 className="text-xl font-semibold">Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500">
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
{state.rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
) : (
<ul {...stylex.props(styles.rows)}>
<ul className="mt-4 space-y-2">
{state.rows.map((row, index) => (
<li key={index} {...stylex.props(styles.row)}>
<li key={index} className="flex flex-wrap items-center gap-2">
<input
type="text"
aria-label={`Prefix ${index + 1}`}
@@ -122,17 +52,22 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
className={`${smallInputClass} w-52`}
/>
<Select
<select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
}
options={groupOptions}
/>
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
<input
type="text"
inputMode="numeric"
@@ -142,12 +77,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
className={`${smallInputClass} w-20`}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
{...stylex.props(styles.removeButton, shared.focusRing)}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
>
Remove
</button>
@@ -156,16 +91,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}>
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
{...stylex.props(shared.button, shared.focusRing)}
className={buttonClass}
>
Add prefix
</button>
@@ -174,7 +109,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
Save prefixes
</button>
@@ -185,7 +120,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
{...stylex.props(shared.button, shared.focusRing)}
className={buttonClass}
>
Discard changes
</button>
+14 -94
View File
@@ -1,11 +1,9 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { focusRing } from "@/ui/classes";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
@@ -14,98 +12,20 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
<div role="group" aria-label="Period" className="flex gap-1">
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
option === period
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
}`}
>
{option}
</button>
@@ -115,7 +35,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
}
export default function DashboardPage() {
@@ -126,9 +46,9 @@ export default function DashboardPage() {
const upstreamHealth = useQuery(upstreamHealthQuery());
return (
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
@@ -142,9 +62,9 @@ export default function DashboardPage() {
<StatCards stats={stats.data} />
)}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
+20 -82
View File
@@ -1,95 +1,33 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
const STATE_CLASSES: Record<Health["disk"]["state"], string> = {
ok: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
};
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="flex items-center justify-between text-sm font-semibold">
Disk
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}>
{disk.state}
</span>
</h2>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-zinc-500">Free</dt>
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
<div className="flex justify-between">
<dt className="text-zinc-500">Database</dt>
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd>
</div>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
<div className="flex justify-between">
<dt className="text-zinc-500">Logs</dt>
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
+6 -31
View File
@@ -1,38 +1,13 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
const classes =
tone === "critical"
? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
return (
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}>
{children}
</p>
);
@@ -64,5 +39,5 @@ export default function HealthBanners({ health }: { health: Health }) {
);
}
if (banners.length === 0) return null;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
return <div className="space-y-2">{banners}</div>;
}
+5 -46
View File
@@ -1,49 +1,8 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
@@ -51,11 +10,11 @@ function percentOf(part: number, total: number): string | null {
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<dt className="text-sm text-zinc-500">{label}</dt>
<dd>
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
<span className="text-2xl font-semibold tabular-nums">{value}</span>
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>}
</dd>
</div>
);
@@ -63,7 +22,7 @@ function Card({ label, value, detail }: { label: string; value: string; detail?:
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl {...stylex.props(styles.grid)}>
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
+30 -131
View File
@@ -1,9 +1,6 @@
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -17,108 +14,6 @@ const SERIES = [
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
chartRoot: {
position: "relative",
},
tooltip: {
pointerEvents: "none",
position: "absolute",
top: "0.5rem",
zIndex: 10,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
tooltipLeft: (left: number) => ({ left, right: null }),
tooltipRight: (right: number) => ({ left: null, right }),
tooltipTitle: {
fontWeight: 500,
},
tooltipList: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
marginTop: "0.25rem",
},
tooltipRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
},
tooltipTerm: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
color: colors.textMuted,
},
swatch: {
display: "inline-block",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchSmall: {
width: "0.5rem",
height: "0.5rem",
},
swatchLarge: {
width: "0.625rem",
height: "0.625rem",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@@ -151,29 +46,28 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return (
<div {...stylex.props(styles.tooltip, side)}>
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
<dl {...stylex.props(styles.tooltipList)}>
<div {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
<div
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
<dl className="mt-1 space-y-0.5">
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
</div>
{SERIES.map((series) => (
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
<div key={series.key} className="flex items-center justify-between gap-4">
<dt className="flex items-center gap-1.5 text-zinc-500">
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
className="inline-block size-2 rounded-xs"
style={{ backgroundColor: series.color }}
/>
{series.label}
</dt>
<dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
</div>
))}
</dl>
@@ -188,7 +82,11 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return (
<div ref={containerRef} {...stylex.props(styles.empty)}>
<div
ref={containerRef}
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
style={{ height: CHART_HEIGHT }}
>
No queries in this period.
</div>
);
@@ -199,7 +97,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return (
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<div ref={containerRef} className="relative">
<svg
role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -215,14 +113,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
{...stylex.props(styles.gridLine)}
className="stroke-zinc-200 dark:stroke-zinc-800"
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
className="fill-zinc-500 text-[10px] tabular-nums"
>
{compact.format(tick.value)}
</text>
@@ -233,7 +131,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
{...stylex.props(styles.axisLine)}
className="stroke-zinc-300 dark:stroke-zinc-700"
/>
{layout.xTicks.map((tick) => (
<text
@@ -241,7 +139,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x}
y={baseline + 14}
textAnchor="middle"
{...stylex.props(styles.axisLabel)}
className="fill-zinc-500 text-[10px]"
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
@@ -259,8 +157,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width}
height={rect.height}
fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
})}
@@ -281,18 +179,19 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))}
</svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul {...stylex.props(styles.legend)}>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
{SERIES.map((series) => (
<li key={series.key} {...stylex.props(styles.legendItem)}>
<li key={series.key} className="flex items-center gap-1.5">
<span
aria-hidden="true"
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
className="inline-block size-2.5 rounded-xs"
style={{ backgroundColor: series.color }}
/>
{series.label}
</li>
))}
</ul>
<table {...stylex.props(shared.srOnly)}>
<table className="sr-only">
<caption>Queries per time bucket</caption>
<thead>
<tr>
@@ -1,150 +1,64 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
const bad = value === badValue;
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>;
}
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold">
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
<span className="text-xs font-normal text-zinc-500 tabular-nums">
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className="overflow-x-auto">
<table className="mt-2 w-full text-sm">
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800">
<th scope="col" className="px-4 py-2 font-medium">
URL
</th>
<th scope="col" {...stylex.props(styles.th)}>
<th scope="col" className="px-4 py-2 font-medium">
Enabled
</th>
<th scope="col" {...stylex.props(styles.th)}>
<th scope="col" className="px-4 py-2 font-medium">
Available
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
<th scope="col" className="px-4 py-2 text-right font-medium">
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
<th scope="col" className="px-4 py-2 text-right font-medium">
Success rate
</th>
<th scope="col" {...stylex.props(styles.th)}>
<th scope="col" className="px-4 py-2 font-medium">
Last error
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => (
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
<td {...stylex.props(styles.cell)}>
<tr
key={upstream.url}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50"
>
<td className="px-4 py-2 font-mono text-xs">{upstream.url}</td>
<td className="px-4 py-2">
<YesNo value={upstream.enabled} badValue={false} />
</td>
<td {...stylex.props(styles.cell)}>
<td className="px-4 py-2">
<YesNo value={upstream.available} badValue={false} />
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{upstream.total_failures}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
<td className="px-4 py-2 text-right tabular-nums">{upstream.total_failures}</td>
<td className="px-4 py-2 text-right tabular-nums">
{(upstream.success_rate * 100).toFixed(1)}%
</td>
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{upstream.last_error || "—"}
</td>
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td>
</tr>
))}
</tbody>
+14 -45
View File
@@ -1,12 +1,10 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -14,35 +12,6 @@ interface Props {
blocklists: Blocklist[];
}
const styles = stylex.create({
note: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
marginTop: "0.75rem",
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
gap: "0.5rem",
},
});
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId));
@@ -52,7 +21,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isPending) {
return (
<p role="status" {...stylex.props(styles.note)}>
<p role="status" className="mt-3 text-sm text-zinc-500">
Loading sources
</p>
);
@@ -60,23 +29,27 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
return (
<p className="mt-3 text-sm text-zinc-500">
No blocklist sources exist yet add them on the Blocklists page.
</p>
);
}
const current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data);
return (
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
<div className="mt-3">
<ul className="space-y-1">
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label {...stylex.props(styles.checkboxLabel)}>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
{blocklist.name}
</label>
@@ -84,7 +57,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
))}
</ul>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}>
<div className="mt-3 flex gap-2">
<button
type="button"
disabled={!dirty || mutation.isPending || readOnly}
@@ -92,16 +65,12 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
Save sources
</button>
{dirty && (
<button
type="button"
onClick={() => setSelected(null)}
{...stylex.props(shared.button, shared.focusRing)}
>
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
Discard
</button>
)}
+24 -111
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
blocklistsQuery,
groupCreateMutation,
@@ -11,85 +10,13 @@ import {
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
createForm: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
fieldLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
list: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
row: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
rowControls: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
renameForm: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
name: {
fontWeight: 500,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
actions: {
marginLeft: "auto",
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
groupButton: {
opacity: { default: 1, ":disabled": 0.5 },
},
destructive: {
color: colors.danger,
},
lockNote: {
marginTop: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -101,9 +28,9 @@ export default function GroupsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Groups</h1>
<h1 className="text-2xl font-semibold">Groups</h1>
<form
{...stylex.props(styles.createForm)}
className="mt-4 flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
@@ -111,7 +38,7 @@ export default function GroupsPage() {
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
<label className="text-sm font-medium" htmlFor="new-group-name">
New group
</label>
<input
@@ -120,19 +47,19 @@ export default function GroupsPage() {
value={newName}
onChange={(event) => setNewName(event.target.value)}
disabled={readOnly}
{...stylex.props(shared.smallInput, shared.focusRing)}
className={smallInputClass}
/>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
Create
</button>
</form>
<InlineError error={createMutation.error} />
<ul {...stylex.props(styles.list)}>
<ul className="mt-6 space-y-4">
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
@@ -154,11 +81,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li {...stylex.props(styles.row)}>
<div {...stylex.props(styles.rowControls)}>
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
<div className="flex flex-wrap items-center gap-3">
{renaming ? (
<form
{...stylex.props(styles.renameForm)}
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
@@ -174,14 +101,14 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
className={smallInputClass}
autoFocus
/>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
className={groupButtonClass}
>
Save
</button>
@@ -191,21 +118,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
className={groupButtonClass}
>
Cancel
</button>
</form>
) : (
<span {...stylex.props(styles.name)}>{group.name}</span>
<span className="font-medium">{group.name}</span>
)}
<label {...stylex.props(styles.checkboxLabel)}>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.focusRing)}
className={focusRing}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
@@ -215,12 +142,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
/>
Safe search
</label>
<span {...stylex.props(styles.actions)}>
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
className={groupButtonClass}
>
Sources
</button>
@@ -233,7 +160,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
className={groupButtonClass}
>
Rename
</button>
@@ -246,20 +173,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false);
deleteMutation.mutate(group.id);
}}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
Cancel
</button>
</>
@@ -269,19 +187,14 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
+1 -7
View File
@@ -50,14 +50,8 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
const blockedRow = screen.getByText("ads.example").closest("tr");
const plainRow = screen.getByText("ok.example").closest("tr");
const blockedClasses = new Set(blockedRow?.className.split(" "));
const plainClasses = plainRow?.className.split(" ") ?? [];
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
expect(blockedRow?.className).toContain("bg-red-50");
const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze);
+33 -175
View File
@@ -1,164 +1,20 @@
import * as stylex from "@stylexjs/stylex";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
toolbar: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
},
pillCapped: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
},
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
const toolbarButtonClass = `${buttonClass} font-medium`;
function StatusPill({ status }: { status: StreamStatus }) {
const label = PILL_LABELS[status];
const styles: Record<StreamStatus, [string, string]> = {
connecting: ["Connecting…", "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"],
open: ["Live", "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"],
retrying: ["Reconnecting…", "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"],
capped: ["Disconnected", "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"],
};
const [label, tone] = styles[status];
return (
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}>
{label}
</span>
);
@@ -171,56 +27,58 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
return (
<section>
<div {...stylex.props(styles.toolbar)}>
<h1 {...stylex.props(styles.heading)}>Live</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">Live</h1>
<StatusPill status={live.status} />
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
className={toolbarButtonClass}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
<p className="mt-2 text-sm text-zinc-500" role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept).
</p>
)}
{live.missed !== null && (
<div role="status" {...stylex.props(styles.resumed)}>
<div
role="status"
className="mt-3 flex items-center gap-3 rounded border border-blue-300 bg-blue-50 px-3 py-2 text-sm text-blue-800 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-200"
>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span>
<button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
<button type="button" onClick={live.dismissMissed} className="font-medium underline">
Dismiss
</button>
</div>
)}
{live.resyncFailed && (
<p role="alert" {...stylex.props(styles.failureNote)}>
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300">
Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p>
)}
{live.status === "capped" && (
<div role="alert" {...stylex.props(styles.cappedBox)}>
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
<p {...stylex.props(styles.cappedDetail)}>
<div
role="alert"
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
>
<h2 className="font-semibold text-red-800 dark:text-red-200">Live stream unavailable</h2>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
<button type="button" onClick={live.retry} className={retryButtonClass}>
Retry
</button>
</div>
@@ -228,25 +86,25 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
<p className="mt-6 text-zinc-500">
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm">
<QueryTableHead />
<tbody>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{live.rows.map((row) => (
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}>
<QueryCells row={row} />
</tr>
))}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
<p className="mt-3 text-sm text-zinc-500">
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept).
</p>
+2 -87
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -24,12 +24,6 @@ function createFetchMock() {
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
@@ -71,34 +65,13 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -110,61 +83,3 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+69 -17
View File
@@ -1,27 +1,79 @@
import * as stylex from "@stylexjs/stylex";
import { useState } from "react";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import Tabs from "@/ui/Tabs";
import { focusRing } from "@/ui/classes";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
type Tab = "records" | "zones";
function TabButton({
id,
controls,
selected,
onClick,
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return (
<section>
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
<h1 className="text-2xl font-semibold">Local DNS</h1>
<div
role="tablist"
aria-label="Local DNS"
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
>
<TabButton
id="tab-records"
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
</section>
);
}
+58 -146
View File
@@ -1,6 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
@@ -9,86 +8,18 @@ import {
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function RecordForm({
initial,
@@ -119,12 +50,10 @@ function RecordForm({
}
return (
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<div>
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
Name
</label>
<input
@@ -133,19 +62,28 @@ function RecordForm({
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<Select
label="Type"
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
Type
</label>
<select
id={`${id}-rtype`}
value={rtype}
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
className={inputClass}
>
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
Value
</label>
<input
@@ -156,11 +94,11 @@ function RecordForm({
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
TTL (seconds)
</label>
<input
@@ -170,19 +108,19 @@ function RecordForm({
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<div className="flex gap-2">
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
<button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel
</button>
</div>
@@ -193,19 +131,10 @@ function RecordForm({
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
LocalRecord,
LocalRecordInput
>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
@@ -215,14 +144,14 @@ export default function RecordsTab() {
return (
<div>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
className={largePrimaryButtonClass}
>
Add record
</button>
@@ -237,37 +166,37 @@ export default function RecordsTab() {
onCancel={closeForm}
/>
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" className="py-2 pr-4 font-medium">
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" className="py-2 pr-4 font-medium">
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" className="py-2 pr-4 font-medium">
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
<td colSpan={5} className="py-4 text-zinc-500">
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
@@ -281,21 +210,17 @@ export default function RecordsTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<td className="py-2 pr-4 font-mono">{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
@@ -304,12 +229,7 @@ export default function RecordsTab() {
onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
@@ -321,14 +241,6 @@ export default function RecordsTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+38 -133
View File
@@ -1,6 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
@@ -9,83 +8,17 @@ import {
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function ZoneForm({
initial,
busy,
@@ -111,12 +44,10 @@ function ZoneForm({
}
return (
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<div>
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
Zone
</label>
<input
@@ -125,11 +56,11 @@ function ZoneForm({
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
Resolver
</label>
<input
@@ -138,19 +69,19 @@ function ZoneForm({
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div {...stylex.props(styles.buttonRow)}>
<div className="flex gap-2">
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
className={largePrimaryButtonClass}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
<button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel
</button>
</div>
@@ -161,19 +92,10 @@ function ZoneForm({
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
ForwardZone,
ForwardZoneInput
>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
@@ -183,8 +105,8 @@ export default function ZonesTab() {
return (
<div>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button
@@ -192,7 +114,7 @@ export default function ZonesTab() {
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
className={largePrimaryButtonClass}
>
Add zone
</button>
@@ -207,31 +129,31 @@ export default function ZonesTab() {
onCancel={closeForm}
/>
)}
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<thead>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" className="py-2 pr-4 font-medium">
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
<td colSpan={3} className="py-4 text-zinc-500">
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
@@ -245,19 +167,15 @@ export default function ZonesTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${rowButtonClass} disabled:opacity-50`}
>
Edit
</button>
@@ -266,12 +184,7 @@ export default function ZonesTab() {
onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
>
Delete
</button>
@@ -283,14 +196,6 @@ export default function ZonesTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+2 -4
View File
@@ -88,8 +88,6 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
test("defaults the group select to the default group (id 1)", async () => {
renderPage();
// A RAC Select names its trigger with the current value and then the label, so
// the selected group's name is the only thing the trigger shows.
const trigger = await screen.findByRole("button", { name: /Group$/ });
expect(trigger.textContent).toContain("default");
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
expect(select.value).toBe("1");
});
+44 -161
View File
@@ -1,15 +1,10 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
interface Submitted {
domain: string;
@@ -18,123 +13,10 @@ interface Submitted {
interface Verdict {
label: string;
tone: "local" | "blocked" | "forwarded" | "allowed";
className: string;
description: string;
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
color: colors.textMuted,
},
form: {
marginTop: "1.5rem",
display: "flex",
maxWidth: "42rem",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.75rem",
},
domainField: {
minWidth: "14rem",
flexGrow: 1,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
note: {
marginTop: "1.5rem",
color: colors.textMuted,
},
error: {
marginTop: "1.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
banner: {
borderStartStartRadius: "0.25rem",
borderStartEndRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
local: {
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
},
blocked: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
forwarded: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
allowed: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
},
verdictLabel: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
verdictDescription: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
details: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
detailRow: {
display: "flex",
gap: "1rem",
paddingBlock: "0.5rem",
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
detailTerm: {
width: "10rem",
flexShrink: 0,
color: colors.textMuted,
},
detailValue: {
minWidth: 0,
overflowWrap: "break-word",
},
sourceLink: {
color: colors.primaryOnSurface,
textDecorationLine: "underline",
},
});
function toneStyle(tone: Verdict["tone"]) {
if (tone === "local") return styles.local;
if (tone === "blocked") return styles.blocked;
return tone === "forwarded" ? styles.forwarded : styles.allowed;
}
/**
* Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then
@@ -144,27 +26,27 @@ export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) {
return {
label: "Local answer",
tone: "local",
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
description: "A local record answers this name directly.",
};
}
if (result.blocked) {
return {
label: "Blocked",
tone: "blocked",
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
description: "Queries for this name get a blocked response.",
};
}
if (result.forward_zone !== null) {
return {
label: "Forwarded",
tone: "forwarded",
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
};
}
return {
label: "Allowed",
tone: "allowed",
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
description: "Queries resolve through the upstream pool.",
};
}
@@ -186,9 +68,9 @@ function errorMessage(error: unknown): string {
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div {...stylex.props(styles.detailRow)}>
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
<div className="flex gap-4 py-2">
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt>
<dd className="min-w-0 break-words">{children}</dd>
</div>
);
}
@@ -198,30 +80,26 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return (
<div {...stylex.props(styles.card)}>
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800">
<div className={`rounded-t px-4 py-3 ${verdict.className}`}>
<h2 className="text-lg font-semibold">{verdict.label}</h2>
<p className="text-sm">{verdict.description}</p>
</div>
<dl {...stylex.props(styles.details)}>
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900">
<DetailRow label="Domain">
<span {...stylex.props(shared.mono)}>{result.domain}</span>
<span className="font-mono">{result.domain}</span>
</DetailRow>
<DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone">
{result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"}
</DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason">
<span {...stylex.props(shared.mono)}>{result.reason}</span>
<span className="font-mono">{result.reason}</span>
</DetailRow>
<DetailRow label="Matched pattern">
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"}
</DetailRow>
<DetailRow label="Blocklist source">
{result.source_url !== null ? (
@@ -229,7 +107,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
href={result.source_url}
target="_blank"
rel="noreferrer"
{...stylex.props(styles.sourceLink, shared.focusRing)}
className="text-blue-600 underline dark:text-blue-400"
>
{result.source_url}
</a>
@@ -239,7 +117,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
</DetailRow>
<DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? (
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
<span className="font-mono">{result.safe_search_rewrite}</span>
) : (
"—"
)}
@@ -275,13 +153,13 @@ export default function LookupPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
<p {...stylex.props(styles.intro)}>
<h1 className="text-2xl font-semibold">Lookup</h1>
<p className="mt-2 text-zinc-500">
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
</p>
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div {...stylex.props(styles.domainField)}>
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3">
<div className="min-w-56 grow">
<label htmlFor="lookup-domain" className="block text-sm font-medium">
Domain
</label>
<input
@@ -290,28 +168,33 @@ export default function LookupPage() {
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<label htmlFor="lookup-group" className="block text-sm font-medium">
Group
</label>
<select
id="lookup-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
Look up
</button>
</form>
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up</p>}
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up</p>}
{!lookup.isFetching && lookup.isError && (
<p role="alert" {...stylex.props(styles.error)}>
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400">
{errorMessage(lookup.error)}
</p>
)}
+15 -78
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, insetFocusRing } from "@/ui/classes";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
@@ -13,72 +11,6 @@ const DURATIONS = [
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens tuned against `warnSurface` do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
@@ -103,6 +35,8 @@ function useNowSeconds(active: boolean): number {
return now;
}
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
@@ -118,7 +52,7 @@ export default function PauseWidget() {
if (data === undefined) {
return (
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
<button type="button" disabled className={triggerButtonClass}>
Pause
</button>
);
@@ -126,16 +60,16 @@ export default function PauseWidget() {
if (data.paused) {
return (
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-amber-700 dark:text-amber-400">
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
className={triggerButtonClass}
>
Resume
</button>
@@ -147,7 +81,7 @@ export default function PauseWidget() {
return (
<div
{...stylex.props(styles.anchor)}
className="relative"
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
@@ -158,12 +92,15 @@ export default function PauseWidget() {
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
className={triggerButtonClass}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
<div
id="pause-menu"
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
@@ -174,7 +111,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
>
{label}
</button>
+63 -193
View File
@@ -1,146 +1,14 @@
import { useState, type FormEvent } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types";
import { qtypeName } from "./qtype";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes";
const DARK = "@media (prefers-color-scheme: dark)";
const STATUS_OPTIONS = [
{ value: "any", label: "All" },
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, five from `lg`, as before. */
filterGrid: {
marginTop: "1rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
},
filterLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
filterInput: {
marginTop: "0.25rem",
width: "100%",
},
buttonRow: {
display: "flex",
alignItems: "flex-end",
gap: "0.5rem",
gridColumn: {
default: null,
"@media (min-width: 640px)": "span 2 / span 2",
"@media (min-width: 1024px)": "span 5 / span 5",
},
},
toolbarButton: {
fontWeight: 500,
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The header tint is a shade off the ground in each scheme, not a token role. */
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
textAlign: "left",
},
th: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textSecondary,
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
nowrap: {
whiteSpace: "nowrap",
},
breakAll: {
wordBreak: "break-all",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
blockedWrap: {
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
},
blockedBadge: {
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
footer: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
moreError: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
});
const filterInputClass = `mt-1 w-full ${smallInputClass}`;
const toolbarButtonClass = `${buttonClass} font-medium`;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -153,11 +21,13 @@ function datetimeLocalToUnix(value: string): number | undefined {
}
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
if (!row.blocked) return <span {...stylex.props(styles.muted)}></span>;
if (!row.blocked) return <span className="text-zinc-400"></span>;
return (
<span {...stylex.props(styles.blockedWrap)}>
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
<span className="inline-flex items-center gap-1.5">
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
Blocked
</span>
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
</span>
);
}
@@ -165,38 +35,37 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell)}>
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td>
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td>
<td className="px-3 py-2">
<BlockedCell row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
<td className="px-3 py-2 whitespace-nowrap tabular-nums">
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<td className="px-3 py-2 whitespace-nowrap">
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
{row.upstream === "" ? "—" : row.upstream}
</td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td>
</>
);
}
export function QueryTableHead() {
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
return (
<thead {...stylex.props(styles.head)}>
<thead className="bg-zinc-50 text-left dark:bg-zinc-900">
<tr>
<th {...stylex.props(styles.th)}>Time</th>
<th {...stylex.props(styles.th)}>Domain</th>
<th {...stylex.props(styles.th)}>Client</th>
<th {...stylex.props(styles.th)}>Type</th>
<th {...stylex.props(styles.th)}>Status</th>
<th {...stylex.props(styles.th)}>Response</th>
<th {...stylex.props(styles.th)}>Cache</th>
<th {...stylex.props(styles.th)}>Upstream</th>
<th className={th}>Time</th>
<th className={th}>Domain</th>
<th className={th}>Client</th>
<th className={th}>Type</th>
<th className={th}>Status</th>
<th className={th}>Response</th>
<th className={th}>Cache</th>
<th className={th}>Upstream</th>
</tr>
</thead>
);
@@ -255,65 +124,66 @@ export default function QueryLogPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
<h1 className="text-2xl font-semibold">Query Log</h1>
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
<label {...stylex.props(styles.filterLabel)}>
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<label className="block text-sm">
Domain contains
<input
type="text"
value={domain}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
className={filterInputClass}
/>
</label>
<label {...stylex.props(styles.filterLabel)}>
<label className="block text-sm">
Client (exact)
<input
type="text"
value={client}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
className={filterInputClass}
/>
</label>
<Select
variant="compactField"
label="Status"
value={blocked}
onChange={setBlocked}
options={STATUS_OPTIONS}
/>
<label {...stylex.props(styles.filterLabel)}>
<label className="block text-sm">
Status
<select
value={blocked}
onChange={(event) => setBlocked(event.target.value)}
className={filterInputClass}
>
<option value="any">All</option>
<option value="blocked">Blocked only</option>
<option value="allowed">Allowed only</option>
</select>
</label>
<label className="block text-sm">
Since
<input
type="datetime-local"
value={since}
onChange={(event) => setSince(event.target.value)}
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
className={filterInputClass}
/>
</label>
<label {...stylex.props(styles.filterLabel)}>
<label className="block text-sm">
Until
<input
type="datetime-local"
value={until}
onChange={(event) => setUntil(event.target.value)}
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
className={filterInputClass}
/>
</label>
<div {...stylex.props(styles.buttonRow)}>
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
<button type="submit" className={toolbarButtonClass}>
Apply filters
</button>
<button
type="button"
onClick={clearFilters}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
<button type="button" onClick={clearFilters} className={toolbarButtonClass}>
Clear
</button>
{base.isFetching && (
<span {...stylex.props(styles.note)} role="status">
<span className="text-sm text-zinc-500" role="status">
Loading
</span>
)}
@@ -321,29 +191,29 @@ export default function QueryLogPage() {
</form>
{base.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
<p className="mt-6 animate-pulse text-zinc-500" role="status">
Loading query log
</p>
) : rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>
<p className="mt-6 text-zinc-500">
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm">
<QueryTableHead />
<tbody>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}>
<tr key={row.id}>
<QueryCells row={row} />
</tr>
))}
</tbody>
</table>
</div>
<div {...stylex.props(styles.footer)}>
<p {...stylex.props(styles.note)}>
<div className="mt-3 flex items-center gap-3">
<p className="text-sm text-zinc-500">
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
@@ -352,14 +222,14 @@ export default function QueryLogPage() {
type="button"
onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
className={toolbarButtonClass}
>
{base.isFetchingNextPage ? "Loading…" : "Load more"}
</button>
)}
</div>
{moreError !== null && (
<p role="alert" {...stylex.props(styles.moreError)}>
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300">
Failed to load more: {moreError}
</p>
)}
+11 -111
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -33,30 +33,17 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
let posted: { pattern: string; kind: string }[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
posted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
headers: { "content-type": "application/json", "Retry-After": "5" },
@@ -88,25 +75,6 @@ function renderRulesRoute() {
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
@@ -119,59 +87,14 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
test("the kind selector can select the regex option, not only list it", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
const regex = options.find((option) => option.textContent === "regex");
expect(regex).toBeTruthy();
fireEvent.click(regex!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(trigger("Kind").textContent).toContain("regex");
});
async function selectKind(label: string): Promise<void> {
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
fireEvent.click(options.find((option) => option.textContent === label)!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
// A regex is stored and matched byte for byte, so whitespace inside it is data,
// not slop the UI may drop. Exact and wildcard are normalized server-side.
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
await selectKind("regex");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(1));
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
await selectKind("exact");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(2));
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const input = screen.getByLabelText("Pattern");
expect(input.getAttribute("autocapitalize")).toBe("none");
expect(input.getAttribute("autocorrect")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -193,8 +116,9 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
});
test("the group select falls back to the first group when the default is absent", async () => {
@@ -205,29 +129,5 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
});
+94 -158
View File
@@ -1,86 +1,13 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -93,65 +20,68 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// A regex pattern is stored and matched byte for byte, so the UI must not
// edit it: trimming here would make a UI-created rule differ from the same
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
// so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
create.mutate(
{ group_id: groupId, pattern: pattern.trim(), kind, action },
{ onSuccess: () => setPattern("") },
);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
function deleteRule(rule: Rule) {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
remove.mutate(rule.id);
}
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
<h1 className="text-2xl font-semibold">Rules</h1>
{rules.length === 0 ? (
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<th className={thClass}>Pattern</th>
<th className={thClass}>Kind</th>
<th className={thClass}>Action</th>
<th className={thClass}>Group</th>
<th className={thClass}>Created</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td>
<td className={tdClass}>
<span
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{rule.action}
</span>
</td>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<td className={tdClass}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={tdClass}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
onClick={() => deleteRule(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
className={dangerLinkButtonClass}
>
Delete
</button>
@@ -164,10 +94,10 @@ export default function RulesPage() {
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
<h2 className="text-lg font-medium">Create rule</h2>
<div>
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="rule-pattern" className="block text-sm font-medium">
Pattern
</label>
<input
@@ -176,61 +106,67 @@ export default function RulesPage() {
required
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)}
placeholder="ads.example.com or *.example.com"
className={inputClass}
/>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label htmlFor="rule-kind" className="block text-sm font-medium">
Kind
</label>
<select
id="rule-kind"
value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)}
className={inputClass}
>
<option value="exact">exact</option>
<option value="wildcard">wildcard</option>
</select>
</div>
<div>
<label htmlFor="rule-action" className="block text-sm font-medium">
Action
</label>
<select
id="rule-action"
value={action}
onChange={(event) => setAction(event.target.value as RuleAction)}
className={inputClass}
>
<option value="allow">allow</option>
<option value="block">block</option>
</select>
</div>
<div>
<label htmlFor="rule-group" className="block text-sm font-medium">
Group
</label>
<select
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
</div>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
@@ -1,22 +1,5 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
const styles = stylex.create({
banner: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
/**
* File authority is a standing condition, not an event, so this banner has no
* dismiss button: it stays up for as long as the process runs from a file.
@@ -25,9 +8,12 @@ export default function ReadOnlyConfigBanner() {
const authority = useAuthority();
if (authority?.mode !== "managed_file") return null;
return (
<div role="status" {...stylex.props(styles.banner)}>
Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
restart nxdns to change it; the server rejects edits made here.
<div
role="status"
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
nxdns to change it; the server rejects edits made here.
</div>
);
}
+11 -38
View File
@@ -1,47 +1,20 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
const styles = stylex.create({
banner: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
message: {
flex: 1,
},
dismiss: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.warnBorderStrong,
backgroundColor: "transparent",
color: "inherit",
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
},
});
import { focusRing } from "@/ui/classes";
export default function RestartBanner() {
const raised = useRestartBanner();
if (!raised) return null;
return (
<div role="status" {...stylex.props(styles.banner)}>
<span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
<button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
<div
role="status"
className="flex items-center gap-3 border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
<span className="flex-1">Changes saved. Restart nxdns to apply.</span>
<button
type="button"
onClick={dismissRestartBanner}
className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
>
Dismiss
</button>
</div>
@@ -130,11 +130,7 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
test("enum and boolean fields diff as their own types", async () => {
await renderPage();
const logging = screen.getByRole("group", { name: "Logging" });
// A RAC Select names its trigger with the current value and then the label, and
// carries the options only while the listbox is open.
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } });
fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1));
+48 -154
View File
@@ -1,15 +1,12 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -142,121 +139,8 @@ const SECTIONS: readonly AnySectionDef[] = [
}),
];
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
form: {
marginTop: "1rem",
maxWidth: "48rem",
},
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
sections: {
display: "flex",
flexDirection: "column",
gap: "1.5rem",
borderStyle: "none",
margin: 0,
padding: 0,
},
section: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
legend: {
paddingInline: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
/** One column on a phone, two from `sm`, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(37% 0.013 285.805)",
[DARK]: "oklch(87.1% 0.006 286.286)",
},
},
checkboxRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
fieldInput: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingInline: "0.5rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
derived: {
color: colors.textMuted,
},
/** Both notices span the whole grid so the wrapped sentence stays readable. */
spanRow: {
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
passwordNotice: {
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
},
mismatchNotice: {
color: colors.danger,
},
submitRow: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
save: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
backgroundColor: {
default: colors.primary,
":disabled": "oklch(87.1% 0.006 286.286)",
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
},
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
},
});
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
function FieldRow({
section,
@@ -272,15 +156,15 @@ function FieldRow({
const id = `${section}.${def.key}`;
if (def.kind === "boolean") {
return (
<div {...stylex.props(styles.checkboxRow)}>
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
<label htmlFor={id} {...stylex.props(styles.label)}>
<label htmlFor={id} className={LABEL_CLASS}>
{def.key}
</label>
</div>
@@ -288,20 +172,30 @@ function FieldRow({
}
if (Array.isArray(def.kind)) {
return (
<Select
variant="inline"
label={def.key}
value={value as string}
onChange={onChange}
options={def.kind.map((option) => ({ value: option, label: option }))}
/>
<div className="flex flex-col gap-1">
<label htmlFor={id} className={LABEL_CLASS}>
{def.key}
</label>
<select
id={id}
value={value as string}
onChange={(e) => onChange(e.target.value)}
className={fieldInputClass}
>
{def.kind.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
);
}
if (def.kind === "number") {
const numeric = value as number;
return (
<div {...stylex.props(styles.field)}>
<label htmlFor={id} {...stylex.props(styles.label)}>
<div className="flex flex-col gap-1">
<label htmlFor={id} className={LABEL_CLASS}>
{def.key}
</label>
<input
@@ -309,14 +203,14 @@ function FieldRow({
type="number"
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
className={fieldInputClass}
/>
</div>
);
}
return (
<div {...stylex.props(styles.field)}>
<label htmlFor={id} {...stylex.props(styles.label)}>
<div className="flex flex-col gap-1">
<label htmlFor={id} className={LABEL_CLASS}>
{def.key}
</label>
<input
@@ -324,7 +218,7 @@ function FieldRow({
type="text"
value={value as string}
onChange={(e) => onChange(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
className={fieldInputClass}
/>
</div>
);
@@ -376,16 +270,16 @@ export default function SettingsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Settings</h1>
<p {...stylex.props(styles.intro)}>
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="mt-1 text-sm text-zinc-500">
Changes are validated as a whole; every setting requires a restart to take effect.
</p>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
{SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} {...stylex.props(styles.section)}>
<legend {...stylex.props(styles.legend)}>{title}</legend>
<div {...stylex.props(styles.fieldGrid)}>
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
<legend className="px-1 text-sm font-semibold">{title}</legend>
<div className="grid gap-3 sm:grid-cols-2">
{(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow
key={def.key}
@@ -397,12 +291,12 @@ export default function SettingsPage() {
))}
{section === "web" && (
<>
<p {...stylex.props(styles.label)}>
<p className={LABEL_CLASS}>
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
<span {...stylex.props(styles.derived)}>(derived, read-only)</span>
<span className="text-zinc-500">(derived, read-only)</span>
</p>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password" {...stylex.props(styles.label)}>
<div className="flex flex-col gap-1">
<label htmlFor="web.password" className={LABEL_CLASS}>
password
</label>
<input
@@ -411,11 +305,11 @@ export default function SettingsPage() {
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
className={fieldInputClass}
/>
</div>
<div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
<div className="flex flex-col gap-1">
<label htmlFor="web.password_confirm" className={LABEL_CLASS}>
confirm password
</label>
<input
@@ -424,17 +318,17 @@ export default function SettingsPage() {
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
className={fieldInputClass}
/>
</div>
{password !== "" && (
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400">
Changing the password signs out every session; you will be asked to log
in again.
</p>
)}
{passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400">
Passwords do not match.
</p>
)}
@@ -443,12 +337,12 @@ export default function SettingsPage() {
</div>
</fieldset>
))}
<div {...stylex.props(styles.submitRow)}>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(styles.save, shared.focusRing)}
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
>
{mutation.isPending ? "Saving…" : "Save"}
</button>
+15 -64
View File
@@ -1,9 +1,7 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
@@ -18,49 +16,6 @@ interface UpstreamFormProps {
onCancel?: () => void;
}
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
hint: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
actions: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancelButton: {
fontWeight: 500,
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
@@ -90,10 +45,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
}
return (
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div>
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="upstream-url" className="block text-sm font-medium">
URL
</label>
<input
@@ -103,11 +58,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="upstream-priority" className="block text-sm font-medium">
Priority
</label>
<input
@@ -116,11 +71,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
min={0}
value={priority}
onChange={(event) => setPriority(event.target.value)}
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium">
TLS name
</label>
<input
@@ -129,36 +84,32 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={tlsName}
onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one"
{...stylex.props(shared.input, shared.focusRing)}
className={inputClass}
/>
<p {...stylex.props(styles.hint)}>
<p className="mt-1 text-xs text-zinc-500">
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p>
</div>
<label {...stylex.props(styles.checkboxLabel)}>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
Enabled
</label>
<div {...stylex.props(styles.actions)}>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
className={primaryButtonClass}
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
>
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
Cancel
</button>
)}
@@ -134,29 +134,15 @@ test("toggling enabled resends the whole row", async () => {
await screen.findByRole("status");
});
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
test("delete asks for confirmation and skips the request when refused", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
return await screen.findByRole("alertdialog");
}
test("delete asks for confirmation and skips the request when cancelled", async () => {
await renderUpstreamsRoute();
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(calls).toHaveLength(0);
});
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
await renderUpstreamsRoute();
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1");
@@ -199,14 +185,14 @@ test("a 409 on toggle renders the last-enabled conflict above the form", async (
test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
});
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
+27 -89
View File
@@ -1,62 +1,17 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function UpstreamsPage() {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient));
@@ -84,10 +39,10 @@ export default function UpstreamsPage() {
);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
setPendingDelete(null);
function deleteUpstream(u: Upstream) {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) {
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() });
}
}
const formError = editing === null ? create.error : save.error;
@@ -95,38 +50,38 @@ export default function UpstreamsPage() {
return (
<section>
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
<h1 className="text-2xl font-semibold">Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500">
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
</p>
{upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
) : (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
<th className={thClass}>URL</th>
<th className={thClass}>Priority</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>TLS name</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={u.url}>
<td className={tdClass}>
<span className="block max-w-72 truncate font-medium" title={u.url}>
{u.url}
</span>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
<td className={tdClass}>
<input
type="checkbox"
aria-label={`${u.url} enabled`}
@@ -134,31 +89,27 @@ export default function UpstreamsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
className={focusRing}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}>
<div className="flex gap-3">
<button
type="button"
onClick={() => setEditing(u)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => setPendingDelete(u)}
onClick={() => deleteUpstream(u)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
className={dangerLinkButtonClass}
>
Delete
</button>
@@ -181,19 +132,6 @@ export default function UpstreamsPage() {
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete upstream"
message={
pendingDelete === null
? ""
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More