43 Commits
Author SHA1 Message Date
mokhtar c428bc2398 ci: allow dispatching the gate set, a replaced runner pod kills the job it holds
Gates / frontend (push) Successful in 1m18s
Gates / test (push) Successful in 2m27s
Gates / test-aarch64 (push) Successful in 5m39s
Gates / package (push) Successful in 3m34s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 11m57s
Release / guard (push) Successful in 1m30s
Gates / frontend (push) Successful in 1m6s
Gates / test (push) Successful in 1m28s
Gates / test-aarch64 (push) Successful in 5m41s
Gates / package (push) Successful in 28s
Gates / container (push) Successful in 16s
Release / gates (push) Successful in 7m55s
Release / publish (push) Successful in 10m37s
2026-08-14 02:34:06 +02:00
mokhtar c7c1e21267 changelog: 0.0.2 releases today
Gates / frontend (push) Successful in 1m9s
Gates / test (push) Failing after 12m32s
Gates / test-aarch64 (push) Successful in 9m14s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 18s
CI / gates (push) Failing after 22m41s
2026-08-14 02:10:45 +02:00
mokhtar 21c5ce1f36 filter: separate the compiled bodies in the checksum, a name moving between them was invisible 2026-08-14 02:09:25 +02:00
mokhtar 1bce81eea0 milestone 24: persist and surface the unsupported-line count 2026-08-13 20:44:27 +02:00
mokhtar 21571e448e schema: collapse config.db to a single baseline, ddl_v1 stays editable until v0.1 2026-08-13 19:14:54 +02:00
mokhtar 2ab7c1f1de milestone 21: abp list exceptions and a regex rule kind 2026-08-13 19:14:47 +02:00
mokhtar b340521716 web tests: raise the async query budget, ci contention timed out a suspended page
Gates / test (push) Successful in 1m35s
Gates / test-aarch64 (push) Successful in 6m0s
Gates / frontend (push) Successful in 1m12s
Gates / package (push) Successful in 5m17s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 13m10s
2026-08-13 00:20:08 +02:00
mokhtar 13458a980e ci: run the frontend gate on the light runner
Gates / test (push) Successful in 1m37s
Gates / frontend (push) Failing after 3m23s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 6m2s
CI / gates (push) Failing after 7m41s
2026-08-12 23:46:07 +02:00
mokhtar e576704fad spec milestone 23: record the readme and conversion-order findings as closed by decision 2026-08-12 23:45:34 +02:00
mokhtar 4aaf6d3815 milestone 23: close the remaining codex findings
the vitest "something prevents Vite server from exiting" warning was
@stylexjs/unplugin, not our code: configureServer starts a 150ms polling
interval for HMR and clears it from server.httpServer's close event, which
vitest resolves to null in middleware mode. drop that one hook under VITEST
and keep the transform.

textMuted was swept rather than judged. measured against their grounds, the
39 flat text-zinc-500 sites went 4.12:1 -> 7.56:1 in dark mode, repairing a
WCAG AA failure, so that stands; the 4 already-adaptive sites went 7.40:1 ->
4.62:1 in light mode for nothing, so a textSecondary token restores their
original zinc-600/zinc-400 pair.

append the symbol families to the reset's font stack: Select renders U+25BE
and Segoe UI does not carry it.

correct two acceptance criteria that were literally false: both greps match
only comments.
2026-08-12 23:43:22 +02:00
mokhtar 0ea2e2905a milestone 23: restore the focus ring on select options, check layer order, close preflight gaps 2026-08-12 23:27:28 +02:00
mokhtar 9d5120cbad milestone 23: put the reset in a cascade layer, guard that it stays there 2026-08-12 23:06:26 +02:00
mokhtar 65f76d4427 milestone 23 s3: plan names stylex and react aria 2026-08-12 22:50:34 +02:00
mokhtar be12587b87 milestone 23 s3: remove tailwind, replace its preflight with an explicit reset 2026-08-12 22:50:25 +02:00
mokhtar 122044e6af milestone 23 s3: delete ui/classes.ts, its last importer is gone 2026-08-12 22:46:29 +02:00
mokhtar 0e9433d53b milestone 23 s3: convert features/queries/QueryLogPage.tsx to stylex 2026-08-12 22:45:53 +02:00
mokhtar ae9f7fb9f3 milestone 23 s3: convert features/lookup/LookupPage.tsx to stylex 2026-08-12 22:44:23 +02:00
mokhtar 1cd8f71a9b milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex 2026-08-12 22:42:11 +02:00
mokhtar 15c895e180 milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex 2026-08-12 22:40:50 +02:00
mokhtar 3b33d7afee milestone 23 s3: convert features/dashboard/UpstreamHealthTable.tsx to stylex 2026-08-12 22:38:15 +02:00
mokhtar f2b4582c0a milestone 23 s3: convert shell/AppShell.tsx to stylex 2026-08-12 22:37:16 +02:00
mokhtar 02bf1ca310 milestone 23 s3: convert features/live/LiveLogPage.tsx to stylex 2026-08-12 22:36:09 +02:00
mokhtar 4379d7599b milestone 23 s3: convert features/groups/GroupsPage.tsx to stylex 2026-08-12 22:33:58 +02:00
mokhtar 1246791fe1 milestone 23 s3: convert features/dashboard/DiskCard.tsx to stylex 2026-08-12 22:32:56 +02:00
mokhtar 2f3115117f milestone 23 s3: convert features/blocklists/SourceStatusSection.tsx to stylex 2026-08-12 22:31:14 +02:00
mokhtar e98896fc0a milestone 23 s3: convert features/dashboard/DashboardPage.tsx to stylex 2026-08-12 22:30:25 +02:00
mokhtar 1de608f9bc milestone 23 s3: convert auth/LoginPage.tsx to stylex 2026-08-12 22:28:56 +02:00
mokhtar 38d34453bb milestone 23 s3: convert features/groups/GroupSourcesEditor.tsx to stylex 2026-08-12 22:28:01 +02:00
mokhtar 368149a358 milestone 23 s3: convert features/blocklists/BlocklistForm.tsx to stylex 2026-08-12 22:27:00 +02:00
mokhtar ac0699faa1 milestone 23: amend two rulings the s2 review found the code in breach of 2026-08-12 22:26:42 +02:00
mokhtar d5b351d7fe milestone 23 s3: convert routes.tsx to stylex 2026-08-12 22:25:56 +02:00
mokhtar cddefa87e4 milestone 23 s3: convert features/pause/PauseWidget.tsx to stylex 2026-08-12 22:25:08 +02:00
mokhtar 9e12683acf milestone 23 s3: convert features/dashboard/StatCards.tsx to stylex 2026-08-12 22:23:16 +02:00
mokhtar 0fdbe25f7a milestone 23 s3: convert features/settings/RestartBanner.tsx to stylex 2026-08-12 22:21:53 +02:00
mokhtar 7e7358b3c2 milestone 23 s3: convert features/settings/ReadOnlyConfigBanner.tsx to stylex 2026-08-12 22:21:13 +02:00
mokhtar d5c4a8d978 milestone 23 s3: convert lib/InlineError.tsx to stylex 2026-08-12 22:19:59 +02:00
mokhtar 73bc180c67 milestone 23 s3: convert features/dashboard/HealthBanners.tsx to stylex 2026-08-12 22:18:23 +02:00
mokhtar 0f58966b31 milestone 23 s3: move srOnly into the shared style vocabulary 2026-08-12 22:16:58 +02:00
mokhtar bb39539fdf milestone 23: correct three comments the s1 review found inaccurate 2026-08-12 22:13:49 +02:00
mokhtar 01e455c8af milestone 23 s2: react aria primitives and their cluster 2026-08-12 22:12:18 +02:00
mokhtar 994bbf922c milestone 23 s1: stylex build integration, tokens and shared styles 2026-08-12 21:41:32 +02:00
mokhtar 7b0527d271 milestone 22: typescript 7 2026-08-12 21:30:40 +02:00
mokhtar c5e6ab9180 spec milestones 22 and 23: typescript 7, stylex and react aria 2026-08-12 21:30:35 +02:00
118 changed files with 11401 additions and 1886 deletions
+6
View File
@@ -9,6 +9,12 @@ on:
branches: [master] branches: [master]
pull_request: pull_request:
branches: [master] 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: jobs:
gates: gates:
+5 -1
View File
@@ -104,7 +104,11 @@ jobs:
run: zig build test-aarch64 -fqemu run: zig build test-aarch64 -fqemu
frontend: frontend:
runs-on: ubuntu-24.04 # 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
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+50
View File
@@ -10,6 +10,13 @@ subject rarely does.
## [Unreleased] ## [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 ### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the - **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
@@ -24,6 +31,26 @@ subject rarely does.
configuration rows, names the tables and counts, and applies it only with 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 the new `--allow-delete` flag. Additive and edit-in-place imports need no
flag. 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 ### Changed
@@ -49,6 +76,29 @@ subject rarely does.
- `nxdns import --force` is renamed `--allow-delete`. - `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. - A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
Use `nxdns import` once, or run in file mode with `--config`. 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 ## [0.0.1] - 2026-08-09
+6 -3
View File
@@ -61,9 +61,12 @@ chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon chmod 0640 /etc/nxdns/config.zon
``` ```
0640 with group `nxdns` rather than 0600: the service runs as `nxdns` and has to 0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and
read this file on the first start, and systemd leaves `/etc/nxdns` owned by systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good.
root. 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.
Check it before starting the service: Check it before starting the service:
+78 -73
View File
@@ -23,17 +23,19 @@ 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. - DNS server for LAN clients: UDP/53, TCP/53, DoH server, DoT server.
- Upstream resolution: DoH (HTTP/1.1), DoT. - Upstream resolution: DoH (HTTP/1.1), DoT.
- Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed). - Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed).
- Domain filtering: blocklists (hosts/domains/ABP), custom rules (allow/block; exact, parent-walk, wildcard), CNAME uncloaking (depth 8), per-group safe-search rewrite. - 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.
- DNS caching: positive + negative, in-memory only. - DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments. - 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. - 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. - 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`. - Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- 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. - 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.
### 2.2 Out of Scope (permanent scope decisions, not deferrals) ### 2.2 Out of Scope (permanent scope decisions, not deferrals)
- 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. - 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.
- DHCP server. - DHCP server.
- DNSSEC validation (DO bit passthrough only). - DNSSEC validation (DO bit passthrough only).
- DoQ (QUIC), HTTP/2 upstream transport. - DoQ (QUIC), HTTP/2 upstream transport.
@@ -74,8 +76,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) ### 3.5 Config Format + Truth Model (Decision F)
- **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. - **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.
- First start: if DB empty and `/etc/nxdns/config.zon` exists, validate → seed DB. Subsequent starts ignore the file. - 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.
- `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). - `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. - No file watcher, no auto-regeneration.
@@ -89,21 +91,21 @@ Two SQLite files with opposite write profiles, isolated from each other:
### 3.7 Upgrades: Auto-Migration (Decision J) ### 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. - `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.
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh. - `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
### 3.8 Blocklist Storage (Decision A) ### 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: 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, count, checksum). Wildcard/regex-flavored lines: wildcards go to `<source_id>.wild`; regex lines are counted + skipped (count in metadata → UI). - 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.
- `config.db` keeps source **metadata only** (`blocklist_sources`). - `config.db` keeps source **metadata only** (`blocklist_sources`).
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5). - 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. - Corruption recovery is per-file: checksum mismatch → re-download one list.
### 3.9 Rule Model (Decision B) ### 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`). Actions: `allow` | `block`. Kind is an explicit column — the model is extensible without breakage, but regex stays out of scope (§2.2). 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.
### 3.10 Filtering Precedence ### 3.10 Filtering Precedence
@@ -111,11 +113,25 @@ Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*`
2. Exact/parent **block** rules 2. Exact/parent **block** rules
3. Wildcard allow rules 3. Wildcard allow rules
4. Wildcard block rules 4. Wildcard block rules
5. Blocklist domains 5. Regex allow rules
6. Blocklist wildcards 6. Regex block rules
7. Blocklist exceptions (`@@||name^`)
8. Blocklist domains
9. Blocklist wildcards
Tie-break at same specificity: **allow wins**. 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 ### 3.11 Network Posture
- Default web bind: LAN/Tailscale-friendly (non-loopback allowed). - Default web bind: LAN/Tailscale-friendly (non-loopback allowed).
@@ -128,14 +144,14 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.13 Filesystem Layout (FHS) ### 3.13 Filesystem Layout (FHS)
- `/etc/nxdns/config.zon`bootstrap (first start only). - `/etc/nxdns/config.zon`the declarative source, read only when `run --config` names it. A file no flag names changes nothing.
- `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db` - `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db`
- `/var/lib/nxdns/blocklists/*.list|*.wild` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp` during a refresh) - `/var/lib/nxdns/blocklists/*.list|*.wild|*.allow` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp|*.allow.tmp` during a refresh)
- `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald. - `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald.
### 3.14 Frontend Stack (Decision I) ### 3.14 Frontend Stack (Decision I)
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR. - Vite + React + TypeScript + StyleX + React Aria; 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). - **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. - Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
@@ -173,7 +189,7 @@ Client DNS Query
Response to client Response to client
``` ```
Cross-cutting: `ConfigManager` (bootstrap/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics). 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).
--- ---
@@ -203,7 +219,8 @@ src/
filter/ # pure filter/ # pure
matcher.zig rules.zig wildcard.zig matcher.zig rules.zig wildcard.zig
parser_hosts.zig parser_domains.zig parser_abp.zig parser_hosts.zig parser_domains.zig parser_abp.zig
fetcher.zig compiler.zig # list download -> compiled .list/.wild files fetcher.zig compiler.zig # list download -> compiled .list/.wild/.allow files
regex.zig # linear-time Pike VM for operator regex rules
safesearch.zig safesearch.zig
local/ # pure local/ # pure
@@ -223,7 +240,8 @@ src/
logger.zig retention.zig disk_monitor.zig logger.zig retention.zig disk_monitor.zig
config/ config/
model.zig bootstrap.zig import.zig export.zig validate.zig # all ZON via std.zon model.zig loader.zig reconcile.zig import.zig export.zig # all ZON via std.zon
validate.zig faults.zig
web/ web/
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
@@ -232,7 +250,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + Tailwind + TanStack web/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned) vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis) docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/ tests/ # dns/ integration/ fuzz/
@@ -276,13 +294,16 @@ For `{domain, group_id}` (the qtype travels with the query for logging and respo
for matching): for matching):
1. Normalize: lowercase, trim trailing dot. 1. Normalize: lowercase, trim trailing dot.
2. Build candidate chain (full, parent1, parent2, …). 2. Build candidate chain (full, parent1, parent2, …).
3. Explicit rules per §3.10 precedence, evaluated against every candidate in the chain. 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 domains (hash set over compiled lists), matched against the query name only. 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 wildcards, matched against every proper parent of the query name. 5. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
6. No match → allow. 6. Group's blocklist wildcards, matched against every proper parent of the query name.
7. No match → allow.
Blocklist entries do not parent-walk; only rules do (§3.9). ABP `||x.y^` emits both a domain entry Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard
`x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics. 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.
### 7.2 Group Assignment ### 7.2 Group Assignment
@@ -341,7 +362,16 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
`journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set. `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set.
### 11.2 config.db Schema ### 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.
```sql ```sql
CREATE TABLE schema_version (version INTEGER NOT NULL); CREATE TABLE schema_version (version INTEGER NOT NULL);
@@ -374,7 +404,8 @@ CREATE TABLE upstreams (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE, url TEXT NOT NULL UNIQUE,
priority INTEGER NOT NULL DEFAULT 100, priority INTEGER NOT NULL DEFAULT 100,
enabled INTEGER NOT NULL DEFAULT 1 enabled INTEGER NOT NULL DEFAULT 1,
tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
); );
CREATE TABLE blocklist_sources ( CREATE TABLE blocklist_sources (
@@ -386,7 +417,9 @@ CREATE TABLE blocklist_sources (
last_updated INTEGER, last_updated INTEGER,
domain_count INTEGER NOT NULL DEFAULT 0, domain_count INTEGER NOT NULL DEFAULT 0,
wildcard_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_regex_count INTEGER NOT NULL DEFAULT 0,
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
checksum TEXT checksum TEXT
); );
@@ -400,7 +433,7 @@ CREATE TABLE rules (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')), kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
action TEXT NOT NULL CHECK(action IN ('allow','block')), action TEXT NOT NULL CHECK(action IN ('allow','block')),
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@@ -471,52 +504,24 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
## 12. Configuration ## 12. Configuration
### 12.1 Bootstrap ZON Shape ### 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:
```zon ```zon
.{ .{
.upstream = .{ .groups = .{ .{ .name = "default" } },
.servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" }, .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
.read_timeout_ms = 3000, .rules = .{
.{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
}, },
.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 },
} }
``` ```
@@ -592,11 +597,11 @@ UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + he
Exit: A/AAAA forwarding over UDP + TCP; health populated. Exit: A/AAAA forwarding over UDP + TCP; health populated.
### Phase 4 — Storage + Config ### Phase 4 — Storage + Config
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON bootstrap + import/export; `nxdns check`. SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`.
Exit: first start seeds DB from ZON; export → import round-trips byte-stable. Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
### Phase 5 — Filtering + Local DNS ### Phase 5 — Filtering + Local DNS
Rule matcher (exact/parent/wildcard); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones. 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.
Exit: precedence table validated by tests; local zone answers + conditional forwards work. Exit: precedence table validated by tests; local zone answers + conditional forwards work.
### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor ### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor
@@ -623,7 +628,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
## 17. Testing Strategy ## 17. Testing Strategy
- **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). - **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).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing. - **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. - **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. - **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
@@ -697,7 +702,7 @@ longer the only path.
| # | Decision | | # | Decision |
|---|----------| |---|----------|
| A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only | | A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only |
| B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope | | B | Rule kinds: exact, parent-walk, wildcard, regex (m21, own linear-time engine). Regex *from downloaded lists* stays out of scope |
| C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering | | 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 | | 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 | | 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 |
+17 -6
View File
@@ -1,14 +1,25 @@
# nxdns # nxdns
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference forwards the rest over an encrypted connection.
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what. ```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.
## Features ## Features
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow - Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists —
and block rules with wildcard support (`*.example.com`) whose `@@` exception lines are honoured — plus your own allow and block rules,
exact, wildcard (`*.example.com`) or regular expression
- Two configuration modes: a database the web UI edits, or a ZON file you keep - Two configuration modes: a database the web UI edits, or a ZON file you keep
in git and converge onto at every start in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and - Per-client policy groups: different filtering for the kids' tablet and
+9
View File
@@ -157,6 +157,15 @@ pub fn build(b: *std.Build) void {
.import_module = sourceModule(b, target, optimize, "src/web/http_util.zig"), .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 // The bench harness (milestone-12 ruling 1). The measured roots
// (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative // (matcher.zig, dns_cache.zig, compiler.zig) share files in their relative
// import closures (model.zig, types.zig, ...), and a file may belong to // import closures (model.zig, types.zig, ...), and a file may belong to
+1 -1
View File
@@ -1,6 +1,6 @@
.{ .{
.name = .nxdns, .name = .nxdns,
.version = "0.0.1", .version = "0.0.2",
.minimum_zig_version = "0.16.0", .minimum_zig_version = "0.16.0",
.paths = .{""}, .paths = .{""},
.fingerprint = 0x3307b311dded1d91, .fingerprint = 0x3307b311dded1d91,
+1 -1
View File
@@ -29,7 +29,7 @@ Directories:
| Directory | Role | | 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/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` 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/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/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/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/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. | | `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 2 info(migrations): config.db migrated from schema version 0 to 1
imported /tmp/nxdns-lab/backup.zon 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: A healthy first start logs the reconcile, the authority and the bound sockets:
``` ```
info(migrations): config.db migrated from schema version 0 to 2 info(migrations): config.db migrated from schema version 0 to 1
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0; 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 … settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
web authentication is now enabled web authentication is now enabled
+8 -3
View File
@@ -184,8 +184,13 @@ was written before the first start.
0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a 0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a
`ConfigurationDirectory`, which systemd leaves owned by root, and the service `ConfigurationDirectory`, which systemd leaves owned by root, and the service
runs as `nxdns` and has to read this file on the first start. A root-owned 0600 runs as `nxdns`. A root-owned 0600 file would be unreadable to it.
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.
Do not expect `nxdns check` to catch a permissive mode here. Its only Do not expect `nxdns check` to catch a permissive mode here. Its only
permission warning is for a TLS private key permission warning is for a TLS private key
@@ -220,7 +225,7 @@ nxdns import /etc/nxdns/config.zon
``` ```
``` ```
info(migrations): config.db migrated from schema version 0 to 2 info(migrations): config.db migrated from schema version 0 to 1
imported /etc/nxdns/config.zon imported /etc/nxdns/config.zon
``` ```
+10 -7
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 nxdns bench suite=filter domains=100000 iters=20000 seed=0x5eed optimize=ReleaseFast
suite ops p50(us) p95(us) p99(us) max(us) suite ops p50(us) p95(us) p99(us) max(us)
filter 20000 0.14 0.25 0.27 0.51 filter 20000 2.38 2.76 2.88 20.32
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
target p95 < 1ms: PASS target p95 < 1ms: PASS
target VmRSS < 100 MiB: 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) suite ops p50(us) p95(us) p99(us) max(us)
cache 20000 0.12 0.21 0.23 0.54 cache 20000 0.14 0.18 0.20 5.14
hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.2 MiB hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.3 MiB
target p95 < 5ms: PASS 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) suite ops p50(us) p95(us) p99(us) max(us)
compile 100000 wall 11.512ms, 8686215 lines/s, 100000 domains kept (informational) compile 100000 wall 15.623ms, 6400464 lines/s, 100000 domains kept (informational)
``` ```
`--seed=N` changes the generated domains and the query order; the default is `--seed=N` changes the generated domains and the query order; the default is
@@ -94,6 +94,9 @@ usage: zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [--d
is building the key, getting the entry and stamping the response id. 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 - `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. 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 - Two memory figures appear on purpose. `Snapshot.memoryBytes` and
`DnsCache.memoryBytes` are the in-repo accounting of those structures; `VmRSS` `DnsCache.memoryBytes` are the in-repo accounting of those structures; `VmRSS`
is what the kernel holds resident for the whole process, allocator slack and is what the kernel holds resident for the whole process, allocator slack and
@@ -121,8 +124,8 @@ zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000
``` ```
``` ```
filter 20000 0.14 0.26 0.27 2.42 filter 20000 2.34 2.71 2.85 15.06
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
target p95 < 1ms: PASS target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS target VmRSS < 100 MiB: PASS
``` ```
+19 -1
View File
@@ -401,12 +401,30 @@ snapshot loaded but has no sources in it. The line
a source in the admin interface, or a `blocklist_sources` entry to the a source in the admin interface, or a `blocklist_sources` entry to the
configuration file with a `group_sources` link naming a group. 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 ## A database stamped by a newer binary
**Symptom.** After putting an older binary back, it will not start: **Symptom.** After putting an older binary back, it will not start:
``` ```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2 warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
nxdns run failed: SchemaTooNew nxdns run failed: SchemaTooNew
``` ```
+9 -10
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: 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 2; `nxdns run` migrates it, `check` will not FAIL /var/lib/nxdns/config.db: schema version 0, this nxdns expects 1; `nxdns run` migrates it, `check` will not
``` ```
That line was reproduced here against a database stamped at version 0; the path That line was reproduced here against a database stamped at version 0; the path
@@ -317,29 +317,28 @@ A fresh database is created at the current schema version; an older one is
stepped up to it. The log line names both versions: stepped up to it. The log line names both versions:
``` ```
info(migrations): config.db migrated from schema version 0 to 2 info(migrations): config.db migrated from schema version 0 to 1
``` ```
> Verified on this host: that exact line is what `nxdns import` printed when it > 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 > 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 > directory is schema version 0, which is why a first run reports a migration
> rather than nothing. The step from a populated older schema to 2 was not > rather than nothing. Version 1 is the only schema nxdns has published, so an
> reproduced here — it needs a database written by an older binary, which this > upgrade from a populated older one is not a case that exists yet.
> host does not have.
Rolling back is the case that has no answer. A database stamped by a newer 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 binary refuses to open, so an older binary against an upgraded data directory
fails to start: fails to start:
``` ```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2 warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
nxdns run failed: SchemaTooNew nxdns run failed: SchemaTooNew
``` ```
> Not reproduced on this host: the same missing ingredient as above, a > Reproduced on this host, with one substitution: no binary from the future was
> database at a schema version this binary does not support. The two lines > available, so the scratch database's `schema_version` row was set to 99 by
> are the messages `src/storage/migrations.zig` emits, not a run captured > hand and `nxdns run` was pointed at it. The two lines above are that run's
> here. > output.
That run exits 1. Recovering means importing the export you took in step 1 into That run exits 1. Recovering means importing the export you took in step 1 into
a fresh data directory with the older binary. a fresh data directory with the older binary.
+28
View File
@@ -277,3 +277,31 @@ 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: 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 `src/web/openapi.yaml` in the repository, or `GET /api/openapi.yaml` from a
running server. 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 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 URL the file still names keeps its row id, its checksum, its counters and its
compiled `<id>.list` and `<id>.wild`, so a restart in file mode downloads compiled `<id>.list`, `<id>.wild` and `<id>.allow`, so a restart in file mode
nothing. Editing a source's URL is a new identity — a new row, a new id, and a downloads nothing. Editing a source's URL is a new identity — a new row, a new id, and a
fresh download. fresh download.
### Failing to start in file mode ### Failing to start in file mode
+62 -4
View File
@@ -279,6 +279,29 @@ Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by the
fetcher and compiled into domain sets. A disabled source is neither downloaded fetcher and compiled into domain sets. A disabled source is neither downloaded
nor loaded. 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 ### group_sources
Which groups consult which blocklist sources. Which groups consult which blocklist sources.
@@ -299,15 +322,48 @@ Per-group allow and block overrides, checked before the blocklists.
|---|---|---|---| |---|---|---|---|
| `group` | string | required | must name a declared group | | `group` | string | required | must name a declared group |
| `pattern` | string | required | see below | | `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` | required | — | | `kind` | enum `.exact` \| `.wildcard` \| `.regex` | required | — |
| `action` | enum `.allow` \| `.block` | required | — | | `action` | enum `.allow` \| `.block` | required | — |
Pattern rules: an `.exact` pattern is a plain domain name and may not contain 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 `*` `*`. 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 (`*.tracker.example`, or `*` alone), and every other label must be a legal DNS
label. `ads*.example` is not a valid wildcard. label. `ads*.example` is not a valid wildcard; a partial label is what the
`.regex` kind is for.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`). 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.
### local_records ### local_records
@@ -548,10 +604,12 @@ upstream. Everything else keeps its default.
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" }, .{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
}, },
// Overrides beat blocklists. Wildcards need a label that is exactly "*". // Overrides beat blocklists. Wildcards need a label that is exactly "*";
// a partial label takes a regex, which is unanchored unless you say "^".
.rules = .{ .rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow }, .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block }, .{ .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. // Local names, answered without any upstream.
+10 -8
View File
@@ -46,19 +46,21 @@ 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/` | 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>.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>.wild` | Wildcard entries for the same source. | 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 | | `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 |
`<id>` is the `blocklist_sources` row id. `<id>` is the `blocklist_sources` row id.
### The orphan sweep ### The orphan sweep
The sweep decides by id, not by suffix. It matches all five names above and The sweep decides by id, not by suffix. It matches all seven names above and
deletes those whose `<id>` is no longer a `blocklist_sources` row, so the deletes those whose `<id>` is no longer a `blocklist_sources` row, so the
compiled `.list` and `.wild` of a removed source go, and so do a `.raw.tmp`, compiled `.list`, `.wild` and `.allow` of a removed source go, and so do a
`.list.tmp` or `.wild.tmp` left behind by a refresh that was killed before it `.raw.tmp`, `.list.tmp`, `.wild.tmp` or `.allow.tmp` left behind by a refresh
could clean up. Files belonging to a source that still has a row are never that was killed before it could clean up. Files belonging to a source that still
touched, whatever state they are in: the sweep holds the same lock every refresh has a row are never touched, whatever state they are in: the sweep holds the same
takes, so it never reads the directory while a refresh is part-way through. lock every refresh takes, so it never reads the directory while a refresh is
part-way through.
It runs at three moments: It runs at three moments:
@@ -86,7 +88,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 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`, owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`,
`.list.tmp` and `.wild.tmp` as it finishes, successfully or not. `.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
A `querylog.db` is moved aside when it is missing nothing but usability: 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 SQLite reports it corrupt or not a database, `PRAGMA quick_check` does not
+16 -8
View File
@@ -27,7 +27,7 @@ the asset-free figure is a real measurement rather than an estimate.
## Measured: x86_64 development host ## Measured: x86_64 development host
Date: 2026-08-02. Hardware and build: Intel Core i7-14700K, Linux 6.18, Date: 2026-08-13. Hardware and build: Intel Core i7-14700K, Linux 6.18,
Zig 0.16.0, `-Doptimize=ReleaseFast`, harness defaults (1,000,000 domains, Zig 0.16.0, `-Doptimize=ReleaseFast`, harness defaults (1,000,000 domains,
200,000 iterations per suite, seed 0x5eed). 200,000 iterations per suite, seed 0x5eed).
@@ -37,18 +37,26 @@ baseline for regressions on the machine development happens on.
``` ```
suite ops p50(us) p95(us) p99(us) max(us) suite ops p50(us) p95(us) p99(us) max(us)
filter 200000 0.11 0.18 0.27 16.41 filter 200000 2.41 2.80 2.97 22.16
blocked 66699/200000, Snapshot.memoryBytes 28.0 MiB, VmRSS 31.8 MiB blocked 66699/200000, 32 regex rules, Snapshot.memoryBytes 28.0 MiB, VmRSS 32.0 MiB
target p95 < 1ms: PASS target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS target VmRSS < 100 MiB: PASS
cache 200000 0.10 0.14 0.17 3.53 cache 200000 0.11 0.17 0.22 5.40
hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.6 MiB hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.7 MiB
target p95 < 5ms: PASS target p95 < 5ms: PASS
compile 1000000 wall 96.025ms, 10413949 lines/s, 1000000 domains kept (informational) compile 1000000 wall 98.597ms, 10142224 lines/s, 1000000 domains kept (informational)
``` ```
Every in-process §18 target passes on this host: the two latency targets by Every in-process §18 target passes on this host: the filter target by about
three to four orders of magnitude, the memory target by about 3x. 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.
### The two memory figures ### The two memory figures
+45 -25
View File
@@ -9,13 +9,13 @@ delete the directory.
Follow the steps in order. Each one says what it did. 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 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 4 to 11, 13 and 14 were re-run end 24.14.1, dig 9.20.26 and curl 8.21.0. Steps 2, 4 to 11, 13 and 14 were re-run
to end for this revision, and the transcripts are that run's output with the 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 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 step 12 — its endpoints were exercised, the page itself was not opened — and the
two build commands in steps 1 and 2, which had already produced the binary under `npm` build in step 1, whose `web/dist` was already on disk and is the one the
test. The ZON block at the end of step 14 was checked with `nxdns check binary under test embeds. The ZON block at the end of step 14 was checked with
--config` rather than started. `nxdns check --config` rather than started.
## What you need ## 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 2 info(migrations): config.db migrated from schema version 0 to 1
imported /home/you/nxdns-tutorial/config.zon 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(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: database 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(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 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. 90 IN A 172.66.147.243 example.com. 229 IN A 172.66.147.243
example.com. 90 IN A 104.20.23.154 example.com. 229 IN A 104.20.23.154
``` ```
nxdns had no answer cached, so it forwarded the query to nxdns had no answer cached, so it forwarded the query to
@@ -220,20 +220,30 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
``` ```
```json ```json
{"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}]} {"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}]}
``` ```
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 The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears: until this appears:
``` ```
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 2523973 bytes
``` ```
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist `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 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 the previous snapshot the whole time the new one is being built. After this, the
domain count in the JSON above — 99559 on the day this was run — is live. domain count in the JSON above — 97648 on the day this was run — is live.
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`. From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`.
Restarting nxdns does not re-download it. Restarting nxdns does not re-download it.
@@ -259,16 +269,18 @@ dig @127.0.0.1 -p 15353 wikipedia.org A +noall +answer
``` ```
``` ```
wikipedia.org. 17 IN A 185.15.58.224 wikipedia.org. 130 IN A 185.15.58.224
``` ```
One thing to know before you try other names: a blocklist entry blocks exactly One thing to know before you try other names: an entry in a hosts list blocks
the name it names. `doubleclick.net` on the list does not block exactly the name it names. `doubleclick.net` is on this list, and so are
`ads.doubleclick.net`; that name is blocked because the list happens to contain `ad.doubleclick.net` and `www.google-analytics.com`. `ads.doubleclick.net` is
it too. Blocklist entries do not walk up the parent chain — only rules you write not on it, and nxdns does not block it — the entry for the parent says nothing
yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you about the child. Two things do walk up the parent chain, and neither is in play
pick a domain to test, pick one that is literally in the file. here: a rule you write yourself, with a wildcard pattern such as
`www.google-analytics.com` is another that is. `*.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.
## 12. Open the web interface ## 12. Open the web interface
@@ -291,7 +303,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: 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, 3096006 bytes info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 2523973 bytes
info(nxdns): authority: database info(nxdns): authority: database
``` ```
@@ -314,8 +326,12 @@ 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; 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, 199 bytes info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon) 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 **Read that first line.** The blocklist source is gone. That is not a bug — it is
@@ -324,8 +340,12 @@ 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 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. removed at every start. The reconcile said so in one line before doing it.
The compiled list is still on disk and the query log is untouched; what changed The three `pruned` lines are the rest of that removal: with the row gone, the
is the configuration, and it now matches the file exactly. 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.
Neither mode is the "advanced" one. Database mode suits a box someone 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 administers through the web interface. File mode suits a file kept in git and
@@ -345,7 +365,7 @@ Ctrl-C to stop it.
## What you have now ## What you have now
A resolver that answers real queries, a real blocklist of about 99000 domains A resolver that answers real queries, a real blocklist of about 98000 domains
attached to the default group, a query log, and a web interface — all inside one attached to the default group, a query log, and a web interface — all inside one
directory you can delete: directory you can delete:
+9
View File
@@ -0,0 +1,9 @@
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.
+24 -1
View File
@@ -39,6 +39,12 @@ 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 sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
[npm runtime closure] [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/history 1.162.0 MIT
@tanstack/query-core 5.101.4 MIT @tanstack/query-core 5.101.4 MIT
@tanstack/react-query 5.101.4 MIT @tanstack/react-query 5.101.4 MIT
@@ -46,24 +52,37 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@tanstack/react-store 0.9.3 MIT @tanstack/react-store 0.9.3 MIT
@tanstack/router-core 1.171.15 MIT @tanstack/router-core 1.171.15 MIT
@tanstack/store 0.9.3 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 cookie-es 3.1.1 MIT
css-mediaquery 0.1.2 BSD
invariant 2.2.4 MIT
isbot 5.2.1 Unlicense isbot 5.2.1 Unlicense
js-tokens 4.0.0 MIT
loose-envify 1.4.0 MIT
react 19.2.8 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-dom 19.2.8 MIT
react-stately 3.49.0 Apache-2.0
scheduler 0.27.0 MIT scheduler 0.27.0 MIT
seroval 1.5.6 MIT seroval 1.5.6 MIT
seroval-plugins 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 use-sync-external-store 1.6.0 MIT
[npm build-time generators whose output ships] [npm build-time generators whose output ships]
rolldown 1.1.5 MIT rolldown 1.1.5 MIT
tailwindcss 4.3.3 MIT
vite 8.1.5 MIT vite 8.1.5 MIT
[container base image] [container base image]
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
[npm packages bundled into web/dist] [npm packages bundled into web/dist]
@internationalized/string
@stylexjs/stylex
@tanstack/history @tanstack/history
@tanstack/query-core @tanstack/query-core
@tanstack/react-query @tanstack/react-query
@@ -71,7 +90,11 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
@tanstack/react-store @tanstack/react-store
@tanstack/router-core @tanstack/router-core
@tanstack/store @tanstack/store
clsx
react react
react-aria
react-aria-components
react-dom react-dom
react-stately
scheduler scheduler
use-sync-external-store use-sync-external-store
+59 -10
View File
@@ -39,11 +39,42 @@
// stay listed in licenses/dependency-identity.txt so that a future build which // stay listed in licenses/dependency-identity.txt so that a future build which
// does pull them in trips the guard. // 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/ // Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
// plugin-react and typescript only transform our own sources (react-refresh is // plugin-react and typescript only transform our own sources (react-refresh is
// dev-server only, and tslib is optional and unused), @tailwindcss/vite // dev-server only, and tslib is optional and unused), lightningcss only
// orchestrates tailwindcss without contributing CSS of its own, lightningcss // minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// never touch the build output. // never touch the build output.
.{ .{
.{ .{
@@ -94,18 +125,36 @@
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.", .note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
.file = "tanstack-mit.txt", .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", .component = "TanStack Store",
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3", .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.", .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", .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", .component = "Vite",
.version = "vite 8.1.5", .version = "vite 8.1.5",
@@ -115,7 +164,7 @@
.{ .{
.component = "Rolldown", .component = "Rolldown",
.version = "rolldown 1.1.5", .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/classes-*.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/styles-*.js, which is embedded in the binary.",
.file = "rolldown-mit.txt", .file = "rolldown-mit.txt",
}, },
.{ .{
+4 -1
View File
@@ -51,7 +51,10 @@ pub const texts: []const Text = &.{
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") }, .{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") }, .{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") }, .{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-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 = "vite-mit.txt", .body = @embedFile("vite-mit.txt") }, .{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-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") }, .{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
+25
View File
@@ -0,0 +1,25 @@
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.
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) Tailwind Labs, Inc. Copyright (c) Nicolas Gallagher
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+21
View File
@@ -0,0 +1,21 @@
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.
+338 -35
View File
@@ -71,6 +71,11 @@ order preserved; the on-disk header (manager.zig:221-241) gains
`# exceptions {d}` after the `# wildcards` line and the pinning test at `# exceptions {d}` after the `# wildcards` line and the pinning test at
manager.zig:1914-1946 is extended, not weakened. 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 ### 4. Exception counts persist and surface
Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`): Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`):
@@ -82,8 +87,13 @@ checksum doc line at sources_repo.zig:100 is updated alongside
`SourceStatus` rehydration (`manager.zig:1458-1502`) restores it alongside the `SourceStatus` rehydration (`manager.zig:1458-1502`) restores it alongside the
existing three counts; `StatusView` (`src/web/handlers/blocklists.zig:52-78`) existing three counts; `StatusView` (`src/web/handlers/blocklists.zig:52-78`)
gains `exceptions: u32`; the blocklists UI shows it where `skipped_regex` gains `exceptions: u32`; the blocklists UI shows it where `skipped_regex`
already shows (`web/src/features/blocklists/SourceStatusSection.tsx`, already shows.
`web/src/lib/types.ts:163,192`).
`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.
### 5. The regex engine is a Pike VM, linear-time by construction, `std` only ### 5. The regex engine is a Pike VM, linear-time by construction, `std` only
@@ -91,7 +101,25 @@ New file `src/filter/regex.zig`. Syntax: literal bytes, `.`, character
classes `[...]` with ranges and leading-`^` negation, escapes classes `[...]` with ranges and leading-`^` negation, escapes
`\. \\ \- \d \w`, repetition `* + ? {n} {n,m}`, alternation `|`, `\. \\ \- \d \w`, repetition `* + ? {n} {n,m}`, alternation `|`,
non-capturing grouping `(...)`, anchors `^` and `$`. No backreferences, no non-capturing grouping `(...)`, anchors `^` and `$`. No backreferences, no
lookaround, no captures. Matching is unanchored unless anchors are written 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
(POSIX-grep convention, matching Pi-hole user expectations). Input is the (POSIX-grep convention, matching Pi-hole user expectations). Input is the
normalized lowercase name, ≤ `types.max_name_len` bytes. Hard limits, each a normalized lowercase name, ≤ `types.max_name_len` bytes. Hard limits, each a
distinct error: pattern ≤ 256 bytes (`PatternTooLong`), compiled program distinct error: pattern ≤ 256 bytes (`PatternTooLong`), compiled program
@@ -106,8 +134,19 @@ pub fn matches(prog: *const Program, input: []const u8) bool;
`matches` is a Pike VM: two thread lists, each program counter admitted at `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 most once per input position, worst case O(program × input) with zero
allocation at match time (thread lists sized from the program at compile allocation at match time.
time). The engine is a fuzz-module root like parsers.zig and imports only
**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
`std`. `std`.
### 6. `regex` is a third rule kind, validated at the edge, memoized by the cache ### 6. `regex` is a third rule kind, validated at the edge, memoized by the cache
@@ -125,7 +164,14 @@ set, and a rename would fail both. `model.RuleKind`
validate.zig:891-902) and `src/filter/rules.zig:62-68` extend. `RuleSet` 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 (`rules.zig:28-36`) grows `regex_allow` and `regex_block` slices holding
compiled `Program`s plus their pattern texts (for `Decision.matched`); compiled `Program`s plus their pattern texts (for `Decision.matched`);
`bucketOf` (rules.zig:133-143) becomes a six-bucket layout; `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";
`max_regex_per_group: usize = 256` with `TooManyRegexRules` mirroring `max_regex_per_group: usize = 256` with `TooManyRegexRules` mirroring
`max_wildcards_per_group` (rules.zig:26). A pattern that fails to compile is `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 `error.BadPattern` at snapshot build, never skipped (rules.zig:41-49 doc
@@ -144,7 +190,14 @@ rules.zig:42 becomes `"kind must be 'exact', 'wildcard' or 'regex'"`.
`src/web/openapi.yaml:1948,1962,1976`: all three `enum: [exact, wildcard]` become `src/web/openapi.yaml:1948,1962,1976`: all three `enum: [exact, wildcard]` become
`[exact, wildcard, regex]`. `web/src/lib/types.ts:195`: `[exact, wildcard, regex]`. `web/src/lib/types.ts:195`:
`RuleKind = "exact" | "wildcard" | "regex"`; the rules page kind selector `RuleKind = "exact" | "wildcard" | "regex"`; the rules page kind selector
gains the option. Contract samples regenerated. `nxdns export` / `import` 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`
round-trip the new kind with no extra work once `RuleKind.toDb/fromDb` extend 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. — the enum round-trip test at model.zig:782 is extended to prove it.
@@ -156,7 +209,11 @@ counted and skipped; `$` modifiers (except the `$important` suffix of ruling
1), partial-segment wildcards, and browser-syntax honoring stay permanently 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 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 (§3.10 precedence) and PLAN.md:700 (decision B) are updated to match rulings
2 and 6. In-code echoes of the old §2.2 move with it: 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:
`src/filter/wildcard.zig:6-7,20-23`, `src/filter/parsers.zig:25`, `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`. `src/filter/parser_abp.zig:5-6`, `src/filter/compiler.zig:129-131`.
@@ -205,8 +262,10 @@ Owns: `src/filter/parsers.zig`, `src/filter/parser_abp.zig`,
(step 3 only), `src/storage/repositories/sources_repo.zig`, (step 3 only), `src/storage/repositories/sources_repo.zig`,
`src/web/handlers/blocklists.zig`, `src/web/handlers/lookup.zig` (doc `src/web/handlers/blocklists.zig`, `src/web/handlers/lookup.zig` (doc
sentence only), `src/web/openapi.yaml` (StatusView shape only), sentence only), `src/web/openapi.yaml` (StatusView shape only),
`web/src/features/blocklists/*`, `web/src/lib/types.ts` (source-stat fields `web/src/features/blocklists/*` (which includes `BlocklistsPage.tsx` per
only), `web/src/lib/contractSamples.gen.ts`, 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),
`tests/fuzz/blocklist_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig`, and the `tests/fuzz/blocklist_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig`, and the
dump-golden assertions in `src/config/import.zig` and dump-golden assertions in `src/config/import.zig` and
`src/config/reconcile.zig` test suites only (ruling 9's `exception_count` `src/config/reconcile.zig` test suites only (ruling 9's `exception_count`
@@ -222,7 +281,9 @@ fallout from `ddl_v3`; S1 touches no reconcile logic).
exceptions as loadable, not rejected. exceptions as loadable, not rejected.
- S1.4 matcher: `SourceSets.exceptions`, `Reason.blocklist_exception`, - S1.4 matcher: `SourceSets.exceptions`, `Reason.blocklist_exception`,
the new evaluate level per ruling 2, `memoryBytes` includes the new sets. the new evaluate level per ruling 2, `memoryBytes` includes the new sets.
- S1.5 storage + API + UI per ruling 4. - 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.6 tests: parser cases (`@@||x^`, `@@||x`, `@@||x^$important`, - S1.6 tests: parser cases (`@@||x^`, `@@||x`, `@@||x^$important`,
`@@||x^$third-party` → unsupported, `@@x` → unsupported); compiler `@@||x^$third-party` → unsupported, `@@x` → unsupported); compiler
three-body + checksum-compat cases (empty allow body reproduces the old three-body + checksum-compat cases (empty allow body reproduces the old
@@ -233,15 +294,21 @@ fallout from `ddl_v3`; S1 touches no reconcile logic).
`filter_integration_test.zig` with a real ABP fixture carrying `@@` lines. `filter_integration_test.zig` with a real ABP fixture carrying `@@` lines.
Acceptance (S1): Acceptance (S1):
- [ ] `zig build test` passes; fuzz targets build and run. - [x] `zig build test` passes; fuzz targets build and run.
- [ ] A fixture list with `||ads.example^` and `@@||good.ads.example^` - [x] A fixture list with `||ads.example^` and `@@||good.ads.example^`
compiled and loaded blocks `ads.example` and `x.ads.example`, does not compiled and loaded blocks `ads.example` and `x.ads.example`, does not
block `good.ads.example` or `y.good.ads.example`, and block `good.ads.example` or `y.good.ads.example`, and
`/api/lookup` reports `blocklist_exception` with the source id for the `/api/lookup` reports `blocklist_exception` with the source id for the
latter two. latter two. Also proven live against the AdGuard DNS filter, which
- [ ] A pre-milestone data directory (no `.allow` files, old checksums) carries `||ads.tvb.com^` beside `@@||api.ads.tvb.com^`: `dig` sinkholed
loads with zero checksum mismatches. `ads.tvb.com` and `x.ads.tvb.com` to 0.0.0.0 and resolved
- [ ] `POST /api/blocklists/update` response rows carry `exceptions`. `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`).
### Session S2: the regex engine (tier 2, engine only) ### Session S2: the regex engine (tier 2, engine only)
@@ -257,9 +324,13 @@ target only).
- S2.3 the fuzz target per ruling 10. - S2.3 the fuzz target per ruling 10.
Acceptance (S2): Acceptance (S2):
- [ ] `zig build test` passes with the new file in `src/tests.zig`. - [x] `zig build test` passes with the new file in `src/tests.zig`.
- [ ] The step-bound property holds under the fuzz corpus. - [x] The step-bound property holds under the fuzz corpus. `expectLinear`
- [ ] `regex.zig` imports nothing but `std`. (`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`.
### Session S3: the regex rule kind, wired through (needs S1 + S2) ### Session S3: the regex rule kind, wired through (needs S1 + S2)
@@ -280,21 +351,32 @@ ruling 9), `PLAN.md`, `src/filter/wildcard.zig` (comments),
- S3.4 web + UI + openapi + samples per ruling 7. - S3.4 web + UI + openapi + samples per ruling 7.
- S3.5 PLAN and comment amendments per ruling 8. - S3.5 PLAN and comment amendments per ruling 8.
- S3.6 bench: the filter suite in `tools/bench.zig` gains a variant with 32 - 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. 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.
- S3.7 tests: rule CRUD with kind `regex` through the API including the 400 - 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 for a bad pattern at insert time; precedence cases regex-allow over
regex-block, wildcard over regex, regex over list entries; export/import regex-block, wildcard over regex, regex over list entries; export/import
round trip; a migration test upgrading a v3 database. round trip; a migration test upgrading a v3 database.
Acceptance (S3): Acceptance (S3):
- [ ] `zig build test` and `cd web && npm test` pass. - [x] `zig build test` and `cd web && npm test` pass.
- [ ] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}` - [x] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}`
returns 201; with `"pattern":"("` returns 400 naming the pattern. returns 201; with `"pattern":"("` returns 400 naming the pattern
- [ ] A regex block rule blocks a matching name; `/api/lookup` reports (live: `{"error":"rules[0].pattern: '(' is not a valid regex pattern"}`).
`rule_block_regex` and `matched` carries the pattern text. - [x] A regex block rule blocks a matching name; `/api/lookup` reports
- [ ] `zig build bench -Doptimize=ReleaseFast -- filter` passes its targets `rule_block_regex` and `matched` carries the pattern text (live:
with the regex variant present. `ad42-tracker.example.com` blocked, `matched` `^ad[0-9]+-`).
- [ ] PLAN §2.2 no longer forbids operator regex; all listed echoes updated. - [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.
### Orchestrator ### Orchestrator
@@ -314,15 +396,236 @@ Deleted surface: none.
## Acceptance (milestone complete) ## Acceptance (milestone complete)
- [ ] All session acceptance boxes above. - [x] All session acceptance boxes above.
- [ ] Schema at version 4; a v2 database migrates cleanly with data intact. - [x] Schema at version 4; a v2 database migrates cleanly with data intact
- [ ] A pre-milestone blocklist data directory loads without refetch. (`migrations.zig:344`, "a version 2 database upgrades and keeps its
- [ ] The six-level operator precedence plus three list levels behave per 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
ruling 2, proven by matcher tests that enumerate adjacent-level pairs. ruling 2, proven by matcher tests that enumerate adjacent-level pairs.
- [ ] No `src/filter/` fuzz-root file imports anything but `std`. Every boundary on the nine-level ladder has a test in which one query
- [ ] Contract samples, openapi.yaml and `web/src/lib/types.ts` agree with 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
the server (the drift guards pass). 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 ## Anti-requirements
- No `$` modifier support beyond tolerating `$important` on exception lines. - No `$` modifier support beyond tolerating `$important` on exception lines.
+108
View File
@@ -0,0 +1,108 @@
# 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
@@ -0,0 +1,574 @@
# 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
@@ -0,0 +1,581 @@
# 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.
+10 -1
View File
@@ -1527,7 +1527,16 @@ 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, "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, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint)); try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK")); // 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.expectEqualStrings("", captured.err.written()); try testing.expectEqualStrings("", captured.err.written());
} }
+32 -8
View File
@@ -1,6 +1,9 @@
//! The one configuration model. Bootstrap, import, export, the repositories and //! The one *declarative* configuration model: loading, reconciliation, import,
//! the running server all speak this struct; nothing else describes nxdns //! export and the running server all speak this struct, and it is the whole
//! configuration. //! 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.
//! //!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The //! 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. //! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
@@ -8,11 +11,21 @@
//! Runtime columns are deliberately absent. `clients.first_seen`, //! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and //! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count, //! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! skipped_regex_count, checksum}` are facts a running server produces, not //! exception_count, skipped_regex_count, skipped_unsupported_count, checksum}`
//! configuration. Including them would make two exports taken minutes apart //! are facts a running server produces, not configuration. Including them would make two exports taken
//! differ, which would make the byte-stable round trip untestable against a //! minutes apart differ, which would make the byte-stable round trip untestable
//! live server. Import sets the timestamps to the import time and leaves the //! against a live server.
//! counters at their column defaults. //!
//! 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.
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -256,20 +269,25 @@ pub const BlocklistSource = struct {
pub const GroupSource = struct { group: []const u8, source_url: []const u8 }; 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 { pub const RuleKind = enum {
exact, exact,
wildcard, wildcard,
regex,
pub fn toDb(self: RuleKind) []const u8 { pub fn toDb(self: RuleKind) []const u8 {
return switch (self) { return switch (self) {
.exact => "exact", .exact => "exact",
.wildcard => "wildcard", .wildcard => "wildcard",
.regex => "regex",
}; };
} }
pub fn fromDb(text: []const u8) ?RuleKind { pub fn fromDb(text: []const u8) ?RuleKind {
if (std.mem.eql(u8, text, "exact")) return .exact; if (std.mem.eql(u8, text, "exact")) return .exact;
if (std.mem.eql(u8, text, "wildcard")) return .wildcard; if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
if (std.mem.eql(u8, text, "regex")) return .regex;
return null; return null;
} }
}; };
@@ -789,6 +807,12 @@ test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(RecordType); 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" { test "RecordType stores the uppercase DDL spelling" {
try testing.expectEqualStrings("A", RecordType.a.toDb()); try testing.expectEqualStrings("A", RecordType.a.toDb());
try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb()); try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
+53 -4
View File
@@ -3,10 +3,10 @@
//! //!
//! The defect this module exists to fix: `import.applyToDb` deletes and //! The defect this module exists to fix: `import.applyToDb` deletes and
//! reinserts every row, `blocklist_sources` included, and the compiled //! reinserts every row, `blocklist_sources` included, and the compiled
//! blocklists are named after the source row id (`<id>.list` / `<id>.wild`). A //! blocklists are named after the source row id (`<id>.list` / `<id>.wild` /
//! configuration re-applied on every boot would therefore hand every source a //! `<id>.allow`). A configuration re-applied on every boot would therefore hand
//! new id, orphan every compiled file, and re-download every blocklist on every //! every source a new id, orphan every compiled file, and re-download every
//! restart. //! blocklist on every restart.
//! //!
//! So nothing is wiped. Every table has an identity; a row the file and the //! 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 //! database agree on is **updated in place**, keeping its row id and every
@@ -1100,7 +1100,9 @@ fn seedSourceStats(database: *db.Db, id: i64) !void {
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 4321, .domain_count = 4321,
.wildcard_count = 21, .wildcard_count = 21,
.exception_count = 9,
.skipped_regex_count = 7, .skipped_regex_count = 7,
.skipped_unsupported_count = 33,
.checksum = "a" ** 64, .checksum = "a" ** 64,
}); });
} }
@@ -1169,6 +1171,9 @@ test "a source keeps its id, its checksum and its counters across a reconcile" {
try testing.expectEqualStrings("advertising", row.name); try testing.expectEqualStrings("advertising", row.name);
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated); 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, 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.?); try testing.expectEqualStrings("a" ** 64, row.checksum.?);
} }
@@ -1550,6 +1555,50 @@ 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" { test "dropping one of two identical rules removes exactly one row" {
var bench: Bench = undefined; var bench: Bench = undefined;
try bench.init(); try bench.init();
+111 -4
View File
@@ -44,6 +44,7 @@ const Writer = std.Io.Writer;
const model = @import("model.zig"); const model = @import("model.zig");
const address = @import("../platform/address.zig"); const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig"); const dns_name = @import("../dns/name.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig"); const safe_url = @import("../safe_url.zig");
const transport = @import("../upstream/transport.zig"); const transport = @import("../upstream/transport.zig");
@@ -890,13 +891,31 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
for (cfg.rules, 0..) |rule, i| { for (cfg.rules, 0..) |rule, i| {
try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i}); try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i});
if (!try patternIsValid(scratch, rule.pattern, rule.kind)) { // `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| {
try diags.add( try diags.add(
error.BadRulePattern, error.BadRulePattern,
"rules[{d}].pattern", "rules[{d}].pattern",
.{i}, .{i},
"{f} is not a valid {s} pattern", "{f} is not a valid {s} pattern{s}",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb() }, .{ safe_url.quoteText(rule.pattern), rule.kind.toDb(), suffix },
); );
} }
} }
@@ -1096,11 +1115,18 @@ fn sourceUrlIsValid(url: []const u8) bool {
/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no /// 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 /// `*` at all, a wildcard pattern carries at least one label that is exactly
/// `*`, and every remaining label must survive `dns.name.fromText`. /// `*`, 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( fn patternIsValid(
scratch: Allocator, scratch: Allocator,
pattern: []const u8, pattern: []const u8,
kind: model.RuleKind, kind: model.RuleKind,
) error{OutOfMemory}!bool { ) regex.Error!bool {
switch (kind) { switch (kind) {
.exact => { .exact => {
if (std.mem.findScalar(u8, pattern, '*') != null) return false; if (std.mem.findScalar(u8, pattern, '*') != null) return false;
@@ -1126,6 +1152,11 @@ fn patternIsValid(
_ = dns_name.fromText(substituted.items) catch return false; _ = dns_name.fromText(substituted.items) catch return false;
return true; return true;
}, },
.regex => {
var program = try regex.compile(scratch, pattern);
program.deinit(scratch);
return true;
},
} }
} }
@@ -2105,6 +2136,82 @@ test "rule patterns accept wildcards only when the kind says so" {
try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern"); 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" { test "parseResolver accepts udp and tcp with an IP literal and a port" {
const udp4 = try parseResolver("udp://192.168.1.1:53"); const udp4 = try parseResolver("udp://192.168.1.1:53");
try testing.expectEqual(ResolverScheme.udp, udp4.scheme); try testing.expectEqual(ResolverScheme.udp, udp4.scheme);
+199 -44
View File
@@ -1,14 +1,15 @@
//! Compiles a downloaded blocklist into the two bodies nxdns stores on disk: //! Compiles a downloaded blocklist into the three bodies nxdns stores on disk:
//! a `.list` body of exact names and a `.wild` body of suffixes. //! a `.list` body of exact names, a `.wild` body of suffixes and an `.allow`
//! body of the names the list's `@@` exceptions lift.
//! //!
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and two //! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and
//! `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body is a //! three `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body
//! pure function of (bytes, format), which is what makes two runs — and two //! is a pure function of (bytes, format), which is what makes two runs — and two
//! permutations of the same input — byte-identical. //! permutations of the same input — byte-identical.
//! //!
//! Nothing but the sorted, deduplicated names is written: no header, no //! Nothing but the sorted, deduplicated names is written: no header, no
//! timestamp, no counts. The header belongs to the caller, and the checksum //! timestamp, no counts. The header belongs to the caller, and the checksum
//! covers the two bodies only. //! covers the three bodies only.
const std = @import("std"); const std = @import("std");
const parsers = @import("parsers.zig"); const parsers = @import("parsers.zig");
@@ -23,6 +24,12 @@ pub const max_line_len: usize = 4096;
pub const Counts = struct { pub const Counts = struct {
domains: u32 = 0, domains: u32 = 0,
wildcards: 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_regex: u32 = 0,
skipped_unsupported: u32 = 0, skipped_unsupported: u32 = 0,
/// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII /// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
@@ -36,30 +43,49 @@ pub const Counts = struct {
pub const Result = struct { pub const Result = struct {
counts: Counts, counts: Counts,
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body. /// 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.
checksum: [64]u8, 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 }; pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };
/// Reads `r` to end of stream and writes the two compiled bodies. /// Reads `r` to end of stream and writes the three compiled bodies.
/// ///
/// `counts.domains` and `counts.wildcards` are the written, deduplicated /// `counts.domains`, `counts.wildcards` and `counts.exceptions` are the written,
/// counts: they are what `blocklist_sources.domain_count` and `wildcard_count` /// deduplicated counts: they are what `blocklist_sources.domain_count`,
/// store and what the UI shows. /// `wildcard_count` and `exception_count` store and what the UI shows.
pub fn compile( pub fn compile(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
r: *std.Io.Reader, r: *std.Io.Reader,
format: parsers.Format, format: parsers.Format,
list_w: *std.Io.Writer, list_w: *std.Io.Writer,
wild_w: *std.Io.Writer, wild_w: *std.Io.Writer,
allow_w: *std.Io.Writer,
) Error!Result { ) Error!Result {
var counts: Counts = .{}; var counts: Counts = .{};
var list: Entries = .{}; var bodies: Bodies = .{};
defer list.deinit(gpa); defer bodies.deinit(gpa);
var wild: Entries = .{};
defer wild.deinit(gpa);
while (try parsers.nextBoundedLine(r, max_line_len)) |event| { while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
const raw = switch (event) { const raw = switch (event) {
@@ -81,32 +107,30 @@ pub fn compile(
.domain => { .domain => {
var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace); var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace);
while (fields.next()) |field| { while (fields.next()) |field| {
try addCandidate(gpa, field, false, false, &list, &wild, &counts); try addCandidate(gpa, field, parsed, &bodies, &counts);
} }
}, },
.wildcard => try addCandidate( .wildcard, .exception => try addCandidate(gpa, parsed.text, parsed, &bodies, &counts),
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(.{}); var hasher = Sha256.init(.{});
counts.domains = try emit(&list, list_w, &hasher, &counts.duplicates); counts.domains = try emit(&bodies.list, list_w, &hasher, &counts.duplicates);
counts.wildcards = try emit(&wild, wild_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);
var digest: [Sha256.digest_length]u8 = undefined; var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest); hasher.final(&digest);
return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) }; return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) };
} }
/// Normalizes one whitespace-separated candidate and files it under `.list`, /// Normalizes one whitespace-separated candidate of `line` and files it under
/// `.wild`, or neither. /// `.list`, `.wild`, `.allow`, or nowhere.
/// ///
/// The normalization below is deliberately not `dns.name.normalizeText`: this /// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every /// one adds the two-label minimum, rejects control bytes, and reports every
@@ -114,20 +138,19 @@ pub fn compile(
fn addCandidate( fn addCandidate(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
field: []const u8, field: []const u8,
from_wildcard_line: bool, line: parsers.Line,
covers_apex: bool, bodies: *Bodies,
list: *Entries,
wild: *Entries,
counts: *Counts, counts: *Counts,
) Error!void { ) Error!void {
var candidate = field; var candidate = field;
var is_wildcard = from_wildcard_line; var is_wildcard = line.kind == .wildcard;
if (std.mem.startsWith(u8, candidate, "*.")) { if (std.mem.startsWith(u8, candidate, "*.")) {
is_wildcard = true; is_wildcard = true;
candidate = candidate[2..]; candidate = candidate[2..];
} }
// A '*' anywhere else makes this a pattern, and patterns belong to the // A '*' anywhere else makes this a pattern, and patterns belong to the
// `rules` table; a blocklist entry is a name or a suffix. // `rules` table, where the operator writes them as a `.wildcard` or a
// `.regex`; a blocklist entry is a name or a suffix.
if (std.mem.indexOfScalar(u8, candidate, '*') != null) { if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
counts.invalid += 1; counts.invalid += 1;
return; return;
@@ -163,12 +186,17 @@ fn addCandidate(
return; return;
} }
if (is_wildcard) { // An exception needs no apex entry beside its suffix entry: the matcher
try wild.append(gpa, normalized); // 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);
// An ABP `||x^` rule covers `x` itself as well as its subdomains. // An ABP `||x^` rule covers `x` itself as well as its subdomains.
if (covers_apex) try list.append(gpa, normalized); if (line.covers_apex) try bodies.list.append(gpa, normalized);
} else { } else {
try list.append(gpa, normalized); try bodies.list.append(gpa, normalized);
} }
} }
@@ -202,6 +230,20 @@ fn emit(
return written; 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 /// Length-prefixed candidate bytes plus the offsets that index them. Sorting
/// permutes the offsets, so the bytes never move. /// permutes the offsets, so the bytes never move.
const Entries = struct { const Entries = struct {
@@ -247,10 +289,12 @@ const Compiled = struct {
result: Result, result: Result,
list_w: std.Io.Writer.Allocating, list_w: std.Io.Writer.Allocating,
wild_w: std.Io.Writer.Allocating, wild_w: std.Io.Writer.Allocating,
allow_w: std.Io.Writer.Allocating,
fn deinit(self: *Compiled) void { fn deinit(self: *Compiled) void {
self.list_w.deinit(); self.list_w.deinit();
self.wild_w.deinit(); self.wild_w.deinit();
self.allow_w.deinit();
} }
fn list(self: *Compiled) []const u8 { fn list(self: *Compiled) []const u8 {
@@ -260,6 +304,10 @@ const Compiled = struct {
fn wild(self: *Compiled) []const u8 { fn wild(self: *Compiled) []const u8 {
return self.wild_w.written(); 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 { fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
@@ -272,8 +320,10 @@ fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Form
errdefer list_w.deinit(); errdefer list_w.deinit();
var wild_w: std.Io.Writer.Allocating = .init(gpa); var wild_w: std.Io.Writer.Allocating = .init(gpa);
errdefer wild_w.deinit(); errdefer wild_w.deinit();
const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer); var allow_w: std.Io.Writer.Allocating = .init(gpa);
return .{ .result = result, .list_w = list_w, .wild_w = wild_w }; 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 hosts_fixture = const hosts_fixture =
@@ -339,10 +389,110 @@ test "abp apex rule lands in both bodies" {
try testing.expectEqualStrings("bare.com\nx.com\n", c.list()); try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
try testing.expectEqualStrings("x.com\n", c.wild()); 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, 2), c.result.counts.domains);
try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards); 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_regex);
try testing.expectEqual(@as(u32, 2), c.result.counts.skipped_unsupported); 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));
} }
test "two runs of the same input are byte-identical" { test "two runs of the same input are byte-identical" {
@@ -353,6 +503,7 @@ test "two runs of the same input are byte-identical" {
try testing.expectEqualStrings(a.list(), b.list()); try testing.expectEqualStrings(a.list(), b.list());
try testing.expectEqualStrings(a.wild(), b.wild()); try testing.expectEqualStrings(a.wild(), b.wild());
try testing.expectEqualStrings(a.allow(), b.allow());
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum); try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
} }
@@ -374,6 +525,7 @@ test "a permutation of the input compiles to the same bodies" {
try testing.expectEqualStrings(a.list(), b.list()); try testing.expectEqualStrings(a.list(), b.list());
try testing.expectEqualStrings(a.wild(), b.wild()); try testing.expectEqualStrings(a.wild(), b.wild());
try testing.expectEqualStrings(a.allow(), b.allow());
try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum); try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
} }
@@ -489,14 +641,17 @@ test "carriage returns are stripped" {
try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list()); try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
} }
test "empty input produces empty bodies and the sha256 of the empty string" { test "empty input produces empty bodies and the digest of three separators" {
var c = try compileText(testing.allocator, "", .domains); var c = try compileText(testing.allocator, "", .domains);
defer c.deinit(); defer c.deinit();
try testing.expectEqualStrings("", c.list()); try testing.expectEqualStrings("", c.list());
try testing.expectEqualStrings("", c.wild()); 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( try testing.expectEqualStrings(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "709e80c88487a2411e1ee4dfb9f22a861492d20c4765150c0c794abd70f8147c",
&c.result.checksum, &c.result.checksum,
); );
} }
+522 -26
View File
@@ -33,10 +33,13 @@ const sources_repo = @import("../storage/repositories/sources_repo.zig");
const compiler = @import("compiler.zig"); const compiler = @import("compiler.zig");
const fetcher = @import("fetcher.zig"); const fetcher = @import("fetcher.zig");
const parsers = @import("parsers.zig");
const manager = @import("manager.zig"); const manager = @import("manager.zig");
const matcher = @import("matcher.zig"); const matcher = @import("matcher.zig");
const response = @import("response.zig"); const response = @import("response.zig");
const lookup = @import("../web/handlers/lookup.zig");
const forward_client = @import("../local/forward_client.zig"); const forward_client = @import("../local/forward_client.zig");
const forward_zones = @import("../local/forward_zones.zig"); const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig"); const records = @import("../local/records.zig");
@@ -60,6 +63,11 @@ const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awak
/// The forward-zone read timeout. Case 15 asserts a silent resolver gives up /// 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 /// 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. /// 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 read_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024); const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);
@@ -111,49 +119,88 @@ const http_body =
const http_domains: i64 = 2; const http_domains: i64 = 2;
const http_wildcards: i64 = 1; const http_wildcards: i64 = 1;
const http_exceptions: i64 = 0;
const http_regex: i64 = 1; 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;
/// Compiles `text` into `<base>.list` and `<base>.wild` under `dir`, exactly as /// An ABP-format list: one element-hiding rule and one `$`-modifier rule that
/// the manager's compile stage does, and returns the compiler's own result. /// 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.
fn compileToFiles( fn compileToFiles(
gpa: std.mem.Allocator, gpa: std.mem.Allocator,
io: std.Io, io: std.Io,
dir: std.Io.Dir, dir: std.Io.Dir,
base: []const u8, base: []const u8,
text: []const u8, text: []const u8,
format: parsers.Format,
) !compiler.Result { ) !compiler.Result {
var list_name_buf: [64]u8 = undefined; var list_name_buf: [64]u8 = undefined;
var wild_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 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 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) }); const list_file = try dir.createFile(io, list_name, .{ .permissions = .fromMode(0o600) });
defer list_file.close(io); defer list_file.close(io);
const wild_file = try dir.createFile(io, wild_name, .{ .permissions = .fromMode(0o600) }); const wild_file = try dir.createFile(io, wild_name, .{ .permissions = .fromMode(0o600) });
defer wild_file.close(io); 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, 2 * 16 * 1024); const buffers = try gpa.alloc(u8, 3 * 16 * 1024);
defer gpa.free(buffers); defer gpa.free(buffers);
var r: std.Io.Reader = .fixed(text); var r: std.Io.Reader = .fixed(text);
var list_w = list_file.writer(io, buffers[0 .. 16 * 1024]); var list_w = list_file.writer(io, buffers[0 .. 16 * 1024]);
var wild_w = wild_file.writer(io, buffers[16 * 1024 ..]); var wild_w = wild_file.writer(io, buffers[16 * 1024 .. 32 * 1024]);
var allow_w = allow_file.writer(io, buffers[32 * 1024 ..]);
const result = try compiler.compile(gpa, &r, .hosts, &list_w.interface, &wild_w.interface); const result = try compiler.compile(
gpa,
&r,
format,
&list_w.interface,
&wild_w.interface,
&allow_w.interface,
);
try list_w.interface.flush(); try list_w.interface.flush();
try wild_w.interface.flush(); try wild_w.interface.flush();
try allow_w.interface.flush();
return result; return result;
} }
/// The two compiled bodies of one source, read back from disk with their /// The three compiled bodies of one source, read back from disk with their
/// headers stripped, exactly as `Manager.reload` reads them. /// headers stripped, exactly as `Manager.reload` reads them.
const Bodies = struct { const Bodies = struct {
list: []u8, list: []u8,
wild: []u8, wild: []u8,
allow: []u8,
fn read(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, base: []const u8) !Bodies { 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 list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined; var wild_name_buf: [64]u8 = undefined;
var allow_name_buf: [64]u8 = undefined;
const list = try dir.readFileAlloc( const list = try dir.readFileAlloc(
io, io,
try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base}), try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base}),
@@ -167,21 +214,34 @@ const Bodies = struct {
gpa, gpa,
file_limit, file_limit,
); );
return .{ .list = list, .wild = wild }; 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 };
} }
fn deinit(self: *Bodies, gpa: std.mem.Allocator) void { fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
gpa.free(self.list); gpa.free(self.list);
gpa.free(self.wild); gpa.free(self.wild);
gpa.free(self.allow);
self.* = undefined; self.* = undefined;
} }
}; };
/// A one-group, one-source snapshot over two compiled bodies. /// 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) !matcher.Snapshot { fn snapshotOver(
gpa: std.mem.Allocator,
list_body: []const u8,
wild_body: []const u8,
allow_body: []const u8,
) !matcher.Snapshot {
const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }}; const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
const compiled = [_]?matcher.Snapshot.Compiled{ const compiled = [_]?matcher.Snapshot.Compiled{
.{ .list_body = list_body, .wild_body = wild_body }, .{ .list_body = list_body, .wild_body = wild_body, .allow_body = allow_body },
}; };
return matcher.Snapshot.build(gpa, .{ return matcher.Snapshot.build(gpa, .{
.groups = &.{.{ .name = "default" }}, .groups = &.{.{ .name = "default" }},
@@ -198,10 +258,17 @@ fn snapshotOver(gpa: std.mem.Allocator, list_body: []const u8, wild_body: []cons
}); });
} }
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 { /// 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 {
var hasher = Sha256.init(.{}); var hasher = Sha256.init(.{});
hasher.update(list_body); hasher.update(list_body);
hasher.update(compiler.body_separator);
hasher.update(wild_body); 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; var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest); hasher.final(&digest);
return std.fmt.bytesToHex(digest, .lower); return std.fmt.bytesToHex(digest, .lower);
@@ -353,7 +420,7 @@ const Env = struct {
// fixtures: the loopback http server // fixtures: the loopback http server
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall }; const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };
/// How long the `stall` route holds a reply open when nothing releases it. /// How long the `stall` route holds a reply open when nothing releases it.
/// ///
@@ -400,6 +467,11 @@ const oversize_length = "104857600";
const HttpFixture = struct { const HttpFixture = struct {
server: net.Server, server: net.Server,
body: []const u8, 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), route: std.atomic.Value(u8),
/// Connections accepted, whatever came over them. A test that claims a pass /// Connections accepted, whatever came over them. A test that claims a pass
/// downloaded nothing reads this rather than the route counters: a refetch /// downloaded nothing reads this rather than the route counters: a refetch
@@ -426,6 +498,7 @@ const HttpFixture = struct {
return .{ return .{
.server = try local.listen(io, .{ .reuse_address = true }), .server = try local.listen(io, .{ .reuse_address = true }),
.body = body, .body = body,
.changed_body = "",
.route = .init(@intFromEnum(Route.body)), .route = .init(@intFromEnum(Route.body)),
.accepted = .init(0), .accepted = .init(0),
.flushed_parts = .init(0), .flushed_parts = .init(0),
@@ -474,6 +547,7 @@ const HttpFixture = struct {
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void { fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) { switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
.body => try request.respond(self.body, .{ .keep_alive = false }), .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)) .redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
try request.respond(self.body, .{ .keep_alive = false }) try request.respond(self.body, .{ .keep_alive = false })
else else
@@ -679,7 +753,7 @@ test "1: a compiled hosts fixture loads into a snapshot that blocks its domains"
const text = try hostsFixture(gpa); const text = try hostsFixture(gpa);
defer gpa.free(text); defer gpa.free(text);
const result = try compileToFiles(gpa, io, tmp.dir, "1", text); const result = try compileToFiles(gpa, io, tmp.dir, "1", text, .hosts);
try testing.expectEqual(@as(u32, fixture_domains), result.counts.domains); try testing.expectEqual(@as(u32, fixture_domains), result.counts.domains);
try testing.expectEqual(@as(u32, 1), result.counts.skipped_regex); try testing.expectEqual(@as(u32, 1), result.counts.skipped_regex);
// The three single-label names are the only invalid candidates here. // The three single-label names are the only invalid candidates here.
@@ -692,7 +766,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); try testing.expect(std.mem.find(u8, bodies.list, bare) == null);
} }
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild); var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
defer snapshot.deinit(); defer snapshot.deinit();
const group = snapshot.groupIndexByName("default").?; const group = snapshot.groupIndexByName("default").?;
@@ -732,8 +806,8 @@ test "2: recompiling the same fixture produces byte-identical files and checksum
var second_dir = try tmp.dir.createDirPathOpen(io, "second", .{}); var second_dir = try tmp.dir.createDirPathOpen(io, "second", .{});
defer second_dir.close(io); defer second_dir.close(io);
const first = try compileToFiles(gpa, io, first_dir, "1", text); const first = try compileToFiles(gpa, io, first_dir, "1", text, .hosts);
const second = try compileToFiles(gpa, io, second_dir, "1", text); const second = try compileToFiles(gpa, io, second_dir, "1", text, .hosts);
try testing.expectEqualStrings(&first.checksum, &second.checksum); try testing.expectEqualStrings(&first.checksum, &second.checksum);
try testing.expectEqual(first.counts, second.counts); try testing.expectEqual(first.counts, second.counts);
@@ -745,11 +819,12 @@ 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.list, second_bodies.list);
try testing.expectEqualSlices(u8, first_bodies.wild, second_bodies.wild); 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 two bodies it // The checksum the compiler reported is the one over the three bodies it
// wrote, which is what the manager stores and compares against. // wrote, which is what the manager stores and compares against.
try testing.expectEqualStrings( try testing.expectEqualStrings(
&bodyChecksum(first_bodies.list, first_bodies.wild), &bodyChecksum(first_bodies.list, first_bodies.wild, first_bodies.allow),
&first.checksum, &first.checksum,
); );
} }
@@ -781,8 +856,10 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 2, .domain_count = 2,
.wildcard_count = 1, .wildcard_count = 1,
.exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.checksum = &bodyChecksum(good_list, good_wild), .skipped_unsupported_count = 0,
.checksum = &bodyChecksum(good_list, good_wild, ""),
}); });
try env.mgr.reload(io); try env.mgr.reload(io);
@@ -812,8 +889,10 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 2, .domain_count = 2,
.wildcard_count = 1, .wildcard_count = 1,
.exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.checksum = &bodyChecksum(unsorted_list, good_wild), .skipped_unsupported_count = 0,
.checksum = &bodyChecksum(unsorted_list, good_wild, ""),
}); });
try testing.expectError(error.NotSorted, env.mgr.reload(io)); try testing.expectError(error.NotSorted, env.mgr.reload(io));
@@ -870,6 +949,7 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
try testing.expectEqual(http_domains, row.domain_count); try testing.expectEqual(http_domains, row.domain_count);
try testing.expectEqual(http_wildcards, row.wildcard_count); try testing.expectEqual(http_wildcards, row.wildcard_count);
try testing.expectEqual(http_regex, row.skipped_regex_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.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len);
try testing.expect(row.last_updated != null); try testing.expect(row.last_updated != null);
@@ -1028,7 +1108,9 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
.last_updated = 1_000, .last_updated = 1_000,
.domain_count = http_domains, .domain_count = http_domains,
.wildcard_count = http_wildcards, .wildcard_count = http_wildcards,
.exception_count = http_exceptions,
.skipped_regex_count = http_regex, .skipped_regex_count = http_regex,
.skipped_unsupported_count = http_unsupported,
.checksum = blk: { .checksum = blk: {
var rows = try listRows(&env.database); var rows = try listRows(&env.database);
defer rows.deinit(); defer rows.deinit();
@@ -1069,6 +1151,9 @@ const ReloadTask = struct {
/// Writes one source's compiled files and records their checksum, without any /// 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 /// network: the swap and the orphan sweep care about files and rows, not about
/// where the bytes came from. /// 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 { fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []const u8) !void {
const io = env.io(); const io = env.io();
var dir = try env.blocklistDir(); var dir = try env.blocklistDir();
@@ -1076,6 +1161,7 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
var list_name_buf: [64]u8 = undefined; var list_name_buf: [64]u8 = undefined;
var wild_name_buf: [64]u8 = undefined; var wild_name_buf: [64]u8 = undefined;
var allow_name_buf: [64]u8 = undefined;
try dir.writeFile(io, .{ try dir.writeFile(io, .{
.sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}), .sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
.data = list_body, .data = list_body,
@@ -1084,13 +1170,19 @@ 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}), .sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
.data = wild_body, .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, .{ try sources_repo.updateSourceStats(&env.database, id, .{
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 1, .domain_count = 1,
.wildcard_count = 0, .wildcard_count = 0,
.exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.checksum = &bodyChecksum(list_body, wild_body), .skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, ""),
}); });
} }
@@ -1146,14 +1238,21 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
var dir = try env.blocklistDir(); var dir = try env.blocklistDir();
defer dir.close(io); 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.list", .data = "gone.example.com\n" });
try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" }); 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 // 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 // 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. // 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.raw.tmp", .data = "" });
try dir.writeFile(io, .{ .sub_path = "9999.list.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.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 // The live source does have a row, so its temporary is a refresh in
// progress and must survive a sweep that runs beside it. // progress and must survive a sweep that runs beside it.
@@ -1165,12 +1264,15 @@ test "10: pruneOrphans deletes the files of a deleted source and leaves live one
var live_buf: [64]u8 = undefined; 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}.list", .{id}), .{});
try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.allow", .{id}), .{});
try dir.access(io, live_tmp, .{}); 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.list", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{})); 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.raw.tmp", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list.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.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" { test "10b: the scheduler sweeps orphans on its own, with no operator call" {
@@ -1190,8 +1292,10 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
.last_updated = std.Io.Clock.real.now(io).toSeconds(), .last_updated = std.Io.Clock.real.now(io).toSeconds(),
.domain_count = 1, .domain_count = 1,
.wildcard_count = 0, .wildcard_count = 0,
.exception_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.checksum = &bodyChecksum(list_body, ""), .skipped_unsupported_count = 0,
.checksum = &bodyChecksum(list_body, "", ""),
}); });
var dir = try env.blocklistDir(); var dir = try env.blocklistDir();
@@ -1201,7 +1305,9 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
// either. // either.
try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" }); 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.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.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 // `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 // only one the server ever calls. A disabled update stops it after the
@@ -1212,7 +1318,9 @@ 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.list", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{})); 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.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 // The live source kept its files and is still filtering: the sweep did not
// take the snapshot the same pass had just published. // take the snapshot the same pass had just published.
@@ -1376,6 +1484,7 @@ 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, ".tmp"));
try testing.expect(!std.mem.endsWith(u8, entry.name, ".list")); 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, ".wild"));
try testing.expect(!std.mem.endsWith(u8, entry.name, ".allow"));
} }
} }
@@ -1410,10 +1519,13 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
defer dir.close(io); defer dir.close(io);
var list_buf: [64]u8 = undefined; var list_buf: [64]u8 = undefined;
var wild_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 list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{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 list_before = try dir.statFile(io, list_name, .{});
const wild_before = try dir.statFile(io, wild_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 // 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: // has to recognise the source by its url and leave the row where it is:
@@ -1459,14 +1571,19 @@ 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. // decision the pass made rather than a connection it could not have opened.
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic)); try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
// The same two files: not recompiled, and not swept as orphans and written // The same three files: not recompiled, and not swept as orphans and
// back. // 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.
const list_after = try dir.statFile(io, list_name, .{}); const list_after = try dir.statFile(io, list_name, .{});
const wild_after = try dir.statFile(io, wild_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.inode, list_after.inode);
try testing.expectEqual(list_before.mtime, list_after.mtime); try testing.expectEqual(list_before.mtime, list_after.mtime);
try testing.expectEqual(wild_before.inode, wild_after.inode); try testing.expectEqual(wild_before.inode, wild_after.inode);
try testing.expectEqual(wild_before.mtime, wild_after.mtime); 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 // The row kept the id those files are named after, and the snapshot the
// restart published is the one compiled from them. // restart published is the one compiled from them.
@@ -1788,12 +1905,12 @@ test "17: each blocking mode synthesizes the documented blocked reply" {
// The reply is synthesized for a name the compiled files actually block, so // The reply is synthesized for a name the compiled files actually block, so
// this case covers the decision and the response together. // 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"); const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n", .hosts);
try testing.expectEqual(@as(u32, 1), result.counts.domains); try testing.expectEqual(@as(u32, 1), result.counts.domains);
var bodies = try Bodies.read(gpa, io, tmp.dir, "1"); var bodies = try Bodies.read(gpa, io, tmp.dir, "1");
defer bodies.deinit(gpa); defer bodies.deinit(gpa);
var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild); var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
defer snapshot.deinit(); defer snapshot.deinit();
const group = snapshot.groupIndexByName("default").?; const group = snapshot.groupIndexByName("default").?;
@@ -1909,3 +2026,382 @@ test "18: a body streamed in flushed parts survives the fetcher's multi-read pum
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason); 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);
}
+257 -73
View File
@@ -38,9 +38,10 @@
//! publish: the download of one source, at up to 300 s each, and the compile //! 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 //! that follows it. It also covers blocklist-directory maintenance, because
//! those stages are the only writers of `.raw.tmp` / `.list.tmp` / //! those stages are the only writers of `.raw.tmp` / `.list.tmp` /
//! `.wild.tmp` and `pruneOrphans` must not sweep the temporaries of a refresh //! `.wild.tmp` / `.allow.tmp` and `pruneOrphans` must not sweep the
//! that is still running. Two concurrent refreshes would share the fetcher's //! temporaries of a refresh that is still running. Two concurrent refreshes
//! buffers and, for one source, the same temporary paths. //! 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 //! **Lock ordering: `refresh_lock` is never acquired while `writer_lock` is
//! held.** A path that needs both takes `refresh_lock` first. The public entry //! held.** A path that needs both takes `refresh_lock` first. The public entry
@@ -94,7 +95,7 @@ const io_buf_len: usize = 64 * 1024;
/// would then be decided by almost no data. /// would then be decided by almost no data.
const sample_buf_len: usize = parsers.sample_lines * (compiler.max_line_len + 1); const sample_buf_len: usize = parsers.sample_lines * (compiler.max_line_len + 1);
/// `<id>` is at most 20 characters and the longest suffix is `.list.tmp`. /// `<id>` is at most 20 characters and the longest suffix is `.allow.tmp`.
const name_buf_len: usize = 48; const name_buf_len: usize = 48;
/// How one blocklist source is named in a log line: by its row id and its name, /// How one blocklist source is named in a log line: by its row id and its name,
@@ -215,9 +216,12 @@ pub const SourceStatus = struct {
}; };
/// The header every compiled file carries, ahead of the body. The `sha256` /// The header every compiled file carries, ahead of the body. The `sha256`
/// covers the `.list` body followed by the `.wild` body and **not** the header, /// covers the `.list` body, then the `.wild` body, then the `.allow` body, and
/// so it stays stable across a refetch of unchanged content while /// **not** the header, so it stays stable across a refetch of unchanged content
/// `fetched_at` moves. /// 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.
pub const Header = struct { pub const Header = struct {
url: []const u8, url: []const u8,
format: parsers.Format, format: parsers.Format,
@@ -233,6 +237,7 @@ pub const Header = struct {
try w.print("# fetched_at {d}\n", .{self.fetched_at}); try w.print("# fetched_at {d}\n", .{self.fetched_at});
try w.print("# domains {d}\n", .{self.counts.domains}); try w.print("# domains {d}\n", .{self.counts.domains});
try w.print("# wildcards {d}\n", .{self.counts.wildcards}); 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_regex {d}\n", .{self.counts.skipped_regex});
try w.print("# skipped_unsupported {d}\n", .{self.counts.skipped_unsupported}); try w.print("# skipped_unsupported {d}\n", .{self.counts.skipped_unsupported});
try w.print("# invalid {d}\n", .{self.counts.invalid}); try w.print("# invalid {d}\n", .{self.counts.invalid});
@@ -557,12 +562,14 @@ pub const Manager = struct {
var list_buf: [name_buf_len]u8 = undefined; var list_buf: [name_buf_len]u8 = undefined;
var wild_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 list_name = compiledName(&list_buf, row.id, ".list");
const wild_name = compiledName(&wild_buf, row.id, ".wild"); const wild_name = compiledName(&wild_buf, row.id, ".wild");
const allow_name = compiledName(&allow_buf, row.id, ".allow");
// Reserved before the reads, so neither buffer can be orphaned by a // Reserved before the reads, so no buffer can be orphaned by a failing
// failing append: `bodies` owns each one from the moment it is read. // append: `bodies` owns each one from the moment it is read.
try bodies.ensureUnusedCapacity(self.gpa, 2); try bodies.ensureUnusedCapacity(self.gpa, 3);
// `error.Canceled` is the one-shot signal that this task is being torn // `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 // down, and it is consumed by whoever catches it. Recording it as a
@@ -583,18 +590,38 @@ pub const Manager = struct {
}; };
bodies.appendAssumeCapacity(wild_bytes); 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 list_body = stripHeader(list_bytes);
const wild_body = stripHeader(wild_bytes); const wild_body = stripHeader(wild_bytes);
const allow_body = stripHeader(allow_bytes);
// The checksum covers both bodies together, so a crash between the two // The checksum covers the three bodies together, so a crash between the
// `replace` calls — a new `.list` beside an old `.wild` — is caught // `replace` calls — a new `.list` beside an old `.wild` — is caught
// here and refreshed, not served as a half-updated list. // here and refreshed, not served as a half-updated list.
if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body))) { if (!std.mem.eql(u8, stored, &bodyChecksum(list_body, wild_body, allow_body))) {
log.warn("blocklist {f}: compiled files do not match the stored checksum", .{SourceLabel.of(row)}); log.warn("blocklist {f}: compiled files do not match the stored checksum", .{SourceLabel.of(row)});
return .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } }; return .{ .failed = .{ .state = .load_failed, .text = "ChecksumMismatch" } };
} }
return .{ .loaded = .{ .list_body = list_body, .wild_body = wild_body } }; return .{ .loaded = .{
.list_body = list_body,
.wild_body = wild_body,
.allow_body = allow_body,
} };
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -635,23 +662,28 @@ pub const Manager = struct {
var raw_buf: [name_buf_len]u8 = undefined; var raw_buf: [name_buf_len]u8 = undefined;
var list_tmp_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 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 raw_name = compiledName(&raw_buf, row.id, ".raw.tmp");
const list_tmp = compiledName(&list_tmp_buf, row.id, ".list.tmp"); const tmp: TempNames = .{
const wild_tmp = compiledName(&wild_tmp_buf, row.id, ".wild.tmp"); .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"),
};
// Installed before the calls that create these files, not after: an // Installed before the calls that create these files, not after: an
// `error.Canceled` or `error.OutOfMemory` returned straight out of // `error.Canceled` or `error.OutOfMemory` returned straight out of
// `download` or `compileTo` would outrun a later `defer` and leave a // `download` or `compileTo` would outrun a later `defer` and leave a
// temporary behind. Deleting a name that was never created is a no-op. // 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, raw_name);
defer self.deleteQuietly(io, dir, list_tmp); defer self.deleteQuietly(io, dir, tmp.list);
defer self.deleteQuietly(io, dir, wild_tmp); defer self.deleteQuietly(io, dir, tmp.wild);
defer self.deleteQuietly(io, dir, tmp.allow);
// The half that takes the time: one download of up to `total_budget` // The half that takes the time: one download of up to `total_budget`
// and one compile of everything it returned. `refresh_lock` alone is // and one compile of everything it returned. `refresh_lock` alone is
// held here, so a rule save, a settings change or any other web // 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. // mutation that ends in `reload` runs beside it instead of behind it.
const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, list_tmp, wild_tmp); const prepared = try self.prepareRefresh(io, dir, row, &status, raw_name, tmp);
// The half that publishes. The compiled files, the runtime columns and // The half that publishes. The compiled files, the runtime columns and
// the status entry land under one `writer_lock`, so a reload never // the status entry land under one `writer_lock`, so a reload never
@@ -659,7 +691,7 @@ pub const Manager = struct {
self.writer_lock.lockUncancelable(io); self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io); defer self.writer_lock.unlock(io);
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, list_tmp, wild_tmp); const replaced = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
self.commitStatus(io, status); self.commitStatus(io, status);
return replaced; return replaced;
} }
@@ -690,6 +722,14 @@ pub const Manager = struct {
return self.reload(io); 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 /// What the fetch-and-compile half of a refresh produced. `.failed` needs
/// no publish and has already recorded why in the status entry. /// no publish and has already recorded why in the status entry.
const Prepared = union(enum) { const Prepared = union(enum) {
@@ -712,8 +752,7 @@ pub const Manager = struct {
row: sources_repo.SourceRow, row: sources_repo.SourceRow,
status: *SourceStatus, status: *SourceStatus,
raw_name: []const u8, raw_name: []const u8,
list_tmp: []const u8, tmp: TempNames,
wild_tmp: []const u8,
) Error!Prepared { ) Error!Prepared {
self.download(io, dir, raw_name, row) catch |err| switch (err) { self.download(io, dir, raw_name, row) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory,
@@ -733,7 +772,7 @@ pub const Manager = struct {
}, },
}; };
const result = self.compileTo(io, dir, raw_name, format, list_tmp, wild_tmp) catch |err| switch (err) { const result = self.compileTo(io, dir, raw_name, format, tmp) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled, error.Canceled => return error.Canceled,
else => { else => {
@@ -762,8 +801,7 @@ pub const Manager = struct {
row: sources_repo.SourceRow, row: sources_repo.SourceRow,
status: *SourceStatus, status: *SourceStatus,
prepared: Prepared, prepared: Prepared,
list_tmp: []const u8, tmp: TempNames,
wild_tmp: []const u8,
) Error!bool { ) Error!bool {
const compiled = switch (prepared) { const compiled = switch (prepared) {
.failed => return false, .failed => return false,
@@ -782,11 +820,21 @@ pub const Manager = struct {
if (std.mem.eql(u8, stored, &compiled.result.checksum) and if (std.mem.eql(u8, stored, &compiled.result.checksum) and
self.diskBodiesMatch(io, dir, row.id, stored)) 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, .{ try sources_repo.updateSourceStats(self.database, row.id, .{
.last_updated = now, .last_updated = now,
.domain_count = row.domain_count, .domain_count = compiled.result.counts.domains,
.wildcard_count = row.wildcard_count, .wildcard_count = compiled.result.counts.wildcards,
.skipped_regex_count = row.skipped_regex_count, .exception_count = compiled.result.counts.exceptions,
.skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.checksum = stored, .checksum = stored,
}); });
status.succeed(now, compiled.result.counts); status.succeed(now, compiled.result.counts);
@@ -801,7 +849,7 @@ pub const Manager = struct {
.counts = compiled.result.counts, .counts = compiled.result.counts,
.checksum = &compiled.result.checksum, .checksum = &compiled.result.checksum,
}; };
self.publish(io, dir, row.id, header, list_tmp, wild_tmp) catch |err| switch (err) { self.publish(io, dir, row.id, header, tmp) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory, error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled, error.Canceled => return error.Canceled,
else => { else => {
@@ -814,7 +862,9 @@ pub const Manager = struct {
.last_updated = now, .last_updated = now,
.domain_count = compiled.result.counts.domains, .domain_count = compiled.result.counts.domains,
.wildcard_count = compiled.result.counts.wildcards, .wildcard_count = compiled.result.counts.wildcards,
.exception_count = compiled.result.counts.exceptions,
.skipped_regex_count = compiled.result.counts.skipped_regex, .skipped_regex_count = compiled.result.counts.skipped_regex,
.skipped_unsupported_count = compiled.result.counts.skipped_unsupported,
.checksum = &compiled.result.checksum, .checksum = &compiled.result.checksum,
}); });
status.succeed(now, compiled.result.counts); status.succeed(now, compiled.result.counts);
@@ -913,7 +963,7 @@ pub const Manager = struct {
return parsers.detectFormat(sample.buffered()); return parsers.detectFormat(sample.buffered());
} }
/// Compiles into two plain temporary files. The compiled bodies cannot go /// Compiles into three plain temporary files. The compiled bodies cannot go
/// straight into the final files: the header carries counts that only exist /// straight into the final files: the header carries counts that only exist
/// once the whole input has been compiled, and the loader requires the /// once the whole input has been compiled, and the loader requires the
/// header first. /// header first.
@@ -923,22 +973,24 @@ pub const Manager = struct {
dir: std.Io.Dir, dir: std.Io.Dir,
raw_name: []const u8, raw_name: []const u8,
format: parsers.Format, format: parsers.Format,
list_tmp: []const u8, tmp: TempNames,
wild_tmp: []const u8,
) !compiler.Result { ) !compiler.Result {
const raw = try dir.openFile(io, raw_name, .{}); const raw = try dir.openFile(io, raw_name, .{});
defer raw.close(io); defer raw.close(io);
const list_file = try dir.createFile(io, list_tmp, .{ .permissions = .fromMode(0o600) }); const list_file = try dir.createFile(io, tmp.list, .{ .permissions = .fromMode(0o600) });
defer list_file.close(io); defer list_file.close(io);
const wild_file = try dir.createFile(io, wild_tmp, .{ .permissions = .fromMode(0o600) }); const wild_file = try dir.createFile(io, tmp.wild, .{ .permissions = .fromMode(0o600) });
defer wild_file.close(io); 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, 3 * io_buf_len); const buffers = try self.gpa.alloc(u8, 4 * io_buf_len);
defer self.gpa.free(buffers); defer self.gpa.free(buffers);
var fr = raw.reader(io, buffers[0..io_buf_len]); 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 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 ..]); 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 ..]);
const result = compiler.compile( const result = compiler.compile(
self.gpa, self.gpa,
@@ -946,18 +998,22 @@ pub const Manager = struct {
format, format,
&list_w.interface, &list_w.interface,
&wild_w.interface, &wild_w.interface,
&allow_w.interface,
) catch |err| switch (err) { ) catch |err| switch (err) {
// `compiler.Error` names the direction; the concrete cause is on // `compiler.Error` names the direction; the concrete cause is on
// the stream that failed. // the stream that failed.
error.ReadFailed => return fr.err orelse err, error.ReadFailed => return fr.err orelse err,
error.WriteFailed => return list_w.err orelse (wild_w.err orelse err), error.WriteFailed => return list_w.err orelse
(wild_w.err orelse (allow_w.err orelse err)),
else => return err, else => return err,
}; };
try list_w.interface.flush(); try list_w.interface.flush();
try wild_w.interface.flush(); try wild_w.interface.flush();
try allow_w.interface.flush();
try list_file.sync(io); try list_file.sync(io);
try wild_file.sync(io); try wild_file.sync(io);
try allow_file.sync(io);
return result; return result;
} }
@@ -970,16 +1026,17 @@ pub const Manager = struct {
dir: std.Io.Dir, dir: std.Io.Dir,
id: i64, id: i64,
header: Header, header: Header,
list_tmp: []const u8, tmp: TempNames,
wild_tmp: []const u8,
) !void { ) !void {
const buffers = try self.gpa.alloc(u8, 2 * io_buf_len); const buffers = try self.gpa.alloc(u8, 2 * io_buf_len);
defer self.gpa.free(buffers); defer self.gpa.free(buffers);
var list_buf: [name_buf_len]u8 = undefined; var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined; var wild_buf: [name_buf_len]u8 = undefined;
try publishOne(io, dir, compiledName(&list_buf, id, ".list"), list_tmp, header, buffers); var allow_buf: [name_buf_len]u8 = undefined;
try publishOne(io, dir, compiledName(&wild_buf, id, ".wild"), wild_tmp, header, buffers); 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);
} }
fn publishOne( fn publishOne(
@@ -1014,12 +1071,18 @@ pub const Manager = struct {
try af.replace(io); try af.replace(io);
} }
/// Whether the two compiled files on disk hash to `expected`. A missing, /// Whether the compiled files on disk hash to `expected`. A missing,
/// unreadable or corrupt file answers false, which sends the caller down /// unreadable or corrupt file answers false, which sends the caller down
/// the rewrite path — the only path that can repair it. /// 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 { 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 list_buf: [name_buf_len]u8 = undefined;
var wild_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 limit: std.Io.Limit = .limited(max_compiled_bytes);
const list_bytes = dir.readFileAlloc(io, compiledName(&list_buf, id, ".list"), self.gpa, limit) catch const list_bytes = dir.readFileAlloc(io, compiledName(&list_buf, id, ".list"), self.gpa, limit) catch
@@ -1029,7 +1092,11 @@ pub const Manager = struct {
return false; return false;
defer self.gpa.free(wild_bytes); defer self.gpa.free(wild_bytes);
return compiledBodiesMatch(list_bytes, wild_bytes, expected); 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);
} }
fn reportFetchFailure( fn reportFetchFailure(
@@ -1231,12 +1298,12 @@ pub const Manager = struct {
/// sweeps to nothing. /// sweeps to nothing.
pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void { pub fn pruneOrphans(self: *Manager, io: std.Io) Error!void {
// `refresh_lock` first, and for the reason it exists: the download and // `refresh_lock` first, and for the reason it exists: the download and
// the compile are the only writers of `.raw.tmp`, `.list.tmp` and // the compile are the only writers of `.raw.tmp`, `.list.tmp`,
// `.wild.tmp`, and they hold it for as long as they run. Without it // `.wild.tmp` and `.allow.tmp`, and they hold it for as long as they
// here, a source deleted through the API would sweep the temporaries of // run. Without it here, a source deleted through the API would sweep
// a refresh still writing them — the row is gone, so nothing else in // the temporaries of a refresh still writing them — the row is gone, so
// this function would spare them — and the pass would fail on a raw // nothing else in this function would spare them — and the pass would
// file that vanished under it. // fail on a raw file that vanished under it.
// //
// `writer_lock` second, in the one order this file ever takes them, // `writer_lock` second, in the one order this file ever takes them,
// because the rows this reads and the compiled files it deletes are // because the rows this reads and the compiled files it deletes are
@@ -1477,7 +1544,7 @@ fn applyLoadOutcomes(
entry.loaded = true; entry.loaded = true;
// Two states survive a successful load. `.ok`, because a // Two states survive a successful load. `.ok`, because a
// refresh in this process already filled the counters the // refresh in this process already filled the counters the
// compile produced and the three database columns are a subset // compile produced and the five database columns are a subset
// of them. And any refresh failure, because the files that just // of them. And any refresh failure, because the files that just
// loaded are exactly the ones the failed refresh could not // loaded are exactly the ones the failed refresh could not
// replace, so the operator must still see why. // replace, so the operator must still see why.
@@ -1485,7 +1552,9 @@ fn applyLoadOutcomes(
entry.succeed(row.last_updated orelse 0, .{ entry.succeed(row.last_updated orelse 0, .{
.domains = countOf(row.domain_count), .domains = countOf(row.domain_count),
.wildcards = countOf(row.wildcard_count), .wildcards = countOf(row.wildcard_count),
.exceptions = countOf(row.exception_count),
.skipped_regex = countOf(row.skipped_regex_count), .skipped_regex = countOf(row.skipped_regex_count),
.skipped_unsupported = countOf(row.skipped_unsupported_count),
}); });
}, },
.failed => |reason| { .failed => |reason| {
@@ -1555,43 +1624,67 @@ fn collectSample(r: *std.Io.Reader, w: *std.Io.Writer) error{ ReadFailed, WriteF
} }
} }
/// Whether two compiled files carry the bodies `expected` was taken over. /// Whether three compiled files carry the bodies `expected` was taken over.
fn compiledBodiesMatch(list_bytes: []const u8, wild_bytes: []const u8, expected: []const u8) bool { fn compiledBodiesMatch(
return std.mem.eql(u8, expected, &bodyChecksum(stripHeader(list_bytes), stripHeader(wild_bytes))); 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),
));
} }
/// A compile that produced no entry at all while rejecting lines is an error /// 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. /// 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 /// Publishing it would replace a working list with nothing and report `ok`. An
/// input that rejected nothing is an empty list, which is legal. /// 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 { fn rejectedWithoutEntries(counts: compiler.Counts) bool {
if (counts.domains != 0 or counts.wildcards != 0) return false; if (counts.domains != 0 or counts.wildcards != 0 or counts.exceptions != 0) return false;
return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0; return counts.invalid != 0 or counts.skipped_unsupported != 0 or counts.long_lines != 0;
} }
fn bodyChecksum(list_body: []const u8, wild_body: []const u8) [64]u8 { /// 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 {
var hasher = Sha256.init(.{}); var hasher = Sha256.init(.{});
hasher.update(list_body); hasher.update(list_body);
hasher.update(compiler.body_separator);
hasher.update(wild_body); 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; var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest); hasher.final(&digest);
return std.fmt.bytesToHex(digest, .lower); return std.fmt.bytesToHex(digest, .lower);
} }
fn compiledName(buf: *[name_buf_len]u8, id: i64, suffix: []const u8) []const u8 { 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 nine, // An `i64` prints in at most 20 characters and the longest suffix is ten,
// so `name_buf_len` cannot be exceeded. // so `name_buf_len` cannot be exceeded.
return std.fmt.bufPrint(buf, "{d}{s}", .{ id, suffix }) catch unreachable; return std.fmt.bufPrint(buf, "{d}{s}", .{ id, suffix }) catch unreachable;
} }
/// Every name `compiledName` can produce, longest suffix first so `.list.tmp` /// Every name `compiledName` can produce, longest suffix first so `.list.tmp`
/// is never read as `.list`. /// is never read as `.list`.
const source_file_suffixes = [_][]const u8{ ".list.tmp", ".wild.tmp", ".raw.tmp", ".list", ".wild" }; const source_file_suffixes = [_][]const u8{
".allow.tmp", ".list.tmp", ".wild.tmp", ".raw.tmp", ".allow", ".list", ".wild",
};
/// The source id a file under the blocklist directory belongs to, or null when /// The source id a file under the blocklist directory belongs to, or null when
/// the name is not one of ours. /// the name is not one of ours.
/// ///
/// The three temporaries count. A refresh that dies between writing one and /// The four temporaries count. A refresh that dies between writing one and
/// renaming it leaves a file no later refresh reuses and no `defer` reaches, so /// 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 /// excluding them from the sweep means nothing ever removes them. Matching them
/// is safe because `pruneOrphans` holds `refresh_lock` for its whole body: /// is safe because `pruneOrphans` holds `refresh_lock` for its whole body:
@@ -1830,18 +1923,25 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
const list_body = "aaa.example.com\n"; const list_body = "aaa.example.com\n";
const wild_body = ""; const wild_body = "";
const allow_body = "";
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{}); var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io); defer dir.close(io);
var list_buf: [name_buf_len]u8 = undefined; var list_buf: [name_buf_len]u8 = undefined;
var wild_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(&list_buf, id, ".list"), .data = list_body });
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_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, .{ try sources_repo.updateSourceStats(&database, id, .{
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 1, .domain_count = 1,
.wildcard_count = 0, .wildcard_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.checksum = &bodyChecksum(list_body, wild_body), .skipped_unsupported_count = 0,
.exception_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, allow_body),
}); });
// The baseline every assertion below is against: one clean reload, one // The baseline every assertion below is against: one clean reload, one
@@ -1853,11 +1953,12 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
try testing.expect(out[0].loaded); try testing.expect(out[0].loaded);
const published = mgr.generation; const published = mgr.generation;
// Both catch sites, in the order `loadSource` reads the two files. A // Every catch site, in the order `loadSource` reads the three files. A
// cancellation is consumed by whoever catches it, so folding it into a load // 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 // failure would spend the shutdown signal and leave a status row reading
// "Canceled" behind. // "Canceled" behind. The `.allow` read is the one that can get this wrong
for ([_][]const u8{ ".list", ".wild" }) |suffix| { // twice over: it also has to keep `FileNotFound` apart from a cancellation.
for ([_][]const u8{ ".list", ".wild", ".allow" }) |suffix| {
var vtable: std.Io.VTable = undefined; var vtable: std.Io.VTable = undefined;
const canceling = cancelingIo(io, suffix, &vtable); const canceling = cancelingIo(io, suffix, &vtable);
try testing.expectError(error.Canceled, mgr.reload(canceling)); try testing.expectError(error.Canceled, mgr.reload(canceling));
@@ -1921,6 +2022,7 @@ test "the header writer produces the documented text" {
.counts = .{ .counts = .{
.domains = 12, .domains = 12,
.wildcards = 3, .wildcards = 3,
.exceptions = 7,
.skipped_regex = 2, .skipped_regex = 2,
.skipped_unsupported = 1, .skipped_unsupported = 1,
.invalid = 5, .invalid = 5,
@@ -1938,6 +2040,7 @@ test "the header writer produces the documented text" {
\\# fetched_at 1700000000 \\# fetched_at 1700000000
\\# domains 12 \\# domains 12
\\# wildcards 3 \\# wildcards 3
\\# exceptions 7
\\# skipped_regex 2 \\# skipped_regex 2
\\# skipped_unsupported 1 \\# skipped_unsupported 1
\\# invalid 5 \\# invalid 5
@@ -1960,6 +2063,7 @@ test "the log label names a source without printing what its url carries" {
.domain_count = 0, .domain_count = 0,
.wildcard_count = 0, .wildcard_count = 0,
.skipped_regex_count = 0, .skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = null, .checksum = null,
}; };
const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{ const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{
@@ -2042,32 +2146,51 @@ test "a success clears the recorded error" {
try testing.expectEqualStrings("", status.errorText()); try testing.expectEqualStrings("", status.errorText());
} }
test "compiledName spells the four file names of a source" { 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" {
var buf: [name_buf_len]u8 = undefined; var buf: [name_buf_len]u8 = undefined;
try testing.expectEqualStrings("42.list", compiledName(&buf, 42, ".list")); try testing.expectEqualStrings("42.list", compiledName(&buf, 42, ".list"));
try testing.expectEqualStrings("42.wild", compiledName(&buf, 42, ".wild")); 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.raw.tmp", compiledName(&buf, 42, ".raw.tmp"));
try testing.expectEqualStrings("42.list.tmp", compiledName(&buf, 42, ".list.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" { 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.list"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.wild")); 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 // 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. // 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.raw.tmp"));
try testing.expectEqual(@as(?i64, 7), sourceFileId("7.list.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.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.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.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.tmp"));
try testing.expectEqual(@as(?i64, null), sourceFileId("7.raw")); 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")); try testing.expectEqual(@as(?i64, null), sourceFileId("README"));
} }
test "every name compiledName writes is a name the sweep can attribute" { 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; var buf: [name_buf_len]u8 = undefined;
for (source_file_suffixes) |suffix| { for (source_file_suffixes) |suffix| {
try testing.expectEqual(@as(?i64, 42), sourceFileId(compiledName(&buf, 42, suffix))); try testing.expectEqual(@as(?i64, 42), sourceFileId(compiledName(&buf, 42, suffix)));
@@ -2113,7 +2236,9 @@ fn testRow(id: i64, enabled: bool) sources_repo.SourceRow {
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 9, .domain_count = 9,
.wildcard_count = 4, .wildcard_count = 4,
.exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 5,
.checksum = "0" ** 64, .checksum = "0" ** 64,
}; };
} }
@@ -2224,6 +2349,9 @@ 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, 9), statuses[0].counts.domains);
try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards); try testing.expectEqual(@as(u32, 4), statuses[0].counts.wildcards);
try testing.expectEqual(@as(u32, 1), statuses[0].counts.skipped_regex); 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" { test "a status borrows nothing, so a copy outlives the table it came from" {
@@ -2260,17 +2388,59 @@ test "SourceStatus truncates a long url at max_url_len" {
test "compiledBodiesMatch verifies the bodies, not the presence of the files" { test "compiledBodiesMatch verifies the bodies, not the presence of the files" {
const list_body = "a.example.com\nb.example.com\n"; const list_body = "a.example.com\nb.example.com\n";
const wild_body = "c.example.com\n"; const wild_body = "c.example.com\n";
const expected = bodyChecksum(list_body, wild_body); const allow_body = "d.example.com\n";
const expected = bodyChecksum(list_body, wild_body, allow_body);
const header = const header =
"# nxdns blocklist\n" ++ "# nxdns blocklist\n" ++
"# url https://lists.example/hosts.txt\n"; "# url https://lists.example/hosts.txt\n";
try testing.expect(compiledBodiesMatch(header ++ list_body, header ++ wild_body, &expected)); try testing.expect(compiledBodiesMatch(
header ++ list_body,
header ++ wild_body,
header ++ allow_body,
&expected,
));
// The corruption a reload reports as `ChecksumMismatch`: the file is there, // The corruption a reload reports as `ChecksumMismatch`: the file is there,
// its body is not what the checksum was taken over. // its body is not what the checksum was taken over. An allow body that lost
try testing.expect(!compiledBodiesMatch(header ++ "a.example.com\nb.exa", header ++ wild_body, &expected)); // its entry counts, because a dropped exception silently restores a block.
try testing.expect(!compiledBodiesMatch("", "", &expected)); 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", ""),
));
} }
test "rejectedWithoutEntries fails a compile that produced nothing usable" { test "rejectedWithoutEntries fails a compile that produced nothing usable" {
@@ -2391,14 +2561,28 @@ test "collectSample steps over a line that does not fit the reader buffer" {
try testing.expectEqualStrings("ads.example.com\n", w.buffered()); try testing.expectEqualStrings("ads.example.com\n", w.buffered());
} }
test "bodyChecksum covers the list body followed by the wild body" { test "bodyChecksum covers the list body, then the wild body, then the allow body" {
const both = bodyChecksum("a.example.com\n", "b.example.com\n"); const all = bodyChecksum("a.example.com\n", "b.example.com\n", "c.example.com\n");
var hasher = Sha256.init(.{}); var hasher = Sha256.init(.{});
hasher.update("a.example.com\nb.example.com\n"); 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);
var digest: [Sha256.digest_length]u8 = undefined; var digest: [Sha256.digest_length]u8 = undefined;
hasher.final(&digest); hasher.final(&digest);
try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &both); try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &all);
// Order matters: the two halves are not interchangeable. // Order matters: the three parts are not interchangeable.
try testing.expect(!std.mem.eql(u8, &both, &bodyChecksum("b.example.com\n", "a.example.com\n"))); 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"),
));
} }
+348 -11
View File
@@ -27,6 +27,11 @@ pub const Reason = enum {
rule_block_exact, rule_block_exact,
rule_allow_wildcard, rule_allow_wildcard,
rule_block_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_domain,
blocklist_wildcard, blocklist_wildcard,
}; };
@@ -34,12 +39,14 @@ pub const Reason = enum {
pub const Decision = struct { pub const Decision = struct {
blocked: bool, blocked: bool,
reason: Reason, reason: Reason,
/// The candidate (for the exact and blocklist levels) or the pattern (for /// The candidate (for the exact, exception and blocklist levels) or the
/// the wildcard levels) that decided it. Borrowed from the caller's /// pattern (for the wildcard and regex levels) that decided it. Borrowed
/// normalized buffer or from the snapshot. "" when `reason == .none`. /// from the caller's normalized buffer or from the snapshot. "" when
/// `reason == .none`.
matched: []const u8, matched: []const u8,
/// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and /// `.blocklist_*` only: index into `Snapshot.sources`, so the query log and
/// the UI can name the list that blocked the query. /// the UI can name the list that blocked the query — or, for
/// `.blocklist_exception`, the list that lifted it.
source: ?u32 = null, source: ?u32 = null,
}; };
@@ -95,6 +102,10 @@ pub const SourceSets = struct {
name: []const u8, name: []const u8,
domains: domain_set.DomainSet, domains: domain_set.DomainSet,
wildcards: 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 { pub const Group = struct {
@@ -122,7 +133,15 @@ pub const Snapshot = struct {
/// can tell which generation answered a query. /// can tell which generation answered a query.
generation: u64, generation: u64,
pub const Compiled = struct { list_body: []const u8, wild_body: []const u8 }; /// 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 Input = struct { pub const Input = struct {
groups: []const model.Group, groups: []const model.Group,
@@ -192,6 +211,7 @@ pub const Snapshot = struct {
.name = try arena.dupe(u8, row.name), .name = try arena.dupe(u8, row.name),
.domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed), .domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
.wildcards = try domain_set.DomainSet.build(arena, bodies.wild_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),
}; };
} }
@@ -221,7 +241,11 @@ pub const Snapshot = struct {
.id = id, .id = id,
.name = try arena.dupe(u8, row.name), .name = try arena.dupe(u8, row.name),
.safe_search = row.safe_search, .safe_search = row.safe_search,
.rules = try rules.RuleSet.build(arena, group_rules.items, input.seed), // `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),
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)), .sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
}; };
} }
@@ -269,7 +293,9 @@ pub const Snapshot = struct {
/// PLAN §3.10 precedence, allow winning at equal specificity: /// PLAN §3.10 precedence, allow winning at equal specificity:
/// 1. exact/parent allow rules 2. exact/parent block rules /// 1. exact/parent allow rules 2. exact/parent block rules
/// 3. wildcard allow rules 4. wildcard block rules /// 3. wildcard allow rules 4. wildcard block rules
/// 5. blocklist domains 6. blocklist wildcards /// 5. regex allow rules 6. regex block rules
/// 7. blocklist exceptions
/// 8. blocklist domains 9. blocklist wildcards
/// ///
/// The order is level-by-level over the whole candidate chain, not /// The order is level-by-level over the whole candidate chain, not
/// candidate-by-candidate over the levels: level 1 is checked against every /// candidate-by-candidate over the levels: level 1 is checked against every
@@ -277,10 +303,24 @@ pub const Snapshot = struct {
/// allow rule on the parent beat a block rule on the child, which is the /// allow rule on the parent beat a block rule on the child, which is the
/// behaviour an allow list is written for. /// behaviour an allow list is written for.
/// ///
/// Level 5 tests only the full name and level 6 tests only proper parents: /// 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:
/// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y` /// a `.list` entry is the domain itself, a `.wild` entry is what `*.x.y`
/// means. Both walk the group's sources in ascending index order, so the /// means. All three list levels walk the group's sources in ascending index
/// reported source is stable for a given snapshot. /// order, so the reported source is stable for a given snapshot.
/// ///
/// `domain` is normalized (`normalize`). No allocation, no lock, no clock. /// `domain` is normalized (`normalize`). No allocation, no lock, no clock.
pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision { pub fn evaluate(self: *const Snapshot, group: u32, domain: []const u8) Decision {
@@ -307,6 +347,27 @@ pub const Snapshot = struct {
return .{ .blocked = true, .reason = .rule_block_wildcard, .matched = pattern }; 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| { for (g.sources) |index| {
if (self.sources[index].domains.contains(domain)) { if (self.sources[index].domains.contains(domain)) {
return .{ return .{
@@ -373,7 +434,8 @@ pub const Snapshot = struct {
var total: usize = 0; var total: usize = 0;
for (self.sources) |*source| { for (self.sources) |*source| {
total += @sizeOf(SourceSets) + source.name.len + total += @sizeOf(SourceSets) + source.name.len +
source.domains.memoryBytes() + source.wildcards.memoryBytes(); source.domains.memoryBytes() + source.wildcards.memoryBytes() +
source.exceptions.memoryBytes();
} }
for (self.groups) |*group| { for (self.groups) |*group| {
total += @sizeOf(Group) + group.name.len + total += @sizeOf(Group) + group.name.len +
@@ -586,6 +648,71 @@ test "precedence: a block wildcard with no allow blocks" {
try testing.expectEqualStrings("*.example.com", decision.matched); 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 = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
const one_source_id = [_]i64{11}; const one_source_id = [_]i64{11};
const one_link = [_]model.GroupSource{ const one_link = [_]model.GroupSource{
@@ -601,6 +728,14 @@ const Lists = struct {
return .{ .compiled = .{.{ .list_body = list_body, .wild_body = wild_body }} }; 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 { fn fixture(self: *const Lists) Fixture {
return .{ return .{
.sources = &one_source, .sources = &one_source,
@@ -669,6 +804,208 @@ test "precedence: an allow rule beats a wild entry" {
try testing.expectEqual(Reason.rule_allow_exact, decision.reason); 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" { test "precedence: nothing configured allows with reason none" {
var snapshot = try build(testing.allocator, .{}); var snapshot = try build(testing.allocator, .{});
defer snapshot.deinit(); defer snapshot.deinit();
+132 -8
View File
@@ -1,9 +1,17 @@
//! The Adblock Plus filter syntax, restricted to what a DNS sinkhole can //! The Adblock Plus filter syntax, restricted to what a DNS sinkhole can
//! honour: domain anchors and bare names. Pure, `std` only. //! honour: domain anchors and bare names. Pure, `std` only.
//! //!
//! Exception rules (`@@`) are `.unsupported` rather than an allow entry. The //! An exception rule is `.exception` in exactly two spellings, `@@||name^` and
//! allow surface is the `rules` table, and a downloaded list that could quietly //! `@@||name`, each of which may carry the literal `$important` behind it. Every
//! allow a domain across every group is a policy hole the operator did not open. //! 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.
const std = @import("std"); const std = @import("std");
const parsers = @import("parsers.zig"); const parsers = @import("parsers.zig");
@@ -12,6 +20,31 @@ const parsers = @import("parsers.zig");
/// this syntax and only a trailing one is meaningful for a domain rule. /// this syntax and only a trailing one is meaningful for a domain rule.
const rule_tokens = "*^|/$"; 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 { pub fn parseLine(line: []const u8) parsers.Line {
const text = std.mem.trim(u8, line, &std.ascii.whitespace); const text = std.mem.trim(u8, line, &std.ascii.whitespace);
if (text.len == 0) return .{ .kind = .ignore }; if (text.len == 0) return .{ .kind = .ignore };
@@ -19,15 +52,14 @@ pub fn parseLine(line: []const u8) parsers.Line {
if (text[0] == '[') return .{ .kind = .ignore }; if (text[0] == '[') return .{ .kind = .ignore };
if (parsers.isElementHiding(text)) return .{ .kind = .unsupported }; if (parsers.isElementHiding(text)) return .{ .kind = .unsupported };
if (text[0] == '#') return .{ .kind = .ignore }; if (text[0] == '#') return .{ .kind = .ignore };
if (std.mem.startsWith(u8, text, "@@")) return .{ .kind = .unsupported }; if (std.mem.startsWith(u8, text, "@@")) return parseException(text[2..]);
if (text[0] == '/') return .{ .kind = .regex }; if (text[0] == '/') return .{ .kind = .regex };
if (std.mem.findScalar(u8, text, '$') != null) return .{ .kind = .unsupported }; if (std.mem.findScalar(u8, text, '$') != null) return .{ .kind = .unsupported };
if (std.mem.startsWith(u8, text, "||")) { if (std.mem.startsWith(u8, text, "||")) {
var candidate = text[2..]; var candidate = text[2..];
if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1]; if (std.mem.endsWith(u8, candidate, "^")) candidate = candidate[0 .. candidate.len - 1];
if (candidate.len == 0) return .{ .kind = .unsupported }; if (!isNameCandidate(candidate)) 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, // A domain anchor covers the domain itself as well as its subdomains,
// so the compiler emits an apex entry beside the wildcard one. // so the compiler emits an apex entry beside the wildcard one.
return .{ .kind = .wildcard, .text = candidate, .covers_apex = true }; return .{ .kind = .wildcard, .text = candidate, .covers_apex = true };
@@ -37,6 +69,24 @@ pub fn parseLine(line: []const u8) parsers.Line {
return .{ .kind = .domain, .text = text }; 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; const testing = std.testing;
test "a bang comment is ignored" { test "a bang comment is ignored" {
@@ -69,8 +119,46 @@ test "a modifier list is unsupported" {
try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com^$third-party").kind); try testing.expectEqual(parsers.Kind.unsupported, parseLine("||example.com^$third-party").kind);
} }
test "an exception rule is unsupported" { test "an exception rule is an exception that covers its apex" {
try testing.expectEqual(parsers.Kind.unsupported, parseLine("@@||example.com^").kind); 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 "element hiding is unsupported" { test "element hiding is unsupported" {
@@ -94,6 +182,42 @@ test "a bare name is a domain" {
try testing.expectEqualStrings("example.com", line.text); 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" { 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("ads*.example.com").kind);
try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com^").kind); try testing.expectEqual(parsers.Kind.unsupported, parseLine("example.com^").kind);
+12 -4
View File
@@ -22,10 +22,15 @@ pub const Kind = enum {
domain, domain,
/// `text` holds one candidate suffix; every proper subdomain of it matches. /// `text` holds one candidate suffix; every proper subdomain of it matches.
wildcard, wildcard,
/// A regex rule. Counted, skipped, never compiled (PLAN §2.2). /// 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).
regex, 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: /// Syntactically a rule of this format, but one nxdns cannot honour:
/// an ABP modifier list, an exception rule, element hiding, a scheme anchor. /// an ABP modifier list, an exception form outside `@@||x^`, element
/// hiding, a scheme anchor.
unsupported, unsupported,
}; };
@@ -33,8 +38,11 @@ pub const Line = struct {
kind: Kind, kind: Kind,
/// Borrowed from the caller's line. Not lowercased, not validated. /// Borrowed from the caller's line. Not lowercased, not validated.
text: []const u8 = "", text: []const u8 = "",
/// `.wildcard` only. ABP `||x^` covers `x` itself as well as its subdomains, /// `.wildcard` and `.exception` only: the rule covers the anchored name
/// so the compiler emits an additional `.list` entry when this is set. /// 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.
covers_apex: bool = false, covers_apex: bool = false,
}; };
+1101
View File
File diff suppressed because it is too large Load Diff
+339 -62
View File
@@ -1,4 +1,4 @@
//! One group's explicit rules (PLAN §3.10 levels 14), compiled once into an //! One group's explicit rules (PLAN §3.10 levels 16), compiled once into an
//! immutable form the query path can read without allocating. //! immutable form the query path can read without allocating.
//! //!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted //! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
@@ -7,7 +7,13 @@
//! that many short patterns is cheaper than an index that would have to be //! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap. //! rebuilt on every snapshot swap.
//! //!
//! Pure: an allocator and plain values, no `std.Io`, no clock, no entropy //! 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
//! source. The hash seed arrives as a parameter. //! source. The hash seed arrives as a parameter.
const std = @import("std"); const std = @import("std");
@@ -17,14 +23,40 @@ const model = @import("../config/model.zig");
const name = @import("../dns/name.zig"); const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig"); const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig"); const domain_set = @import("domain_set.zig");
const regex = @import("regex.zig");
const wildcard = @import("wildcard.zig"); const wildcard = @import("wildcard.zig");
pub const Error = error{ OutOfMemory, BadPattern, TooManyWildcards } || domain_set.DomainSet.Error; pub const Error = error{
OutOfMemory,
BadPattern,
TooManyWildcards,
TooManyRegexRules,
} || domain_set.DomainSet.Error;
/// Both wildcard lists of one group together. The cap exists so a rules table /// 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. /// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096; 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 { pub const RuleSet = struct {
exact_allow: domain_set.DomainSet = .empty, exact_allow: domain_set.DomainSet = .empty,
exact_block: domain_set.DomainSet = .empty, exact_block: domain_set.DomainSet = .empty,
@@ -32,75 +64,115 @@ pub const RuleSet = struct {
/// order and the same first match. /// order and the same first match.
wildcard_allow: []const []const u8 = &.{}, wildcard_allow: []const []const u8 = &.{},
wildcard_block: []const []const u8 = &.{}, wildcard_block: []const []const u8 = &.{},
/// One block holding the bytes of both wildcard lists; freed as a unit. /// Sorted and deduplicated like the wildcards, so the first regex to match a
wildcard_bytes: []const u8 = &.{}, /// 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 = &.{},
pub const empty: RuleSet = .{}; pub const empty: RuleSet = .{};
/// `rows` are one group's rules only; splitting `listRules` output by group /// `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. /// belongs to the caller, which is the only holder of the group table.
/// ///
/// Patterns are normalized (lowercase over ASCII, one trailing dot /// Name patterns are normalized (lowercase over ASCII, one trailing dot
/// stripped) and validated: `.exact` through `dns.name.fromText`, /// stripped) and validated: `.exact` through `dns.name.fromText`,
/// `.wildcard` through `wildcard.validate`. An invalid pattern is /// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An
/// `error.BadPattern`, not a skipped row — every pattern passed /// invalid pattern is `error.BadPattern`, not a skipped row — every pattern
/// `config/validate.zig` on the way in, so an invalid one here means the /// passed `config/validate.zig` on the way in, so an invalid one here means
/// rows were edited underneath nxdns and a silently dropped allow rule /// the rows were edited underneath nxdns and a silently dropped allow rule
/// would block a domain the operator unblocked. /// would block a domain the operator unblocked.
pub fn build(gpa: Allocator, rows: []const model.Rule, seed: u64) Error!RuleSet { ///
/// 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 {
if (rows.len == 0) return .empty; if (rows.len == 0) return .empty;
var scratch: std.ArrayList(u8) = .empty; var joined: std.ArrayList(u8) = .empty;
defer scratch.deinit(gpa); defer joined.deinit(scratch);
var spans: [4]std.ArrayList(Span) = .{ .empty, .empty, .empty, .empty }; var spans: [6]std.ArrayList(Span) = @splat(.empty);
defer for (&spans) |*bucket| bucket.deinit(gpa); defer for (&spans) |*bucket| bucket.deinit(scratch);
var wildcards: usize = 0; var wildcards: usize = 0;
var regexes: usize = 0;
var buf: [types.max_name_len]u8 = undefined; var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| { for (rows) |row| {
const pattern = normalize(row.pattern, &buf) catch return error.BadPattern; const pattern = switch (row.kind) {
switch (row.kind) { .exact => blk: {
.exact => _ = name.fromText(pattern) catch return error.BadPattern, const text = normalize(row.pattern, &buf) catch return error.BadPattern;
.wildcard => { _ = name.fromText(text) catch return error.BadPattern;
wildcard.validate(pattern) 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;
wildcards += 1; wildcards += 1;
if (wildcards > max_wildcards_per_group) return error.TooManyWildcards; 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)]; const bucket = &spans[bucketOf(row.kind, row.action)];
try bucket.append(gpa, .{ .offset = scratch.items.len, .len = pattern.len }); try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len });
try scratch.appendSlice(gpa, pattern); try joined.appendSlice(scratch, pattern);
} }
// `scratch` stops growing here, so spans can become slices of it. // `joined` stops growing here, so spans can become slices of it.
var sorted: [4]std.ArrayList([]const u8) = .{ .empty, .empty, .empty, .empty }; var sorted: [6]std.ArrayList([]const u8) = @splat(.empty);
defer for (&sorted) |*bucket| bucket.deinit(gpa); defer for (&sorted) |*bucket| bucket.deinit(scratch);
for (&spans, &sorted) |*bucket, *out| { for (&spans, &sorted) |*bucket, *out| {
try out.ensureTotalCapacityPrecise(gpa, bucket.items.len); try out.ensureTotalCapacityPrecise(scratch, bucket.items.len);
for (bucket.items) |span| { for (bucket.items) |span| {
out.appendAssumeCapacity(scratch.items[span.offset..][0..span.len]); out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]);
} }
std.mem.sort([]const u8, out.items, {}, lessThanBytes); std.mem.sort([]const u8, out.items, {}, lessThanBytes);
dedupSorted(out); dedupSorted(out);
} }
var self: RuleSet = .empty; var self: RuleSet = .empty;
errdefer self.deinit(gpa); errdefer self.deinit(perm);
self.exact_allow = try buildSet(gpa, sorted[bucketOf(.exact, .allow)].items, seed); self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed);
self.exact_block = try buildSet(gpa, sorted[bucketOf(.exact, .block)].items, seed); self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed);
const allow = sorted[bucketOf(.wildcard, .allow)].items; const wild_allow = sorted[bucketOf(.wildcard, .allow)].items;
const block = sorted[bucketOf(.wildcard, .block)].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;
var total: usize = 0; var total: usize = 0;
for (allow) |pattern| total += pattern.len; for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| {
for (block) |pattern| total += pattern.len; for (list) |pattern| total += pattern.len;
}
const bytes = try gpa.alloc(u8, total); const bytes = try perm.alloc(u8, total);
self.wildcard_bytes = bytes; self.pattern_bytes = bytes;
var at: usize = 0; var at: usize = 0;
self.wildcard_allow = try copyPatterns(gpa, allow, bytes, &at); self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at);
self.wildcard_block = try copyPatterns(gpa, block, 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);
return self; return self;
} }
@@ -110,18 +182,35 @@ pub const RuleSet = struct {
self.exact_block.deinit(gpa); self.exact_block.deinit(gpa);
gpa.free(self.wildcard_allow); gpa.free(self.wildcard_allow);
gpa.free(self.wildcard_block); gpa.free(self.wildcard_block);
gpa.free(self.wildcard_bytes); freeRules(gpa, self.regex_allow);
freeRules(gpa, self.regex_block);
gpa.free(self.pattern_bytes);
self.* = .empty; self.* = .empty;
} }
pub fn memoryBytes(self: *const RuleSet) usize { 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() + return self.exact_allow.memoryBytes() +
self.exact_block.memoryBytes() + self.exact_block.memoryBytes() +
self.wildcard_bytes.len + self.pattern_bytes.len +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8); programs +
(self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) +
(self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule);
} }
}; };
/// 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 // Internals
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -131,15 +220,16 @@ pub const RuleSet = struct {
const Span = struct { offset: usize, len: usize }; const Span = struct { offset: usize, len: usize };
fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize { fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
const kind_bit: usize = switch (kind) { const kind_base: usize = switch (kind) {
.exact => 0, .exact => 0,
.wildcard => 2, .wildcard => 2,
.regex => 4,
}; };
const action_bit: usize = switch (action) { const action_offset: usize = switch (action) {
.allow => 0, .allow => 0,
.block => 1, .block => 1,
}; };
return kind_bit + action_bit; return kind_base + action_offset;
} }
fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool { fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
@@ -159,26 +249,31 @@ fn dedupSorted(list: *std.ArrayList([]const u8)) void {
list.shrinkRetainingCapacity(kept); list.shrinkRetainingCapacity(kept);
} }
fn buildSet(gpa: Allocator, patterns: []const []const u8, seed: u64) Error!domain_set.DomainSet { fn buildSet(
perm: Allocator,
scratch: Allocator,
patterns: []const []const u8,
seed: u64,
) Error!domain_set.DomainSet {
if (patterns.len == 0) return .empty; if (patterns.len == 0) return .empty;
var body: std.ArrayList(u8) = .empty; var body: std.ArrayList(u8) = .empty;
defer body.deinit(gpa); defer body.deinit(scratch);
for (patterns) |pattern| { for (patterns) |pattern| {
try body.appendSlice(gpa, pattern); try body.appendSlice(scratch, pattern);
try body.append(gpa, '\n'); try body.append(scratch, '\n');
} }
return domain_set.DomainSet.build(gpa, body.items, seed); return domain_set.DomainSet.build(perm, body.items, seed);
} }
fn copyPatterns( fn copyPatterns(
gpa: Allocator, perm: Allocator,
patterns: []const []const u8, patterns: []const []const u8,
bytes: []u8, bytes: []u8,
at: *usize, at: *usize,
) Error![]const []const u8 { ) Error![]const []const u8 {
if (patterns.len == 0) return &.{}; if (patterns.len == 0) return &.{};
const out = try gpa.alloc([]const u8, patterns.len); const out = try perm.alloc([]const u8, patterns.len);
for (out, patterns) |*slot, pattern| { for (out, patterns) |*slot, pattern| {
@memcpy(bytes[at.*..][0..pattern.len], pattern); @memcpy(bytes[at.*..][0..pattern.len], pattern);
slot.* = bytes[at.*..][0..pattern.len]; slot.* = bytes[at.*..][0..pattern.len];
@@ -187,6 +282,49 @@ fn copyPatterns(
return out; 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}; const NameError = error{BadName};
/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is /// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
@@ -223,7 +361,7 @@ test "exact rules land in the matching set" {
rule("ads.example.com", .exact, .block), rule("ads.example.com", .exact, .block),
rule("good.example.com", .exact, .allow), rule("good.example.com", .exact, .allow),
}; };
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed); var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expect(set.exact_block.contains("ads.example.com"));
@@ -239,7 +377,7 @@ test "wildcard rules land in the matching list, sorted" {
rule("*.a.example.com", .wildcard, .block), rule("*.a.example.com", .wildcard, .block),
rule("*.allowed.example.com", .wildcard, .allow), rule("*.allowed.example.com", .wildcard, .allow),
}; };
var set = try RuleSet.build(testing.allocator, &rows, 0x5eed); var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), set.wildcard_block.len); try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
@@ -254,7 +392,7 @@ test "patterns are normalized to lowercase without a trailing dot" {
rule("ADS.Example.COM.", .exact, .block), rule("ADS.Example.COM.", .exact, .block),
rule("*.Tracker.NET.", .wildcard, .block), rule("*.Tracker.NET.", .wildcard, .block),
}; };
var set = try RuleSet.build(testing.allocator, &rows, 0); var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expect(set.exact_block.contains("ads.example.com"));
@@ -268,7 +406,7 @@ test "duplicate rows collapse to one entry" {
rule("*.x.example.com", .wildcard, .block), rule("*.x.example.com", .wildcard, .block),
rule("*.x.example.com", .wildcard, .block), rule("*.x.example.com", .wildcard, .block),
}; };
var set = try RuleSet.build(testing.allocator, &rows, 0); var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 1), set.exact_block.count); try testing.expectEqual(@as(u32, 1), set.exact_block.count);
@@ -278,14 +416,98 @@ test "duplicate rows collapse to one entry" {
test "an invalid exact pattern is an error" { test "an invalid exact pattern is an error" {
for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| { for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .exact, .block)}; const rows = [_]model.Rule{rule(pattern, .exact, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0)); try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, 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" { test "an invalid wildcard pattern is an error" {
for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| { for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
const rows = [_]model.Rule{rule(pattern, .wildcard, .block)}; const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, &rows, 0)); try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
} }
} }
@@ -305,11 +527,11 @@ test "too many wildcards is an error" {
row.* = rule(pattern, .wildcard, .block); row.* = rule(pattern, .wildcard, .block);
} }
try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, rows, 0)); try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0));
} }
test "an empty rule list builds the empty set" { test "an empty rule list builds the empty set" {
var set = try RuleSet.build(testing.allocator, &[_]model.Rule{}, 0); var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expect(!set.exact_block.contains("ads.example.com")); try testing.expect(!set.exact_block.contains("ads.example.com"));
@@ -328,11 +550,63 @@ test "memoryBytes counts every part" {
rule("ads.example.com", .exact, .block), rule("ads.example.com", .exact, .block),
rule("*.tracker.net", .wildcard, .block), rule("*.tracker.net", .wildcard, .block),
}; };
var set = try RuleSet.build(testing.allocator, &rows, 0); var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
defer set.deinit(testing.allocator); defer set.deinit(testing.allocator);
try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes()); try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
try testing.expect(set.memoryBytes() >= "*.tracker.net".len); 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 { fn buildUnderFailure(gpa: Allocator) !void {
@@ -341,11 +615,14 @@ fn buildUnderFailure(gpa: Allocator) !void {
rule("good.example.com", .exact, .allow), rule("good.example.com", .exact, .allow),
rule("*.tracker.net", .wildcard, .block), rule("*.tracker.net", .wildcard, .block),
rule("*.ok.tracker.net", .wildcard, .allow), rule("*.ok.tracker.net", .wildcard, .allow),
rule("^ad[0-9]+-", .regex, .block),
rule("\\.ok\\.", .regex, .allow),
}; };
var set = try RuleSet.build(gpa, &rows, 0x5eed); var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed);
defer set.deinit(gpa); defer set.deinit(gpa);
try testing.expect(set.exact_block.contains("ads.example.com")); try testing.expect(set.exact_block.contains("ads.example.com"));
try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]); 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" { test "build leaks nothing under allocation failure" {
+5 -3
View File
@@ -4,7 +4,8 @@
//! A pattern is a domain name in which one or more labels are exactly `*`. //! 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 //! 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 //! globbing (`ad*.example.com`) is deliberately absent: it is regex by another
//! name, which PLAN §2.2 rules out. //! 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.
const std = @import("std"); const std = @import("std");
@@ -18,8 +19,9 @@ pub const PatternError = error{
/// No label is exactly "*". /// No label is exactly "*".
NoWildcard, NoWildcard,
/// A label contains '*' but is not exactly "*". Partial-label globbing /// A label contains '*' but is not exactly "*". Partial-label globbing
/// (`ad*.example.com`) is out of scope: it is regex by another name, and /// (`ad*.example.com`) is out of scope: PLAN §3.9 defines the wildcard as a
/// PLAN §3.9 defines the wildcard as a label pattern. /// label pattern, and the `.regex` kind covers what partial globbing was
/// wanted for.
PartialWildcardLabel, PartialWildcardLabel,
EmptyLabel, EmptyLabel,
LabelTooLong, LabelTooLong,
+106 -20
View File
@@ -47,7 +47,7 @@ const licenses = @import("licenses_files");
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in /// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
/// a CommonJS module. Every other devDependency left `web/dist` untouched. /// a CommonJS module. Every other devDependency left `web/dist` untouched.
/// Anything added here must also be inventoried. /// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" }; const npm_generators = [_][]const u8{ "rolldown", "vite" };
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so /// 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 /// the inventory names them in a note rather than carrying their licence. The
@@ -57,7 +57,23 @@ const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
/// This list is a claim about the build, and the build is what settles it: a /// 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 /// name here that turns up in the recorded `web/dist` bundle fails, so a
/// package that starts shipping cannot stay on this list quietly. /// package that starts shipping cannot stay on this list quietly.
const npm_not_shipped = [_][]const u8{ "cookie-es", "isbot", "seroval", "seroval-plugins" }; 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",
};
/// Components the shipped artifacts contain that no dependency file mentions, /// Components the shipped artifacts contain that no dependency file mentions,
/// so no automatic check would ever notice their disappearance from the /// so no automatic check would ever notice their disappearance from the
@@ -71,7 +87,6 @@ const required_components = [_][]const u8{
"p256-m", "p256-m",
"React", "React",
"TanStack", "TanStack",
"Tailwind",
"Vite", "Vite",
"Rolldown", "Rolldown",
"Mozilla CA certificate bundle", "Mozilla CA certificate bundle",
@@ -103,15 +118,61 @@ const ZigDependencyVersion = struct {
}, },
}; };
/// The licence every *shipped* npm package carries today, and the licence of /// The licence each npm package of the recorded sets carries today. The identity
/// every npm text under `licenses/`. The identity file records a licence token /// file records a licence token per package; nothing used to read it, so a
/// per package; nothing used to read it, so a package that relicensed passed as /// package that relicensed passed as long as its version had not moved.
/// 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 /// MIT is the default because almost every package here is MIT, so only the
/// review, not a paste. The `npm_not_shipped` packages are exempt — they /// exceptions are written down. A package whose recorded token stops matching
/// redistribute nothing, and one of them is Unlicense. /// 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.
const npm_expected_licence = "MIT"; 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 two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they /// 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, /// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
@@ -481,22 +542,21 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
} }
} }
test "every npm package in the recorded closure ships under the licence the inventory assumes" { test "every npm package in the recorded closure carries the licence the inventory expects" {
for ([_][]const u8{ npm_section, generator_section }) |header| { for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header); const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n'); var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| { while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine; const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
// A package that puts no byte in web/dist redistributes nothing, so const expected = expectedLicence(pkg.name);
// its licence carries no obligation. isbot is Unlicense and is one if (!std.mem.eql(u8, pkg.licence, expected)) {
// of these.
if (isNotShipped(pkg.name)) continue;
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
std.debug.print( std.debug.print(
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++ "npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" licenses/ is an {s} text, so this package needs one of its own and a" ++ " '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" decision about EUPL-1.2 compatibility.\n", " it a text under licenses/ and an inventory entry naming who accepted it if" ++
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence }, " this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
); );
return error.NpmLicenceChanged; return error.NpmLicenceChanged;
} }
@@ -504,6 +564,32 @@ test "every npm package in the recorded closure ships under the licence the inve
} }
} }
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" { test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| { for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse { const body = textBody(pinned.file) orelse {
+14 -5
View File
@@ -1,9 +1,15 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every //! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! other storage session needs. //! other storage session needs.
//! //!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and //! The DDL text is data, not code: `migrations.zig` carries it as step 1.
//! 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. //! 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.
const std = @import("std"); const std = @import("std");
@@ -40,7 +46,8 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY, \\ id INTEGER PRIMARY KEY,
\\ url TEXT NOT NULL UNIQUE, \\ url TEXT NOT NULL UNIQUE,
\\ priority INTEGER NOT NULL DEFAULT 100, \\ priority INTEGER NOT NULL DEFAULT 100,
\\ enabled INTEGER NOT NULL DEFAULT 1 \\ enabled INTEGER NOT NULL DEFAULT 1,
\\ tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
\\); \\);
\\ \\
\\CREATE TABLE blocklist_sources ( \\CREATE TABLE blocklist_sources (
@@ -52,7 +59,9 @@ pub const ddl_v1: [:0]const u8 =
\\ last_updated INTEGER, \\ last_updated INTEGER,
\\ domain_count INTEGER NOT NULL DEFAULT 0, \\ domain_count INTEGER NOT NULL DEFAULT 0,
\\ wildcard_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_regex_count INTEGER NOT NULL DEFAULT 0,
\\ skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
\\ checksum TEXT \\ checksum TEXT
\\); \\);
\\ \\
@@ -66,7 +75,7 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY, \\ id INTEGER PRIMARY KEY,
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, \\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
\\ pattern TEXT NOT NULL, \\ pattern TEXT NOT NULL,
\\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')), \\ kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
\\ action TEXT NOT NULL CHECK(action IN ('allow','block')), \\ action TEXT NOT NULL CHECK(action IN ('allow','block')),
\\ created_at INTEGER NOT NULL \\ created_at INTEGER NOT NULL
\\); \\);
+55 -48
View File
@@ -18,19 +18,18 @@ const log = std.log.scoped(.migrations);
pub const Step = struct { version: u32, sql: [:0]const u8 }; pub const Step = struct { version: u32, sql: [:0]const u8 };
/// Append only. Editing a released step — or `config_schema.ddl_v1` — would make /// One baseline, no steps. Until v0.1 a schema change edits
/// a fresh database and an upgraded one disagree, and nothing would detect it. /// `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.
pub const steps = [_]Step{ pub const steps = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 }, .{ .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 /// The schema version this binary expects. A database `readVersion` reports
/// below this needs `nxdns run` to migrate it; above it is `error.SchemaTooNew` /// below this needs `nxdns run` to migrate it; above it is `error.SchemaTooNew`
/// and needs a newer nxdns. /// and needs a newer nxdns.
@@ -112,7 +111,7 @@ pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32 {
/// ///
/// Reads only, so it works on a connection opened `.read_only` or /// 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 /// `.immutable`. That is what it is public for: `nxdns check` may not migrate
/// (ruling F-c), and "at version 1, this binary expects 2" tells an operator /// (ruling F-c), and "at version 0, this binary expects 1" tells an operator
/// what to do where a bare SQLite error message does not. /// what to do where a bare SQLite error message does not.
pub fn readVersion(database: *db.Db) Error!u32 { pub fn readVersion(database: *db.Db) Error!u32 {
const present = try database.queryInt( const present = try database.queryInt(
@@ -264,52 +263,62 @@ fn columnExists(database: *db.Db, table: []const u8, column: []const u8) !bool {
return stmt.columnInt(0) != 0; return stmt.columnInt(0) != 0;
} }
test "a fresh database reaches version 2 with the tls_name column" { test "a fresh database reaches the baseline with every v1 column and rule kind" {
var database = try openMigrated(); var database = try openMigrated();
defer database.close(); defer database.close();
try testing.expectEqual(@as(u32, 2), try migrate(&database)); try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expectEqual(@as(u32, 2), target_version); try testing.expectEqual(@as(u32, 1), target_version);
try testing.expect(try columnExists(&database, "upstreams", "tls_name")); 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 "a version 1 database upgrades to 2 and keeps its rows with an empty tls_name" { test "the baseline rules table cascades from its group" {
var database = try openMigrated(); var database = try openMigrated();
defer database.close(); defer database.close();
_ = try migrate(&database);
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }}; try database.exec(
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first)); \\INSERT INTO groups (id, name) VALUES (2, 'kids');
try testing.expect(!try columnExists(&database, "upstreams", "tls_name")); \\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);
try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);"); try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);");
try testing.expectEqual(@as(u32, 2), try migrate(&database)); // Rollback of an *upgrade* is a different case from rollback of the initial
try testing.expectEqual(@as(u32, 2), try readVersion(&database)); // creation ("a failing step rolls the whole migration back"): here a
try testing.expect(try columnExists(&database, "upstreams", "tls_name")); // populated database must come back untouched, not cease to exist.
const broken = steps ++ [_]Step{
var stmt = try database.prepare("SELECT url, tls_name FROM upstreams"); .{ .version = target_version + 1, .sql = "CREATE TABLE second (id INTEGER PRIMARY KEY);" },
defer stmt.deinit(); .{ .version = target_version + 2, .sql = "CREATE TABLE third (" },
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)); try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));
// One transaction: the ALTER TABLE of step 2 went back with step 3. // One transaction: the step that did succeed went back with the one that did not.
try testing.expect(!try columnExists(&database, "upstreams", "tls_name")); try testing.expect(!try tableExists(&database, "second"));
try testing.expectEqual(@as(u32, 1), try readVersion(&database)); try testing.expectEqual(target_version, try readVersion(&database));
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
} }
test "readVersion reports 0 before a migration and target_version after it" { test "readVersion reports 0 before a migration and target_version after it" {
@@ -333,20 +342,18 @@ test "readVersion reads a file database through an immutable open, writing nothi
var path_buf: [tmp_prefix.len + sub_path_len + 32]u8 = undefined; 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 }); const path = try std.fmt.bufPrintZ(&path_buf, "{s}{s}/config.db", .{ tmp_prefix, &tmp.sub_path });
// A database an older nxdns left at version 1. `check` must report that, not // `check` reads the stamped version without migrating (ruling F-c), so the
// migrate it (ruling F-c). // read has to work through a connection that cannot write at all.
{ {
var database = try db.Db.open(path, .{ .mode = .read_write_create }); var database = try db.Db.open(path, .{ .mode = .read_write_create });
defer database.close(); defer database.close();
try db.applyPragmas(&database, .{}); try db.applyPragmas(&database, .{});
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }}; try testing.expectEqual(target_version, try migrate(&database));
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
} }
var database = try db.Db.open(path, .{ .mode = .{ .immutable = testing.io } }); var database = try db.Db.open(path, .{ .mode = .{ .immutable = testing.io } });
defer database.close(); defer database.close();
try testing.expectEqual(@as(u32, 1), try readVersion(&database)); try testing.expectEqual(target_version, try readVersion(&database));
try testing.expectEqual(@as(u32, 2), target_version);
// A write through this connection is refused by SQLite, not by convention. // A write through this connection is refused by SQLite, not by convention.
try testing.expectError(error.ReadOnly, database.exec("DELETE FROM schema_version;")); try testing.expectError(error.ReadOnly, database.exec("DELETE FROM schema_version;"));
+25
View File
@@ -281,6 +281,31 @@ 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" { test "a duplicate rule is accepted and stays deterministically ordered by id" {
var database = try openMigrated(); var database = try openMigrated();
defer database.close(); defer database.close();
+43 -7
View File
@@ -1,9 +1,10 @@
//! `blocklist_sources`. //! `blocklist_sources`.
//! //!
//! Only the four configuration columns are read and written. `last_updated`, //! Only the four configuration columns are read and written. `last_updated`,
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are //! `domain_count`, `wildcard_count`, `exception_count`, `skipped_regex_count`,
//! facts a running server produces; an insert leaves them at their column //! `skipped_unsupported_count` and `checksum` are facts a running server
//! defaults so two exports taken minutes apart stay identical. //! 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 import path is list / insert / deleteAll / count; the runtime columns and
//! the REST surface follow it, both keyed by row id. //! the REST surface follow it, both keyed by row id.
@@ -88,7 +89,17 @@ pub const SourceRow = struct {
last_updated: ?i64, last_updated: ?i64,
domain_count: i64, domain_count: i64,
wildcard_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, 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, checksum: ?[]const u8,
}; };
@@ -96,15 +107,19 @@ pub const SourceStats = struct {
last_updated: i64, last_updated: i64,
domain_count: i64, domain_count: i64,
wildcard_count: i64, wildcard_count: i64,
exception_count: i64,
skipped_regex_count: i64, skipped_regex_count: i64,
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body. 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.
checksum: []const u8, checksum: []const u8,
}; };
const row_columns_sql = const row_columns_sql =
\\SELECT id, url, name, enabled, last_updated, \\SELECT id, url, name, enabled, last_updated,
\\ domain_count, wildcard_count, skipped_regex_count, checksum, \\ domain_count, wildcard_count, skipped_regex_count, checksum,
\\ is_suggested \\ is_suggested, exception_count, skipped_unsupported_count
\\ FROM blocklist_sources \\ FROM blocklist_sources
; ;
@@ -133,7 +148,9 @@ fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4), .last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5), .domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6), .wildcard_count = stmt.columnInt(6),
.exception_count = stmt.columnInt(10),
.skipped_regex_count = stmt.columnInt(7), .skipped_regex_count = stmt.columnInt(7),
.skipped_unsupported_count = stmt.columnInt(11),
.checksum = checksum, .checksum = checksum,
}; };
} }
@@ -150,7 +167,8 @@ pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
const update_stats_sql = const update_stats_sql =
\\UPDATE blocklist_sources \\UPDATE blocklist_sources
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4, \\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
\\ skipped_regex_count = ?5, checksum = ?6 \\ skipped_regex_count = ?5, checksum = ?6, exception_count = ?7,
\\ skipped_unsupported_count = ?8
\\ WHERE id = ?1 \\ WHERE id = ?1
; ;
@@ -165,6 +183,8 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
try stmt.bindInt(4, stats.wildcard_count); try stmt.bindInt(4, stats.wildcard_count);
try stmt.bindInt(5, stats.skipped_regex_count); try stmt.bindInt(5, stats.skipped_regex_count);
try stmt.bindText(6, stats.checksum); try stmt.bindText(6, stats.checksum);
try stmt.bindInt(7, stats.exception_count);
try stmt.bindInt(8, stats.skipped_unsupported_count);
try stmt.exec(); try stmt.exec();
} }
@@ -304,7 +324,13 @@ test "insertBlocklistSource leaves the runtime columns at their defaults" {
); );
try testing.expectEqual( try testing.expectEqual(
@as(i64, 0), @as(i64, 0),
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"), 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",
),
); );
} }
@@ -355,7 +381,9 @@ 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(?[]const u8, null), row.checksum);
try testing.expectEqual(@as(i64, 0), row.domain_count); 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.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_regex_count);
try testing.expectEqual(@as(i64, 0), row.skipped_unsupported_count);
} }
} }
@@ -373,7 +401,9 @@ test "updateSourceStats writes the runtime columns of one source only" {
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 4321, .domain_count = 4321,
.wildcard_count = 21, .wildcard_count = 21,
.exception_count = 9,
.skipped_regex_count = 7, .skipped_regex_count = 7,
.skipped_unsupported_count = 15,
.checksum = "a" ** 64, .checksum = "a" ** 64,
}); });
@@ -389,7 +419,9 @@ 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, 1_700_000_000), updated.last_updated);
try testing.expectEqual(@as(i64, 4321), updated.domain_count); try testing.expectEqual(@as(i64, 4321), updated.domain_count);
try testing.expectEqual(@as(i64, 21), updated.wildcard_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, 7), updated.skipped_regex_count);
try testing.expectEqual(@as(i64, 15), updated.skipped_unsupported_count);
try testing.expectEqualStrings("a" ** 64, updated.checksum.?); try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
// The two untouched rows kept their defaults. // The two untouched rows kept their defaults.
@@ -405,7 +437,9 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
.last_updated = 1, .last_updated = 1,
.domain_count = 2, .domain_count = 2,
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 5,
.skipped_regex_count = 4, .skipped_regex_count = 4,
.skipped_unsupported_count = 6,
.checksum = "b" ** 64, .checksum = "b" ** 64,
}); });
@@ -470,7 +504,9 @@ test "updateSource leaves the runtime columns where the refresh path left them"
.last_updated = 1_700_000_000, .last_updated = 1_700_000_000,
.domain_count = 12, .domain_count = 12,
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 4,
.checksum = "c" ** 64, .checksum = "c" ** 64,
}); });
+1
View File
@@ -58,6 +58,7 @@ comptime {
_ = @import("filter/parser_domains.zig"); _ = @import("filter/parser_domains.zig");
_ = @import("filter/parser_abp.zig"); _ = @import("filter/parser_abp.zig");
_ = @import("filter/wildcard.zig"); _ = @import("filter/wildcard.zig");
_ = @import("filter/regex.zig");
_ = @import("filter/domain_set.zig"); _ = @import("filter/domain_set.zig");
_ = @import("filter/rules.zig"); _ = @import("filter/rules.zig");
_ = @import("filter/matcher.zig"); _ = @import("filter/matcher.zig");
+18 -1
View File
@@ -59,7 +59,9 @@ pub const StatusView = struct {
last_error: []const u8, last_error: []const u8,
domains: u32, domains: u32,
wildcards: u32, wildcards: u32,
exceptions: u32,
skipped_regex: u32, skipped_regex: u32,
skipped_unsupported: u32,
pub fn from(status: *const manager_mod.SourceStatus) StatusView { pub fn from(status: *const manager_mod.SourceStatus) StatusView {
return .{ return .{
@@ -72,7 +74,9 @@ pub const StatusView = struct {
.last_error = status.errorText(), .last_error = status.errorText(),
.domains = status.counts.domains, .domains = status.counts.domains,
.wildcards = status.counts.wildcards, .wildcards = status.counts.wildcards,
.exceptions = status.counts.exceptions,
.skipped_regex = status.counts.skipped_regex, .skipped_regex = status.counts.skipped_regex,
.skipped_unsupported = status.counts.skipped_unsupported,
}; };
} }
}; };
@@ -317,7 +321,9 @@ test "editing a blocklist keeps the counters the refresh wrote" {
.last_updated = 1700, .last_updated = 1700,
.domain_count = 42, .domain_count = 42,
.wildcard_count = 3, .wildcard_count = 3,
.exception_count = 2,
.skipped_regex_count = 1, .skipped_regex_count = 1,
.skipped_unsupported_count = 8,
.checksum = "abc", .checksum = "abc",
}); });
@@ -332,6 +338,8 @@ test "editing a blocklist keeps the counters the refresh wrote" {
try testing.expectEqualStrings("renamed", row.name); try testing.expectEqualStrings("renamed", row.name);
try testing.expect(!row.enabled); try testing.expect(!row.enabled);
try testing.expectEqual(@as(i64, 42), row.domain_count); 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); try testing.expectEqual(@as(usize, 2), bench.reloads);
} }
@@ -380,7 +388,13 @@ test "a status becomes the flat shape the API answers with" {
const message = "connection refused"; const message = "connection refused";
@memcpy(status.last_error[0..message.len], message); @memcpy(status.last_error[0..message.len], message);
status.last_error_len = message.len; status.last_error_len = message.len;
status.counts = .{ .domains = 10, .wildcards = 2, .skipped_regex = 1 }; status.counts = .{
.domains = 10,
.wildcards = 2,
.exceptions = 4,
.skipped_regex = 1,
.skipped_unsupported = 6,
};
const view: StatusView = .from(&status); const view: StatusView = .from(&status);
try testing.expectEqual(@as(i64, 7), view.id); try testing.expectEqual(@as(i64, 7), view.id);
@@ -389,4 +403,7 @@ test "a status becomes the flat shape the API answers with" {
try testing.expectEqualStrings(url, view.url); try testing.expectEqualStrings(url, view.url);
try testing.expectEqualStrings(message, view.last_error); try testing.expectEqualStrings(message, view.last_error);
try testing.expectEqual(@as(u32, 10), view.domains); 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);
} }
+4 -1
View File
@@ -38,6 +38,8 @@ pub const Body = struct {
reason: []const u8, reason: []const u8,
/// The rule or list entry that decided it; "" when nothing matched. /// The rule or list entry that decided it; "" when nothing matched.
matched: []const u8, 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, source_url: ?[]const u8,
safe_search_rewrite: ?[]const u8, safe_search_rewrite: ?[]const u8,
}; };
@@ -50,7 +52,8 @@ pub const Result = struct {
blocked: bool, blocked: bool,
reason: matcher.Reason, reason: matcher.Reason,
matched: []const u8, matched: []const u8,
/// `blocklist_sources` row id of the list that matched. /// `blocklist_sources` row id of the list that matched, whether it blocked
/// the name or lifted it through an `@@` exception.
source_id: ?i64, source_id: ?i64,
safe_search_rewrite: ?[]const u8, safe_search_rewrite: ?[]const u8,
}; };
+44 -2
View File
@@ -39,7 +39,7 @@ const Created = union(enum) { id: i64, fail: Failure };
/// not understood. /// not understood.
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } { fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
const kind = model.RuleKind.fromDb(body.kind) orelse const kind = model.RuleKind.fromDb(body.kind) orelse
return .{ .fail = .{ .invalid = "kind must be 'exact' or 'wildcard'" } }; return .{ .fail = .{ .invalid = "kind must be 'exact', 'wildcard' or 'regex'" } };
const action = model.RuleAction.fromDb(body.action) orelse const action = model.RuleAction.fromDb(body.action) orelse
return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } }; return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
return .{ .input = .{ return .{ .input = .{
@@ -308,7 +308,7 @@ test "an unknown kind or action is a 400 before anything is written" {
try testing.expect(toInput(.{ try testing.expect(toInput(.{
.group_id = 1, .group_id = 1,
.pattern = "ads.example", .pattern = "ads.example",
.kind = "regex", .kind = "glob",
.action = "block", .action = "block",
}).fail == .invalid); }).fail == .invalid);
@@ -327,4 +327,46 @@ 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.RuleKind.wildcard, good.input.kind);
try testing.expectEqual(model.RuleAction.allow, good.input.action); 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);
} }
+9 -5
View File
@@ -1876,7 +1876,7 @@ components:
Blocklist: Blocklist:
type: object type: object
required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, skipped_regex_count, checksum] required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum]
properties: properties:
id: { type: integer } id: { type: integer }
url: { type: string } url: { type: string }
@@ -1888,7 +1888,9 @@ components:
nullable: true nullable: true
domain_count: { type: integer } domain_count: { type: integer }
wildcard_count: { type: integer } wildcard_count: { type: integer }
exception_count: { type: integer }
skipped_regex_count: { type: integer } skipped_regex_count: { type: integer }
skipped_unsupported_count: { type: integer }
checksum: checksum:
type: string type: string
nullable: true nullable: true
@@ -1918,7 +1920,7 @@ components:
SourceStatus: SourceStatus:
type: object type: object
required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, skipped_regex] required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported]
properties: properties:
id: { type: integer } id: { type: integer }
state: state:
@@ -1933,7 +1935,9 @@ components:
description: Empty when the last attempt succeeded. description: Empty when the last attempt succeeded.
domains: { type: integer } domains: { type: integer }
wildcards: { type: integer } wildcards: { type: integer }
exceptions: { type: integer }
skipped_regex: { type: integer } skipped_regex: { type: integer }
skipped_unsupported: { type: integer }
Rule: Rule:
type: object type: object
@@ -1945,7 +1949,7 @@ components:
pattern: { type: string } pattern: { type: string }
kind: kind:
type: string type: string
enum: [exact, wildcard] enum: [exact, wildcard, regex]
action: action:
type: string type: string
enum: [allow, block] enum: [allow, block]
@@ -1959,7 +1963,7 @@ components:
pattern: { type: string } pattern: { type: string }
kind: kind:
type: string type: string
enum: [exact, wildcard] enum: [exact, wildcard, regex]
action: action:
type: string type: string
enum: [allow, block] enum: [allow, block]
@@ -1973,7 +1977,7 @@ components:
pattern: { type: string } pattern: { type: string }
kind: kind:
type: string type: string
enum: [exact, wildcard] enum: [exact, wildcard, regex]
action: action:
type: string type: string
enum: [allow, block] enum: [allow, block]
+12
View File
@@ -1665,6 +1665,9 @@ fn createdId(body: []const u8) !i64 {
return std.fmt.parseInt(i64, rest[0..end], 10); 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 { fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
var buf: [64]u8 = undefined; var buf: [64]u8 = undefined;
try dir.writeFile(io, .{ try dir.writeFile(io, .{
@@ -1675,6 +1678,10 @@ 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}), .sub_path = try std.fmt.bufPrint(&buf, "{d}.wild", .{id}),
.data = "", .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 { fn accessCompiled(io: std.Io, dir: std.Io.Dir, id: i64) !void {
@@ -1726,6 +1733,11 @@ fn deleteSweepsCompiledFiles(io: std.Io, env: *Env) anyerror!void {
try std.fmt.bufPrint(&name_buf, "{d}.wild", .{doomed}), 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); try accessCompiled(io, dir, kept);
} }
+24 -3
View File
@@ -9,8 +9,10 @@
//! //!
//! - `Line.text` is always a slice of the caller's line, never a copy and //! - `Line.text` is always a slice of the caller's line, never a copy and
//! never a dangling pointer into a temporary; //! never a dangling pointer into a temporary;
//! - `covers_apex` is set only on a `.wildcard` line, because the compiler //! - `covers_apex` is set only on a `.wildcard` or an `.exception` line,
//! reads it only there; //! 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;
//! - `wildcard.matches` terminates for any pattern, validated or not, and a //! - `wildcard.matches` terminates for any pattern, validated or not, and a
//! match implies the domain has at least as many labels as the pattern, //! match implies the domain has at least as many labels as the pattern,
//! since every pattern label consumes at least one domain label. //! since every pattern label consumes at least one domain label.
@@ -75,6 +77,11 @@ fn formatTarget(format: parsers.Format, smith: *Smith) anyerror!void {
const parsed = parsers.parseLine(format, line); const parsed = parsers.parseLine(format, line);
try expectBorrowed(parsed, 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. /// The sniffer reads whole files, so this one keeps the line breaks.
@@ -113,7 +120,12 @@ fn wildcardTarget(_: void, smith: *Smith) anyerror!void {
/// The parser contract: `text` is a window into the caller's line, so the /// 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. /// compiler may keep it for the length of that line and no longer.
fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void { fn expectBorrowed(parsed: parsers.Line, line: []const u8) !void {
if (parsed.covers_apex) try std.testing.expectEqual(parsers.Kind.wildcard, parsed.kind); 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.text.len == 0) return; if (parsed.text.len == 0) return;
const start = @intFromPtr(parsed.text.ptr); const start = @intFromPtr(parsed.text.ptr);
@@ -146,6 +158,12 @@ 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. /// An ABP domain rule, which covers the apex as well as the subdomains.
const abp_line = "||ads.example.net^"; 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). /// A regex rule, which every parser counts and skips (PLAN §2.2).
const regex_line = "/^ads[0-9]+\\.example\\.org$/"; const regex_line = "/^ads[0-9]+\\.example\\.org$/";
@@ -161,6 +179,9 @@ const scheme_anchor = "|https://ads.example.com/track";
const corpus = [_][]const u8{ const corpus = [_][]const u8{
sliceInput(hosts_line), sliceInput(hosts_line),
sliceInput(abp_line), sliceInput(abp_line),
sliceInput(abp_exception),
sliceInput(abp_exception_important),
sliceInput(abp_exception_unanchored),
sliceInput(regex_line), sliceInput(regex_line),
sliceInput(long_line), sliceInput(long_line),
sliceInput(element_hiding), sliceInput(element_hiding),
+8 -1
View File
@@ -94,6 +94,8 @@ fn compileOnce(
var list_w: std.Io.Writer.Discarding = .init(&list_sink); var list_w: std.Io.Writer.Discarding = .init(&list_sink);
var wild_sink: [0]u8 = .{}; var wild_sink: [0]u8 = .{};
var wild_w: std.Io.Writer.Discarding = .init(&wild_sink); 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( return compiler.compile(
std.testing.allocator, std.testing.allocator,
@@ -101,6 +103,7 @@ fn compileOnce(
format, format,
&list_w.writer, &list_w.writer,
&wild_w.writer, &wild_w.writer,
&allow_w.writer,
) catch |err| switch (err) { ) catch |err| switch (err) {
error.OutOfMemory, error.OutOfMemory,
error.TooManyDomains, error.TooManyDomains,
@@ -122,12 +125,13 @@ fn expectConsistent(counts: compiler.Counts, bytes: []const u8) !void {
// A candidate is a non-empty whitespace-separated field or a whole wildcard // 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 // line, so every candidate consumes at least one byte of the input, and a
// written name is a candidate that survived. // written name is a candidate that survived.
const candidates = @as(u64, counts.domains) + counts.wildcards + const candidates = @as(u64, counts.domains) + counts.wildcards + counts.exceptions +
counts.duplicates + counts.invalid; counts.duplicates + counts.invalid;
try std.testing.expect(candidates <= bytes.len + 1); try std.testing.expect(candidates <= bytes.len + 1);
try std.testing.expect(counts.domains <= compiler.max_domains); try std.testing.expect(counts.domains <= compiler.max_domains);
try std.testing.expect(counts.wildcards <= compiler.max_domains); try std.testing.expect(counts.wildcards <= compiler.max_domains);
try std.testing.expect(counts.exceptions <= compiler.max_domains);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -154,6 +158,9 @@ const corpus = [_][]const u8{
sliceInput(long_line_terminated), sliceInput(long_line_terminated),
sliceInput("# a hosts list\n0.0.0.0 ads.example.com # advertising\n"), 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"), 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"), sliceInput("*.wild.example.org\nlocalhost\nAdS.Example.COM.\n"),
}; };
+149
View File
@@ -0,0 +1,149 @@
//! 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),
};
+40 -7
View File
@@ -10,8 +10,9 @@
//! What each suite measures: //! What each suite measures:
//! - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op — the handler's //! - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op — the handler's
//! filtering work — against a snapshot built from `--domains` generated exact //! filtering work — against a snapshot built from `--domains` generated exact
//! entries plus a small wildcard body. Query mix cycles hit, miss and //! entries, a small wildcard body and `bench_regex_rules` operator regex
//! parent-walk. Target p95 < 1 ms; VmRSS < 100 MiB with the list loaded. //! rules. 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`: `buildKey` + `DnsCache.get` + `packet.setId` — the handler's
//! cache-hit path, TTL aging included — on a 10k-entry cache prefilled with a //! 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. //! realistic response. Query mix alternates hit and miss. Target p95 < 5 ms.
@@ -38,6 +39,11 @@ const rss_target_bytes: usize = 100 * 1024 * 1024;
const cache_entries: u32 = 10_000; 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 /// 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 /// 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 /// test fixture is edited is a benchmark that silently shifts). A CNAME to
@@ -147,6 +153,23 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
try body.appendSlice(gpa, text); 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 sources = [_]model.BlocklistSource{.{ .url = "bench://list", .name = "bench" }};
const links = [_]model.GroupSource{.{ .group = "default", .source_url = "bench://list" }}; const links = [_]model.GroupSource{.{ .group = "default", .source_url = "bench://list" }};
var snapshot = try matcher.Snapshot.build(gpa, .{ var snapshot = try matcher.Snapshot.build(gpa, .{
@@ -155,7 +178,7 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
.group_sources = &links, .group_sources = &links,
.sources = &sources, .sources = &sources,
.source_ids = &.{1}, .source_ids = &.{1},
.rules = &.{}, .rules = &regex_rules,
.clients = &.{}, .clients = &.{},
.prefixes = &.{}, .prefixes = &.{},
.compiled = &.{.{ .list_body = body.items, .wild_body = wild_body }}, .compiled = &.{.{ .list_body = body.items, .wild_body = wild_body }},
@@ -205,9 +228,10 @@ fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 {
const pct = percentiles(samples); const pct = percentiles(samples);
const rss = vmRssBytes(io); const rss = vmRssBytes(io);
try printRow(w, "filter", opts.iters, pct); try printRow(w, "filter", opts.iters, pct);
try w.print(" blocked {d}/{d}, Snapshot.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n", .{ try w.print(
blocked, opts.iters, mib(snapshot.memoryBytes()), mib(rss), " 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) },
);
var exceeded: u32 = 0; var exceeded: u32 = 0;
exceeded += try printTarget(w, "p95 < 1ms", pct.p95 < filter_p95_target_ns); exceeded += try printTarget(w, "p95 < 1ms", pct.p95 < filter_p95_target_ns);
@@ -287,11 +311,20 @@ fn runCompile(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !void {
var reader = std.Io.Reader.fixed(body.items); var reader = std.Io.Reader.fixed(body.items);
var list_buf: [4096]u8 = undefined; var list_buf: [4096]u8 = undefined;
var wild_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 list_out: Writer.Discarding = .init(&list_buf);
var wild_out: Writer.Discarding = .init(&wild_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 t0 = std.Io.Clock.awake.now(io);
const result = compiler.compile(gpa, &reader, .hosts, &list_out.writer, &wild_out.writer) catch |err| { const result = compiler.compile(
gpa,
&reader,
.hosts,
&list_out.writer,
&wild_out.writer,
&allow_out.writer,
) catch |err| {
std.process.fatal("compiler.compile failed: {t}", .{err}); std.process.fatal("compiler.compile failed: {t}", .{err});
}; };
const t1 = std.Io.Clock.awake.now(io); const t1 = std.Io.Clock.awake.now(io);
+1111 -328
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -8,7 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && node scripts/stamp-dist.mjs", "build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts", "lint": "oxlint src vite.config.ts",
"format": "prettier --write .", "format": "prettier --write .",
@@ -25,13 +25,15 @@
"trailingComma": "all" "trailingComma": "all"
}, },
"dependencies": { "dependencies": {
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18", "@tanstack/react-router": "1.170.18",
"react": "19.2.8", "react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "4.3.3", "@stylexjs/unplugin": "0.19.0",
"@testing-library/dom": "10.4.1", "@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
"@types/node": "26.1.1", "@types/node": "26.1.1",
@@ -41,8 +43,7 @@
"jsdom": "29.1.1", "jsdom": "29.1.1",
"oxlint": "1.75.0", "oxlint": "1.75.0",
"prettier": "3.9.6", "prettier": "3.9.6",
"tailwindcss": "4.3.3", "typescript": "7.0.2",
"typescript": "6.0.3",
"vite": "8.1.5", "vite": "8.1.5",
"vitest": "4.1.10" "vitest": "4.1.10"
} }
+118
View File
@@ -0,0 +1,118 @@
#!/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`,
);
+59 -10
View File
@@ -1,8 +1,57 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router"; import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; 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,
},
});
export function safeRedirect(raw: string | undefined): string { export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/"; if (raw === undefined) return "/";
@@ -76,15 +125,15 @@ export default function LoginPage() {
} }
return ( return (
<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"> <main {...stylex.props(styles.page)}>
<section className="w-full max-w-sm"> <section {...stylex.props(styles.card)}>
<h1 className="text-2xl font-semibold">nxdns</h1> <h1 {...stylex.props(styles.heading)}>nxdns</h1>
{authRequired !== true ? ( {authRequired !== true ? (
<p className="mt-4 text-zinc-500">Checking whether a password is required</p> <p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
) : ( ) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium"> <label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
Password Password
</label> </label>
<input <input
@@ -95,20 +144,20 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={busy || lockedOut} disabled={busy || lockedOut}
className={`w-full ${largePrimaryButtonClass}`} {...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
> >
{busy ? "Logging in…" : "Log in"} {busy ? "Logging in…" : "Log in"}
</button> </button>
</form> </form>
)} )}
{error !== null && ( {error !== null && (
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400"> <p role="alert" {...stylex.props(styles.error)}>
{errorMessage(error, remaining)} {errorMessage(error, remaining)}
</p> </p>
)} )}
+54 -12
View File
@@ -1,10 +1,48 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types"; import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority"; 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 * 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 * else is a bug in this component and must reach the console instead of dying
@@ -46,10 +84,10 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2> <h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<div> <div>
<label htmlFor="blocklist-url" className="block text-sm font-medium"> <label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
URL URL
</label> </label>
<input <input
@@ -58,11 +96,11 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required required
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="blocklist-name" className="block text-sm font-medium"> <label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
Name Name
</label> </label>
<input <input
@@ -71,29 +109,33 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
required required
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={enabled}
onChange={(event) => setEnabled(event.target.checked)} onChange={(event) => setEnabled(event.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{initial === undefined ? "Add source" : "Save changes"} {initial === undefined ? "Add source" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}> <button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
>
Cancel Cancel
</button> </button>
)} )}
@@ -17,7 +17,9 @@ const BLOCKLISTS = {
last_updated: 1700000000, last_updated: 1700000000,
domain_count: 1000, domain_count: 1000,
wildcard_count: 10, wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3, skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc", checksum: "abc",
}, },
{ {
@@ -29,7 +31,9 @@ const BLOCKLISTS = {
last_updated: null, last_updated: null,
domain_count: 0, domain_count: 0,
wildcard_count: 0, wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0, skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null, checksum: null,
}, },
], ],
@@ -41,14 +45,20 @@ const RESPONSES: Record<string, unknown> = {
}; };
let resolveUpdate: ((response: Response) => void) | null; let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => { beforeEach(() => {
clearRefreshStatus(); clearRefreshStatus();
resolveUpdate = null; resolveUpdate = null;
deleted = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); 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") { if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => { return new Promise<Response>((resolve) => {
resolveUpdate = resolve; resolveUpdate = resolve;
@@ -92,7 +102,9 @@ const SNAPSHOT = {
last_error: "", last_error: "",
domains: 1200, domains: 1200,
wildcards: 12, wildcards: 12,
exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
], ],
}; };
@@ -106,8 +118,15 @@ test("renders the source table and the status empty state", async () => {
expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText("1000")).toBeTruthy(); expect(screen.getByText("1000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy(); expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).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; const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true); expect(enabledToggle.checked).toBe(true);
@@ -141,7 +160,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "", last_error: "",
domains: 1200, domains: 1200,
wildcards: 12, wildcards: 12,
exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
{ {
id: 2, id: 2,
@@ -153,7 +174,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "connect timed out", last_error: "connect timed out",
domains: 0, domains: 0,
wildcards: 0, wildcards: 0,
exceptions: 0,
skipped_regex: 0, skipped_regex: 0,
skipped_unsupported: 0,
}, },
], ],
}; };
@@ -166,7 +189,10 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("connect timed out")).toBeTruthy(); expect(screen.getByText("connect timed out")).toBeTruthy();
expect(screen.getByText("1200")).toBeTruthy(); expect(screen.getByText("1200")).toBeTruthy();
expect(screen.getByText("12")).toBeTruthy(); expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).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(); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands. // The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/); await screen.findByText(/Update completed/);
@@ -227,3 +253,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers(); 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"]));
});
+134 -49
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { import {
@@ -13,21 +14,79 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm"; import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore"; import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection"; import SourceStatusSection from "./SourceStatusSection";
import { import ConfirmDialog from "@/ui/ConfirmDialog";
dangerLinkButtonClass, import { styles as shared } from "@/ui/styles";
focusRing, import { colors } from "@/ui/tokens.stylex";
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; 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() { export default function BlocklistsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery()); const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null); const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient)); const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient)); const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -57,10 +116,10 @@ export default function BlocklistsPage() {
}); });
} }
function deleteBlocklist(b: Blocklist) { function confirmDelete() {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) { if (pendingDelete === null) return;
remove.mutate(b.id); remove.mutate(pendingDelete.id);
} setPendingDelete(null);
} }
const formError = editing === null ? create.error : save.error; const formError = editing === null ? create.error : save.error;
@@ -68,60 +127,58 @@ export default function BlocklistsPage() {
return ( return (
<section> <section>
<div className="flex flex-wrap items-center justify-between gap-3"> <div {...stylex.props(styles.header)}>
<h1 className="text-2xl font-semibold">Blocklists</h1> <h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button <button
type="button" type="button"
onClick={() => updateNow.mutate()} onClick={() => updateNow.mutate()}
disabled={updateNow.isPending} disabled={updateNow.isPending}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{updateNow.isPending ? "Updating…" : "Update now"} {updateNow.isPending ? "Updating…" : "Update now"}
</button> </button>
</div> </div>
{updateNow.isSuccess && !updateNow.isPending && ( {updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status"> <p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below. Update completed; source status refreshed below.
</p> </p>
)} )}
<InlineError error={updateNow.error} /> <InlineError error={updateNow.error} />
{blocklists.length === 0 ? ( {blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p> <p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Name</th> <th {...stylex.props(shared.th)}>Name</th>
<th className={thClass}>URL</th> <th {...stylex.props(shared.th)}>URL</th>
<th className={thClass}>Enabled</th> <th {...stylex.props(shared.th)}>Enabled</th>
<th className={thClass}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th className={thClass}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th className={thClass}>Skipped regex</th> <th {...stylex.props(shared.th)}>Exceptions</th>
<th className={thClass}>Last updated</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>Skipped unsupported</th>
<span className="sr-only">Actions</span> <th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{blocklists.map((b) => ( {blocklists.map((b) => (
<tr key={b.id}> <tr key={b.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="font-medium">{b.name}</span> <span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && ( {b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<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>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="block max-w-72 truncate" title={b.url}> <span {...stylex.props(styles.url)} title={b.url}>
{b.url} {b.url}
</span> </span>
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${b.name} enabled`} aria-label={`${b.name} enabled`}
@@ -129,32 +186,40 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td className={tdClass}> <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)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<div className="flex gap-3"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => setEditing(b)} onClick={() => setEditing(b)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`} {...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteBlocklist(b)} onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -164,6 +229,13 @@ export default function BlocklistsPage() {
))} ))}
</tbody> </tbody>
</table> </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> </div>
)} )}
<InlineError error={tableError} /> <InlineError error={tableError} />
@@ -179,6 +251,19 @@ export default function BlocklistsPage() {
/> />
<SourceStatusSection sources={sources} namesById={namesById} /> <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> </section>
); );
} }
@@ -1,6 +1,8 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types"; import type { SourceStatus } from "@/lib/types";
import { tdClass, thClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
function formatAttempt(unixSeconds: number): string { function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds); return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -11,61 +13,114 @@ interface SourceStatusSectionProps {
namesById: ReadonlyMap<number, string>; 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) { export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return ( return (
<section className="mt-8"> <section {...stylex.props(styles.section)}>
<h2 className="text-lg font-medium">Source status</h2> <h2 {...stylex.props(styles.heading)}>Source status</h2>
{sources === null ? ( {sources === null ? (
<p className="mt-2 text-zinc-500"> <p {...stylex.props(styles.note)}>
No status snapshot yet run Update now to fetch status for every enabled source. No status snapshot yet run Update now to fetch status for every enabled source.
</p> </p>
) : sources.length === 0 ? ( ) : sources.length === 0 ? (
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p> <p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
) : ( ) : (
<div className="mt-2 overflow-x-auto"> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Source</th> <th {...stylex.props(shared.th)}>Source</th>
<th className={thClass}>State</th> <th {...stylex.props(shared.th)}>State</th>
<th className={thClass}>Last attempt</th> <th {...stylex.props(shared.th)}>Last attempt</th>
<th className={thClass}>Last success</th> <th {...stylex.props(shared.th)}>Last success</th>
<th className={thClass}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th className={thClass}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th className={thClass}>Skipped regex</th> <th {...stylex.props(shared.th)}>Exceptions</th>
<th className={thClass}>Last error</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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sources.map((source) => ( {sources.map((source) => (
<tr key={source.id}> <tr key={source.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span> <span {...stylex.props(styles.name)}>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500"> {namesById.get(source.id) ?? source.url}
{source.url}
</span> </span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td> </td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span <span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
className={
source.loaded
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{source.state} {source.state}
</span> </span>
</td> </td>
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td> <td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td className={tdClass}>{formatAttempt(source.last_success)}</td> <td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td className={`${tdClass} tabular-nums`}>{source.domains}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td className={tdClass}> <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)}>
{source.last_error === "" ? ( {source.last_error === "" ? (
<span className="text-zinc-400"></span> <span {...stylex.props(styles.absent)}></span>
) : ( ) : (
<span className="text-red-600 dark:text-red-400">{source.last_error}</span> <span {...stylex.props(styles.failed)}>{source.last_error}</span>
)} )}
</td> </td>
</tr> </tr>
+47 -31
View File
@@ -1,9 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries"; import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types"; import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes"; import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -12,7 +15,34 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const dialogInputClass = `mt-1 w-full ${smallInputClass}`; 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",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) { export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -22,16 +52,10 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<div <h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
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"
>
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
<form <form
className="mt-4 space-y-4" {...stylex.props(styles.form)}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
mutation.mutate( mutation.mutate(
@@ -40,46 +64,38 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
); );
}} }}
> >
<label className="block text-sm font-medium"> <label {...stylex.props(styles.fieldLabel)}>
Name Name
<input <input
type="text" type="text"
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={dialogInputClass}
autoFocus autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm font-medium"> <Select
Group label="Group"
<select variant="compactField"
value={String(groupId)} value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))} onChange={(value) => setGroupId(Number(value))}
className={dialogInputClass} options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
> />
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
</label>
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="flex justify-end gap-2"> <div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} className={buttonClass}> <button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={mutation.isPending || readOnly} disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Save Save
</button> </button>
</div> </div>
</form> </form>
</div> </Dialog>
</div>
); );
} }
+20 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE); await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!); fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" }); // The dialog portals out of the table, so every field query is scoped to it.
expect(dialog).toBeTruthy(); const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1"); // 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");
}); });
test("prefix editor starts clean and dirties on add", async () => { test("prefix editor starts clean and dirties on add", async () => {
+124 -34
View File
@@ -1,16 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries"; import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types"; import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog"; import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor"; import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; 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 * Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts * it stays live under file authority; deleting a hand-edited row contradicts
@@ -18,6 +18,83 @@ const cellClass = "px-3 py-2";
*/ */
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart."; 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() { export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery()); const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery()); const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -30,46 +107,46 @@ export default function ClientsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Clients</h1> <h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? ( {clients.length === 0 ? (
<p className="mt-4 text-zinc-500"> <p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand. nothing to create by hand.
</p> </p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-[48rem] text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700"> <tr {...stylex.props(styles.headRow)}>
<th className={cellClass}>IP</th> <th {...stylex.props(styles.cell)}>IP</th>
<th className={cellClass}>Name</th> <th {...stylex.props(styles.cell)}>Name</th>
<th className={cellClass}>Group</th> <th {...stylex.props(styles.cell)}>Group</th>
<th className={cellClass}>First seen</th> <th {...stylex.props(styles.cell)}>First seen</th>
<th className={cellClass}>Last seen</th> <th {...stylex.props(styles.cell)}>Last seen</th>
<th className={cellClass}> <th {...stylex.props(styles.cell)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{clients.map((client) => ( {clients.map((client) => (
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800"> <tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td className={`${cellClass} font-mono`}>{client.ip}</td> <td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td className={cellClass}> <td {...stylex.props(styles.cell)}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name} {client.name === "" ? (
{client.hand_edited && ( <span {...stylex.props(styles.dash)}></span>
<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 client.name
</span>
)} )}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td> </td>
<td className={cellClass}>{client.group}</td> <td {...stylex.props(styles.cell)}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}> <td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? ( {confirmingId === client.id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2"> <span {...stylex.props(styles.confirmGroup)}>
<span className="text-xs text-zinc-500"> <span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query. Deleted clients re-materialize on their next DNS query.
</span> </span>
<button <button
@@ -78,26 +155,34 @@ export default function ClientsPage() {
setConfirmingId(null); setConfirmingId(null);
deleteMutation.mutate(client.id); deleteMutation.mutate(client.id);
}} }}
className={`${smallButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
> >
Confirm delete Confirm delete
</button> </button>
<button <button
type="button" type="button"
onClick={() => setConfirmingId(null)} onClick={() => setConfirmingId(null)}
className={smallButtonClass} {...stylex.props(shared.smallButton, shared.focusRing)}
> >
Cancel Cancel
</button> </button>
</span> </span>
) : ( ) : (
<span className="inline-flex gap-2"> <span {...stylex.props(styles.actionGroup)}>
<button <button
type="button" type="button"
onClick={() => setEditing(client)} onClick={() => setEditing(client)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`} {...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -110,7 +195,12 @@ export default function ClientsPage() {
? DECLARED_CLIENT_NOTE ? DECLARED_CLIENT_NOTE
: undefined : undefined
} }
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
+91 -26
View File
@@ -1,11 +1,14 @@
import { useReducer, useState } from "react"; import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -13,6 +16,72 @@ interface Props {
groups: Group[]; 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) { export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient)); const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -21,6 +90,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const dirty = isDirty(state); const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups); const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
@@ -32,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
}; };
return ( return (
<section className="mt-10"> <section {...stylex.props(styles.section)}>
<h2 className="text-xl font-semibold">Client prefixes</h2> <h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins. match wins.
</p> </p>
{state.rows.length === 0 ? ( {state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p> <p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : ( ) : (
<ul className="mt-4 space-y-2"> <ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => ( {state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2"> <li key={index} {...stylex.props(styles.row)}>
<input <input
type="text" type="text"
aria-label={`Prefix ${index + 1}`} aria-label={`Prefix ${index + 1}`}
@@ -52,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } }) dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
} }
className={`${smallInputClass} w-52`} {...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/> />
<select <Select
aria-label={`Group for prefix ${index + 1}`} aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)} value={String(row.group_id)}
onChange={(event) => onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } }) dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
} }
className={smallInputClass} options={groupOptions}
> />
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -77,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) => onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } }) dispatch({ type: "edit", index, patch: { priority: event.target.value } })
} }
className={`${smallInputClass} w-20`} {...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/> />
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "remove", index })} onClick={() => dispatch({ type: "remove", index })}
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`} {...stylex.props(styles.removeButton, shared.focusRing)}
> >
Remove Remove
</button> </button>
@@ -91,16 +156,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul> </ul>
)} )}
{validation !== null && ( {validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400"> <p role="alert" {...stylex.props(styles.validation)}>
{validation} {validation}
</p> </p>
)} )}
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="mt-4 flex gap-2"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })} onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass} {...stylex.props(shared.button, shared.focusRing)}
> >
Add prefix Add prefix
</button> </button>
@@ -109,7 +174,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={save} onClick={save}
disabled={!dirty || mutation.isPending || readOnly} disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Save prefixes Save prefixes
</button> </button>
@@ -120,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null); setValidation(null);
dispatch({ type: "reset", prefixes }); dispatch({ type: "reset", prefixes });
}} }}
className={buttonClass} {...stylex.props(shared.button, shared.focusRing)}
> >
Discard changes Discard changes
</button> </button>
+94 -14
View File
@@ -1,9 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries"; import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types"; import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard"; import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners"; import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards"; import StatCards from "./StatCards";
@@ -12,20 +14,98 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"]; 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 }) { function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return ( return (
<div role="group" aria-label="Period" className="flex gap-1"> <div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => ( {PERIODS.map((option) => (
<button <button
key={option} key={option}
type="button" type="button"
aria-pressed={option === period} aria-pressed={option === period}
onClick={() => onChange(option)} onClick={() => onChange(option)}
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${ {...stylex.props(
option === period styles.period,
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50" option === period ? styles.periodSelected : styles.periodIdle,
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800" shared.focusRing,
}`} )}
> >
{option} {option}
</button> </button>
@@ -35,7 +115,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
} }
function Skeleton({ height }: { height: number }) { function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />; return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -46,9 +126,9 @@ export default function DashboardPage() {
const upstreamHealth = useQuery(upstreamHealthQuery()); const upstreamHealth = useQuery(upstreamHealthQuery());
return ( return (
<section className="space-y-4"> <section {...stylex.props(styles.page)}>
<div className="flex flex-wrap items-center justify-between gap-3"> <div {...stylex.props(styles.titleRow)}>
<h1 className="text-2xl font-semibold">Dashboard</h1> <h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} /> <PeriodPicker period={period} onChange={setPeriod} />
</div> </div>
@@ -62,9 +142,9 @@ export default function DashboardPage() {
<StatCards stats={stats.data} /> <StatCards stats={stats.data} />
)} )}
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]"> <div {...stylex.props(styles.panelGrid)}>
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.panel)}>
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2> <h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? ( {timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} /> <InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? ( ) : timeseries.data === undefined ? (
+82 -20
View File
@@ -1,33 +1,95 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types"; import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const STATE_CLASSES: Record<Health["disk"]["state"], string> = { const DARK = "@media (prefers-color-scheme: dark)";
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", const styles = stylex.create({
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300", 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;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) { export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return ( return (
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.card)}>
<h2 className="flex items-center justify-between text-sm font-semibold"> <h2 {...stylex.props(styles.heading)}>
Disk Disk
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}> <span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
{disk.state}
</span>
</h2> </h2>
<dl className="mt-3 space-y-2 text-sm"> <dl {...stylex.props(styles.list)}>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Free</dt> <dt {...stylex.props(styles.term)}>Free</dt>
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div> </div>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Database</dt> <dt {...stylex.props(styles.term)}>Database</dt>
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div> </div>
<div className="flex justify-between"> <div {...stylex.props(styles.row)}>
<dt className="text-zinc-500">Logs</dt> <dt {...stylex.props(styles.term)}>Logs</dt>
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd> <dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div> </div>
</dl> </dl>
</section> </section>
+31 -6
View File
@@ -1,13 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types"; 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 }) { 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 ( return (
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}> <p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children} {children}
</p> </p>
); );
@@ -39,5 +64,5 @@ export default function HealthBanners({ health }: { health: Health }) {
); );
} }
if (banners.length === 0) return null; if (banners.length === 0) return null;
return <div className="space-y-2">{banners}</div>; return <div {...stylex.props(styles.stack)}>{banners}</div>;
} }
+46 -5
View File
@@ -1,8 +1,49 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format"; import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types"; 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 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 { function percentOf(part: number, total: number): string | null {
if (total === 0) return null; if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`; return `${((part / total) * 100).toFixed(1)}%`;
@@ -10,11 +51,11 @@ function percentOf(part: number, total: number): string | null {
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) { function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return ( return (
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"> <div {...stylex.props(styles.card)}>
<dt className="text-sm text-zinc-500">{label}</dt> <dt {...stylex.props(styles.label)}>{label}</dt>
<dd> <dd>
<span className="text-2xl font-semibold tabular-nums">{value}</span> <span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>} {detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd> </dd>
</div> </div>
); );
@@ -22,7 +63,7 @@ function Card({ label, value, detail }: { label: string; value: string; detail?:
export default function StatCards({ stats }: { stats: StatsTotals }) { export default function StatCards({ stats }: { stats: StatsTotals }) {
return ( return (
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5"> <dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} /> <Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card <Card
label="Blocked" label="Blocked"
+131 -30
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types"; 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"; import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both // Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -14,6 +17,108 @@ const SERIES = [
const CHART_HEIGHT = 240; const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640; 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] { function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) { function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2; const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2; const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return ( return (
<div <div {...stylex.props(styles.tooltip, side)}>
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" <div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }} <dl {...stylex.props(styles.tooltipList)}>
> <div {...stylex.props(styles.tooltipRow)}>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div> <dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dl className="mt-1 space-y-0.5"> <dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
</div> </div>
{SERIES.map((series) => ( {SERIES.map((series) => (
<div key={series.key} className="flex items-center justify-between gap-4"> <div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt className="flex items-center gap-1.5 text-zinc-500"> <dt {...stylex.props(styles.tooltipTerm)}>
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-block size-2 rounded-xs" {...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
style={{ backgroundColor: series.color }}
/> />
{series.label} {series.label}
</dt> </dt>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd> <dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) { if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return ( return (
<div <div ref={containerRef} {...stylex.props(styles.empty)}>
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. No queries in this period.
</div> </div>
); );
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined; const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<svg <svg
role="img" role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`} aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width} x2={layout.plot.x + layout.plot.width}
y1={tick.y} y1={tick.y}
y2={tick.y} y2={tick.y}
className="stroke-zinc-200 dark:stroke-zinc-800" {...stylex.props(styles.gridLine)}
/> />
<text <text
x={layout.plot.x - 6} x={layout.plot.x - 6}
y={tick.y} y={tick.y}
textAnchor="end" textAnchor="end"
dominantBaseline="middle" dominantBaseline="middle"
className="fill-zinc-500 text-[10px] tabular-nums" {...stylex.props(styles.axisLabel, shared.tabularNums)}
> >
{compact.format(tick.value)} {compact.format(tick.value)}
</text> </text>
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width} x2={layout.plot.x + layout.plot.width}
y1={baseline} y1={baseline}
y2={baseline} y2={baseline}
className="stroke-zinc-300 dark:stroke-zinc-700" {...stylex.props(styles.axisLine)}
/> />
{layout.xTicks.map((tick) => ( {layout.xTicks.map((tick) => (
<text <text
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x} x={tick.x}
y={baseline + 14} y={baseline + 14}
textAnchor="middle" textAnchor="middle"
className="fill-zinc-500 text-[10px]" {...stylex.props(styles.axisLabel)}
> >
{formatTick(tick.ts, data.bucket_seconds)} {formatTick(tick.ts, data.bucket_seconds)}
</text> </text>
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width} width={rect.width}
height={rect.height} height={rect.height}
fill={series.color} fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0} strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/> />
); );
})} })}
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))} ))}
</svg> </svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />} {hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400"> <ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => ( {SERIES.map((series) => (
<li key={series.key} className="flex items-center gap-1.5"> <li key={series.key} {...stylex.props(styles.legendItem)}>
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-block size-2.5 rounded-xs" {...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
style={{ backgroundColor: series.color }}
/> />
{series.label} {series.label}
</li> </li>
))} ))}
</ul> </ul>
<table className="sr-only"> <table {...stylex.props(shared.srOnly)}>
<caption>Queries per time bucket</caption> <caption>Queries per time bucket</caption>
<thead> <thead>
<tr> <tr>
@@ -1,64 +1,150 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth } from "@/lib/types"; 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 }) { function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
const bad = value === badValue; const bad = value === badValue;
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>; return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
} }
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) { export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
return ( return (
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"> <section {...stylex.props(styles.card)}>
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold"> <h2 {...stylex.props(styles.heading)}>
Upstreams Upstreams
<span className="text-xs font-normal text-zinc-500 tabular-nums"> <span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available {health.available}/{health.total} available
</span> </span>
</h2> </h2>
{health.upstreams.length === 0 ? ( {health.upstreams.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p> <p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div {...stylex.props(styles.tableWrap)}>
<table className="mt-2 w-full text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
URL URL
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Enabled Enabled
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Available Available
</th> </th>
<th scope="col" className="px-4 py-2 text-right font-medium"> <th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures Failures
</th> </th>
<th scope="col" className="px-4 py-2 text-right font-medium"> <th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate Success rate
</th> </th>
<th scope="col" className="px-4 py-2 font-medium"> <th scope="col" {...stylex.props(styles.th)}>
Last error Last error
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{health.upstreams.map((upstream) => ( {health.upstreams.map((upstream) => (
<tr <tr key={upstream.url} {...stylex.props(styles.row)}>
key={upstream.url} <td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50" <td {...stylex.props(styles.cell)}>
>
<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} /> <YesNo value={upstream.enabled} badValue={false} />
</td> </td>
<td className="px-4 py-2"> <td {...stylex.props(styles.cell)}>
<YesNo value={upstream.available} badValue={false} /> <YesNo value={upstream.available} badValue={false} />
</td> </td>
<td className="px-4 py-2 text-right tabular-nums">{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 {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{(upstream.success_rate * 100).toFixed(1)}% {(upstream.success_rate * 100).toFixed(1)}%
</td> </td>
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td> <td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{upstream.last_error || "—"}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+45 -14
View File
@@ -1,10 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries"; import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types"; import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props { interface Props {
@@ -12,6 +14,35 @@ interface Props {
blocklists: Blocklist[]; 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) { export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId)); const sources = useQuery(groupSourcesQuery(groupId));
@@ -21,7 +52,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isPending) { if (sources.isPending) {
return ( return (
<p role="status" className="mt-3 text-sm text-zinc-500"> <p role="status" {...stylex.props(styles.note)}>
Loading sources Loading sources
</p> </p>
); );
@@ -29,27 +60,23 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />; if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) { if (blocklists.length === 0) {
return ( return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
<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 current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data); const dirty = !sameSet(current, sources.data);
return ( return (
<div className="mt-3"> <div {...stylex.props(styles.root)}>
<ul className="space-y-1"> <ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => ( {blocklists.map((blocklist) => (
<li key={blocklist.id}> <li key={blocklist.id}>
<label className="inline-flex items-center gap-2 text-sm"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={current.includes(blocklist.id)} checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))} onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
{blocklist.name} {blocklist.name}
</label> </label>
@@ -57,7 +84,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
))} ))}
</ul> </ul>
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div className="mt-3 flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="button" type="button"
disabled={!dirty || mutation.isPending || readOnly} disabled={!dirty || mutation.isPending || readOnly}
@@ -65,12 +92,16 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
onClick={() => onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) }) mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
} }
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Save sources Save sources
</button> </button>
{dirty && ( {dirty && (
<button type="button" onClick={() => setSelected(null)} className={buttonClass}> <button
type="button"
onClick={() => setSelected(null)}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard Discard
</button> </button>
)} )}
+111 -24
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
blocklistsQuery, blocklistsQuery,
groupCreateMutation, groupCreateMutation,
@@ -10,13 +11,85 @@ import {
import type { Blocklist, Group } from "@/lib/types"; import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor"; import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup"; import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted."; const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`; 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,
},
});
export default function GroupsPage() { export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery()); const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -28,9 +101,9 @@ export default function GroupsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Groups</h1> <h1 {...stylex.props(styles.heading)}>Groups</h1>
<form <form
className="mt-4 flex flex-wrap items-center gap-2" {...stylex.props(styles.createForm)}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const name = newName.trim(); const name = newName.trim();
@@ -38,7 +111,7 @@ export default function GroupsPage() {
createMutation.mutate({ name }, { onSuccess: () => setNewName("") }); createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}} }}
> >
<label className="text-sm font-medium" htmlFor="new-group-name"> <label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group New group
</label> </label>
<input <input
@@ -47,19 +120,19 @@ export default function GroupsPage() {
value={newName} value={newName}
onChange={(event) => setNewName(event.target.value)} onChange={(event) => setNewName(event.target.value)}
disabled={readOnly} disabled={readOnly}
className={smallInputClass} {...stylex.props(shared.smallInput, shared.focusRing)}
/> />
<button <button
type="submit" type="submit"
disabled={createMutation.isPending || readOnly} disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
Create Create
</button> </button>
</form> </form>
<InlineError error={createMutation.error} /> <InlineError error={createMutation.error} />
<ul className="mt-6 space-y-4"> <ul {...stylex.props(styles.list)}>
{groups.map((group) => ( {groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} /> <GroupRow key={group.id} group={group} blocklists={blocklists} />
))} ))}
@@ -81,11 +154,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined; const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return ( return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700"> <li {...stylex.props(styles.row)}>
<div className="flex flex-wrap items-center gap-3"> <div {...stylex.props(styles.rowControls)}>
{renaming ? ( {renaming ? (
<form <form
className="flex items-center gap-2" {...stylex.props(styles.renameForm)}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
@@ -101,14 +174,14 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`} aria-label={`New name for ${group.name}`}
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
className={smallInputClass} {...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending || readOnly} disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Save Save
</button> </button>
@@ -118,21 +191,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(false); setRenaming(false);
}} }}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Cancel Cancel
</button> </button>
</form> </form>
) : ( ) : (
<span className="font-medium">{group.name}</span> <span {...stylex.props(styles.name)}>{group.name}</span>
)} )}
<label className="inline-flex items-center gap-2 text-sm"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={group.safe_search} checked={group.safe_search}
disabled={updateMutation.isPending || readOnly} disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={focusRing} {...stylex.props(shared.focusRing)}
onChange={(event) => onChange={(event) =>
updateMutation.mutate({ updateMutation.mutate({
id: group.id, id: group.id,
@@ -142,12 +215,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
/> />
Safe search Safe search
</label> </label>
<span className="ml-auto inline-flex flex-wrap items-center gap-2"> <span {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)} onClick={() => setExpanded((open) => !open)}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Sources Sources
</button> </button>
@@ -160,7 +233,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name); setName(group.name);
setRenaming(true); setRenaming(true);
}} }}
className={groupButtonClass} {...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
> >
Rename Rename
</button> </button>
@@ -173,11 +246,20 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false); setConfirming(false);
deleteMutation.mutate(group.id); deleteMutation.mutate(group.id);
}} }}
className={`${groupButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
> >
Confirm delete Confirm delete
</button> </button>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}> <button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel Cancel
</button> </button>
</> </>
@@ -187,14 +269,19 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
disabled={isDefault || readOnly} disabled={isDefault || readOnly}
title={lockNote} title={lockNote}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`} {...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
)} )}
</span> </span>
</div> </div>
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>} {isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} /> <InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />} {expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li> </li>
+7 -1
View File
@@ -50,8 +50,14 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
expect(screen.getByText("Blocked")).toBeTruthy(); expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy(); expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("AAAA")).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 blockedRow = screen.getByText("ads.example").closest("tr");
expect(blockedRow?.className).toContain("bg-red-50"); 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);
const freeze = screen.getByRole("button", { name: "Freeze" }); const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze); fireEvent.click(freeze);
+175 -33
View File
@@ -1,20 +1,164 @@
import * as stylex from "@stylexjs/stylex";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage"; import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer"; import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries"; import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const toolbarButtonClass = `${buttonClass} font-medium`; 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;
}
function StatusPill({ status }: { status: StreamStatus }) { function StatusPill({ status }: { status: StreamStatus }) {
const styles: Record<StreamStatus, [string, string]> = { const label = PILL_LABELS[status];
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 ( return (
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}> <span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label} {label}
</span> </span>
); );
@@ -27,58 +171,56 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
return ( return (
<section> <section>
<div className="flex flex-wrap items-center gap-3"> <div {...stylex.props(styles.toolbar)}>
<h1 className="text-2xl font-semibold">Live</h1> <h1 {...stylex.props(styles.heading)}>Live</h1>
<StatusPill status={live.status} /> <StatusPill status={live.status} />
<button <button
type="button" type="button"
onClick={live.toggleFreeze} onClick={live.toggleFreeze}
aria-pressed={live.frozen} aria-pressed={live.frozen}
className={toolbarButtonClass} {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
{live.frozen ? "Resume" : "Freeze"} {live.frozen ? "Resume" : "Freeze"}
</button> </button>
</div> </div>
{live.frozen && ( {live.frozen && (
<p className="mt-2 text-sm text-zinc-500" role="status"> <p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "} Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept). kept).
</p> </p>
)} )}
{live.missed !== null && ( {live.missed !== null && (
<div <div role="status" {...stylex.props(styles.resumed)}>
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> <span>
Stream resumed {" "} Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}. {live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span> </span>
<button type="button" onClick={live.dismissMissed} className="font-medium underline"> <button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss Dismiss
</button> </button>
</div> </div>
)} )}
{live.resyncFailed && ( {live.resyncFailed && (
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300"> <p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here. Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p> </p>
)} )}
{live.status === "capped" && ( {live.status === "capped" && (
<div <div role="alert" {...stylex.props(styles.cappedBox)}>
role="alert" <h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950" <p {...stylex.props(styles.cappedDetail)}>
>
<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 The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable. address), or the server is unreachable.
</p> </p>
<button type="button" onClick={live.retry} className={retryButtonClass}> <button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry Retry
</button> </button>
</div> </div>
@@ -86,25 +228,25 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
{live.rows.length === 0 ? ( {live.rows.length === 0 ? (
live.status !== "capped" && ( live.status !== "capped" && (
<p className="mt-6 text-zinc-500"> <p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."} {live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p> </p>
) )
) : ( ) : (
<> <>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full text-sm"> <table {...stylex.props(styles.table)}>
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody>
{live.rows.map((row) => ( {live.rows.map((row) => (
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}> <tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} /> <QueryCells row={row} />
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="mt-3 text-sm text-zinc-500"> <p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "} Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept). {RING_CAPACITY} kept).
</p> </p>
+87 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -24,6 +24,12 @@ function createFetchMock() {
records = [...records, created]; records = [...records, created];
return json(created, 201); 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") { if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] }); return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
} }
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy(); 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 () => { test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage(); renderPage();
await screen.findByText("nas.lan.home"); await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" })); fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } }); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } }); // 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("Value"), { target: { value: "fd00::11" } }); fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy(); expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" }); 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),
);
});
+17 -69
View File
@@ -1,79 +1,27 @@
import { useState } from "react"; import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab"; import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab"; import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes"; import Tabs from "@/ui/Tabs";
type Tab = "records" | "zones"; const styles = stylex.create({
heading: {
function TabButton({ fontSize: "1.5rem",
id, lineHeight: "2rem",
controls, fontWeight: 600,
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() { export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Local DNS</h1> <h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<div <Tabs
role="tablist" label="Local DNS"
aria-label="Local DNS" tabs={[
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800" { id: "records", label: "Records", content: <RecordsTab /> },
> { id: "zones", label: "Forward zones", content: <ZonesTab /> },
<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> </section>
); );
} }
+146 -58
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
localRecordCreateMutation, localRecordCreateMutation,
localRecordDeleteMutation, localRecordDeleteMutation,
@@ -8,18 +9,86 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types"; import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm"; import { useCrudForm } from "@/ui/useCrudForm";
import { import { styles as shared } from "@/ui/styles";
formCardClass, import { colors } from "@/ui/tokens.stylex";
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"]; 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({ function RecordForm({
initial, initial,
@@ -50,10 +119,12 @@ function RecordForm({
} }
return ( return (
<form onSubmit={submit} className={formCardClass}> <form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3> <h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div> <div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium"> <label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name Name
</label> </label>
<input <input
@@ -62,28 +133,19 @@ function RecordForm({
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home" placeholder="nas.lan.home"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium"> <Select
Type label="Type"
</label>
<select
id={`${id}-rtype`}
value={rtype} value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)} onChange={(next) => setRtype(next as LocalRecordType)}
className={inputClass} options={RTYPE_OPTIONS}
> />
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div> </div>
<div> <div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium"> <label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value Value
</label> </label>
<input <input
@@ -94,11 +156,11 @@ function RecordForm({
placeholder={ placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10" rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
} }
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium"> <label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds) TTL (seconds)
</label> </label>
<input <input
@@ -108,19 +170,19 @@ function RecordForm({
value={ttl} value={ttl}
onChange={(event) => setTtl(event.target.value)} onChange={(event) => setTtl(event.target.value)}
placeholder="300" placeholder="300"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div className="flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -131,10 +193,19 @@ function RecordForm({
export default function RecordsTab() { export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data; const records = useSuspenseQuery(localRecordsQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< const {
LocalRecord, create,
LocalRecordInput update,
>({ remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation, create: localRecordCreateMutation,
update: localRecordUpdateMutation, update: localRecordUpdateMutation,
remove: localRecordDeleteMutation, remove: localRecordDeleteMutation,
@@ -144,14 +215,14 @@ export default function RecordsTab() {
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <div {...stylex.props(styles.toolbar)}>
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p> <p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "create" })} onClick={() => openForm({ mode: "create" })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
Add record Add record
</button> </button>
@@ -166,37 +237,37 @@ export default function RecordsTab() {
onCancel={closeForm} onCancel={closeForm}
/> />
)} )}
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Name Name
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Type Type
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Value Value
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
TTL TTL
</th> </th>
<th scope="col" className="py-2"> <th scope="col" {...stylex.props(styles.headCellLast)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{records.length === 0 && ( {records.length === 0 && (
<tr> <tr>
<td colSpan={5} className="py-4 text-zinc-500"> <td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet. No local records yet.
</td> </td>
</tr> </tr>
)} )}
{records.map((record) => ( {records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? ( {form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}> <td colSpan={5}>
<RecordForm <RecordForm
@@ -210,17 +281,21 @@ export default function RecordsTab() {
</td> </td>
) : ( ) : (
<> <>
<td className="py-2 pr-4 font-mono">{record.name}</td> <td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td> <td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td> <td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td> <td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap"> <td {...stylex.props(styles.actionCell)}>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: record })} onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`} {...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -229,7 +304,12 @@ export default function RecordsTab() {
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
@@ -241,6 +321,14 @@ export default function RecordsTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div> </div>
); );
} }
+133 -38
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react"; import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { import {
forwardZoneCreateMutation, forwardZoneCreateMutation,
forwardZoneDeleteMutation, forwardZoneDeleteMutation,
@@ -8,17 +9,83 @@ import {
} from "@/lib/queries"; } from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types"; import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm"; import { useCrudForm } from "@/ui/useCrudForm";
import { import { styles as shared } from "@/ui/styles";
formCardClass, import { colors } from "@/ui/tokens.stylex";
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; 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({ function ZoneForm({
initial, initial,
busy, busy,
@@ -44,10 +111,12 @@ function ZoneForm({
} }
return ( return (
<form onSubmit={submit} className={formCardClass}> <form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3> <h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div> <div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium"> <label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone Zone
</label> </label>
<input <input
@@ -56,11 +125,11 @@ function ZoneForm({
value={zone} value={zone}
onChange={(event) => setZone(event.target.value)} onChange={(event) => setZone(event.target.value)}
placeholder="lan.home" placeholder="lan.home"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium"> <label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver Resolver
</label> </label>
<input <input
@@ -69,19 +138,19 @@ function ZoneForm({
value={resolver} value={resolver}
onChange={(event) => setResolver(event.target.value)} onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53" placeholder="udp://192.168.1.1:53"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div className="flex gap-2"> <div {...stylex.props(styles.buttonRow)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
{busy ? "Saving…" : "Save"} {busy ? "Saving…" : "Save"}
</button> </button>
<button type="button" onClick={onCancel} className={largeButtonClass}> <button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel Cancel
</button> </button>
</div> </div>
@@ -92,10 +161,19 @@ function ZoneForm({
export default function ZonesTab() { export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data; const zones = useSuspenseQuery(forwardZonesQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm< const {
ForwardZone, create,
ForwardZoneInput update,
>({ remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation, create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation, update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation, remove: forwardZoneDeleteMutation,
@@ -105,8 +183,8 @@ export default function ZonesTab() {
return ( return (
<div> <div>
<div className="mt-4 flex items-center justify-between"> <div {...stylex.props(styles.toolbar)}>
<p className="text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live. Names under these zones go to their own resolver. Changes apply live.
</p> </p>
<button <button
@@ -114,7 +192,7 @@ export default function ZonesTab() {
onClick={() => openForm({ mode: "create" })} onClick={() => openForm({ mode: "create" })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass} {...stylex.props(shared.largePrimaryButton, shared.focusRing)}
> >
Add zone Add zone
</button> </button>
@@ -129,31 +207,31 @@ export default function ZonesTab() {
onCancel={closeForm} onCancel={closeForm}
/> />
)} )}
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full text-left text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800"> <tr {...stylex.props(styles.headRow)}>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Zone Zone
</th> </th>
<th scope="col" className="py-2 pr-4 font-medium"> <th scope="col" {...stylex.props(styles.headCell)}>
Resolver Resolver
</th> </th>
<th scope="col" className="py-2"> <th scope="col" {...stylex.props(styles.headCellLast)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{zones.length === 0 && ( {zones.length === 0 && (
<tr> <tr>
<td colSpan={3} className="py-4 text-zinc-500"> <td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet. No forward zones yet.
</td> </td>
</tr> </tr>
)} )}
{zones.map((zone) => ( {zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900"> <tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? ( {form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}> <td colSpan={3}>
<ZoneForm <ZoneForm
@@ -167,15 +245,19 @@ export default function ZonesTab() {
</td> </td>
) : ( ) : (
<> <>
<td className="py-2 pr-4 font-mono">{zone.zone}</td> <td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td> <td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap"> <td {...stylex.props(styles.actionCell)}>
<button <button
type="button" type="button"
onClick={() => openForm({ mode: "edit", entity: zone })} onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`} {...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
@@ -184,7 +266,12 @@ export default function ZonesTab() {
onClick={() => onDelete(zone)} onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`} {...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Delete Delete
</button> </button>
@@ -196,6 +283,14 @@ export default function ZonesTab() {
</tbody> </tbody>
</table> </table>
</div> </div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div> </div>
); );
} }
+4 -2
View File
@@ -88,6 +88,8 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
test("defaults the group select to the default group (id 1)", async () => { test("defaults the group select to the default group (id 1)", async () => {
renderPage(); renderPage();
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement; // A RAC Select names its trigger with the current value and then the label, so
expect(select.value).toBe("1"); // 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");
}); });
+161 -44
View File
@@ -1,10 +1,15 @@
import { useState, type FormEvent, type ReactNode } from "react"; import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries"; import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types"; import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes"; 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)";
interface Submitted { interface Submitted {
domain: string; domain: string;
@@ -13,10 +18,123 @@ interface Submitted {
interface Verdict { interface Verdict {
label: string; label: string;
className: string; tone: "local" | "blocked" | "forwarded" | "allowed";
description: 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 * Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then * (PLAN §6): local records answer first, then the block decision, then
@@ -26,27 +144,27 @@ export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) { if (result.local_records) {
return { return {
label: "Local answer", label: "Local answer",
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300", tone: "local",
description: "A local record answers this name directly.", description: "A local record answers this name directly.",
}; };
} }
if (result.blocked) { if (result.blocked) {
return { return {
label: "Blocked", label: "Blocked",
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300", tone: "blocked",
description: "Queries for this name get a blocked response.", description: "Queries for this name get a blocked response.",
}; };
} }
if (result.forward_zone !== null) { if (result.forward_zone !== null) {
return { return {
label: "Forwarded", label: "Forwarded",
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300", tone: "forwarded",
description: `Queries go to the resolver for zone ${result.forward_zone}.`, description: `Queries go to the resolver for zone ${result.forward_zone}.`,
}; };
} }
return { return {
label: "Allowed", label: "Allowed",
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300", tone: "allowed",
description: "Queries resolve through the upstream pool.", description: "Queries resolve through the upstream pool.",
}; };
} }
@@ -68,9 +186,9 @@ function errorMessage(error: unknown): string {
function DetailRow({ label, children }: { label: string; children: ReactNode }) { function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="flex gap-4 py-2"> <div {...stylex.props(styles.detailRow)}>
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt> <dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd className="min-w-0 break-words">{children}</dd> <dd {...stylex.props(styles.detailValue)}>{children}</dd>
</div> </div>
); );
} }
@@ -80,26 +198,30 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`; const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return ( return (
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800"> <div {...stylex.props(styles.card)}>
<div className={`rounded-t px-4 py-3 ${verdict.className}`}> <div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 className="text-lg font-semibold">{verdict.label}</h2> <h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p className="text-sm">{verdict.description}</p> <p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
</div> </div>
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900"> <dl {...stylex.props(styles.details)}>
<DetailRow label="Domain"> <DetailRow label="Domain">
<span className="font-mono">{result.domain}</span> <span {...stylex.props(shared.mono)}>{result.domain}</span>
</DetailRow> </DetailRow>
<DetailRow label="Group">{groupName}</DetailRow> <DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow> <DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone"> <DetailRow label="Forward zone">
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"} {result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
</DetailRow> </DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow> <DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason"> <DetailRow label="Reason">
<span className="font-mono">{result.reason}</span> <span {...stylex.props(shared.mono)}>{result.reason}</span>
</DetailRow> </DetailRow>
<DetailRow label="Matched pattern"> <DetailRow label="Matched pattern">
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"} {result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
</DetailRow> </DetailRow>
<DetailRow label="Blocklist source"> <DetailRow label="Blocklist source">
{result.source_url !== null ? ( {result.source_url !== null ? (
@@ -107,7 +229,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
href={result.source_url} href={result.source_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-blue-600 underline dark:text-blue-400" {...stylex.props(styles.sourceLink, shared.focusRing)}
> >
{result.source_url} {result.source_url}
</a> </a>
@@ -117,7 +239,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
</DetailRow> </DetailRow>
<DetailRow label="Safe search rewrite"> <DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? ( {result.safe_search_rewrite !== null ? (
<span className="font-mono">{result.safe_search_rewrite}</span> <span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
) : ( ) : (
"—" "—"
)} )}
@@ -153,13 +275,13 @@ export default function LookupPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Lookup</h1> <h1 {...stylex.props(styles.heading)}>Lookup</h1>
<p className="mt-2 text-zinc-500"> <p {...stylex.props(styles.intro)}>
What the pipeline would do with a domain: local records, forward zones, block decision, safe search. What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
</p> </p>
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div className="min-w-56 grow"> <div {...stylex.props(styles.domainField)}>
<label htmlFor="lookup-domain" className="block text-sm font-medium"> <label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
Domain Domain
</label> </label>
<input <input
@@ -168,33 +290,28 @@ export default function LookupPage() {
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com" placeholder="ads.example.com"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="lookup-group" className="block text-sm font-medium"> <Select
Group label="Group"
</label> value={String(groupId)}
<select onChange={(value) => setGroupId(Number(value))}
id="lookup-group" options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
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" className={largePrimaryButtonClass} disabled={lookup.isFetching}> <button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Look up Look up
</button> </button>
</form> </form>
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up</p>} {lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up</p>}
{!lookup.isFetching && lookup.isError && ( {!lookup.isFetching && lookup.isError && (
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400"> <p role="alert" {...stylex.props(styles.error)}>
{errorMessage(lookup.error)} {errorMessage(lookup.error)}
</p> </p>
)} )}
+78 -15
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries"; import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [ const DURATIONS = [
{ label: "60 seconds", seconds: 60 }, { label: "60 seconds", seconds: 60 },
@@ -11,6 +13,72 @@ const DURATIONS = [
{ label: "Indefinitely", seconds: null }, { label: "Indefinitely", seconds: null },
] as const; ] 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 { export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds); const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600); const hours = Math.floor(clamped / 3600);
@@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number {
return now; return now;
} }
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() { export default function PauseWidget() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useQuery({ const { data } = useQuery({
@@ -52,7 +118,7 @@ export default function PauseWidget() {
if (data === undefined) { if (data === undefined) {
return ( return (
<button type="button" disabled className={triggerButtonClass}> <button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause Pause
</button> </button>
); );
@@ -60,16 +126,16 @@ export default function PauseWidget() {
if (data.paused) { if (data.paused) {
return ( return (
<div className="flex flex-col items-end"> <div {...stylex.props(styles.pausedRow)}>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.pausedControls)}>
<span className="text-sm text-amber-700 dark:text-amber-400"> <span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`} {data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span> </span>
<button <button
type="button" type="button"
onClick={() => mutation.mutate({ paused: false })} onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending} disabled={mutation.isPending}
className={triggerButtonClass} {...stylex.props(shared.button, styles.trigger, shared.focusRing)}
> >
Resume Resume
</button> </button>
@@ -81,7 +147,7 @@ export default function PauseWidget() {
return ( return (
<div <div
className="relative" {...stylex.props(styles.anchor)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false); if (e.key === "Escape") setMenuOpen(false);
}} }}
@@ -92,15 +158,12 @@ export default function PauseWidget() {
aria-controls="pause-menu" aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)} onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending} disabled={mutation.isPending}
className={triggerButtonClass} {...stylex.props(shared.button, styles.trigger, shared.focusRing)}
> >
Pause Pause
</button> </button>
{menuOpen && ( {menuOpen && (
<div <div id="pause-menu" {...stylex.props(styles.menu)}>
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 }) => ( {DURATIONS.map(({ label, seconds }) => (
<button <button
key={label} key={label}
@@ -111,7 +174,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds }, seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
); );
}} }}
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`} {...stylex.props(styles.menuItem, shared.insetFocusRing)}
> >
{label} {label}
</button> </button>
+192 -62
View File
@@ -1,14 +1,146 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format"; import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries"; import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types"; import type { QueriesFilter, QueryRow } from "@/lib/types";
import { qtypeName } from "./qtype"; import { qtypeName } from "./qtype";
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const filterInputClass = `mt-1 w-full ${smallInputClass}`; const DARK = "@media (prefers-color-scheme: dark)";
const toolbarButtonClass = `${buttonClass} font-medium`;
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,
},
});
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
@@ -21,13 +153,11 @@ function datetimeLocalToUnix(value: string): number | undefined {
} }
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) { export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
if (!row.blocked) return <span className="text-zinc-400"></span>; if (!row.blocked) return <span {...stylex.props(styles.muted)}></span>;
return ( return (
<span className="inline-flex items-center gap-1.5"> <span {...stylex.props(styles.blockedWrap)}>
<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"> <span {...stylex.props(styles.blockedBadge)}>Blocked</span>
Blocked {row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
</span>
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
</span> </span>
); );
} }
@@ -35,37 +165,38 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) { export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
return ( return (
<> <>
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td> <td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td> <td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td> <td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td> <td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td className="px-3 py-2"> <td {...stylex.props(styles.cell)}>
<BlockedCell row={row} /> <BlockedCell row={row} />
</td> </td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums"> <td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)} {row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td> </td>
<td className="px-3 py-2 whitespace-nowrap"> <td {...stylex.props(styles.cell, styles.nowrap)}>
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"} {row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
</td> </td>
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td> <td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
{row.upstream === "" ? "—" : row.upstream}
</td>
</> </>
); );
} }
export function QueryTableHead() { export function QueryTableHead() {
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
return ( return (
<thead className="bg-zinc-50 text-left dark:bg-zinc-900"> <thead {...stylex.props(styles.head)}>
<tr> <tr>
<th className={th}>Time</th> <th {...stylex.props(styles.th)}>Time</th>
<th className={th}>Domain</th> <th {...stylex.props(styles.th)}>Domain</th>
<th className={th}>Client</th> <th {...stylex.props(styles.th)}>Client</th>
<th className={th}>Type</th> <th {...stylex.props(styles.th)}>Type</th>
<th className={th}>Status</th> <th {...stylex.props(styles.th)}>Status</th>
<th className={th}>Response</th> <th {...stylex.props(styles.th)}>Response</th>
<th className={th}>Cache</th> <th {...stylex.props(styles.th)}>Cache</th>
<th className={th}>Upstream</th> <th {...stylex.props(styles.th)}>Upstream</th>
</tr> </tr>
</thead> </thead>
); );
@@ -124,66 +255,65 @@ export default function QueryLogPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Query Log</h1> <h1 {...stylex.props(styles.heading)}>Query Log</h1>
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5"> <form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Domain contains Domain contains
<input <input
type="text" type="text"
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Client (exact) Client (exact)
<input <input
type="text" type="text"
value={client} value={client}
onChange={(event) => setClient(event.target.value)} onChange={(event) => setClient(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <Select
Status variant="compactField"
<select label="Status"
value={blocked} value={blocked}
onChange={(event) => setBlocked(event.target.value)} onChange={setBlocked}
className={filterInputClass} options={STATUS_OPTIONS}
> />
<option value="any">All</option> <label {...stylex.props(styles.filterLabel)}>
<option value="blocked">Blocked only</option>
<option value="allowed">Allowed only</option>
</select>
</label>
<label className="block text-sm">
Since Since
<input <input
type="datetime-local" type="datetime-local"
value={since} value={since}
onChange={(event) => setSince(event.target.value)} onChange={(event) => setSince(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<label className="block text-sm"> <label {...stylex.props(styles.filterLabel)}>
Until Until
<input <input
type="datetime-local" type="datetime-local"
value={until} value={until}
onChange={(event) => setUntil(event.target.value)} onChange={(event) => setUntil(event.target.value)}
className={filterInputClass} {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
/> />
</label> </label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5"> <div {...stylex.props(styles.buttonRow)}>
<button type="submit" className={toolbarButtonClass}> <button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
Apply filters Apply filters
</button> </button>
<button type="button" onClick={clearFilters} className={toolbarButtonClass}> <button
type="button"
onClick={clearFilters}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
Clear Clear
</button> </button>
{base.isFetching && ( {base.isFetching && (
<span className="text-sm text-zinc-500" role="status"> <span {...stylex.props(styles.note)} role="status">
Loading Loading
</span> </span>
)} )}
@@ -191,29 +321,29 @@ export default function QueryLogPage() {
</form> </form>
{base.data === undefined ? ( {base.data === undefined ? (
<p className="mt-6 animate-pulse text-zinc-500" role="status"> <p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading query log Loading query log
</p> </p>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="mt-6 text-zinc-500"> <p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."} {filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p> </p>
) : ( ) : (
<> <>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}> <div {...stylex.props(styles.tableWrap)}>
<table className="w-full text-sm"> <table {...stylex.props(styles.table)}>
<QueryTableHead /> <QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800"> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.id}> <tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} /> <QueryCells row={row} />
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="mt-3 flex items-center gap-3"> <div {...stylex.props(styles.footer)}>
<p className="text-sm text-zinc-500"> <p {...stylex.props(styles.note)}>
Showing {rows.length} {rows.length === 1 ? "query" : "queries"} Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"} {hasMore ? "" : " — end of log"}
</p> </p>
@@ -222,14 +352,14 @@ export default function QueryLogPage() {
type="button" type="button"
onClick={loadMore} onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData} disabled={base.isFetchingNextPage || base.isPlaceholderData}
className={toolbarButtonClass} {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
{base.isFetchingNextPage ? "Loading…" : "Load more"} {base.isFetchingNextPage ? "Loading…" : "Load more"}
</button> </button>
)} )}
</div> </div>
{moreError !== null && ( {moreError !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300"> <p role="alert" {...stylex.props(styles.moreError)}>
Failed to load more: {moreError} Failed to load more: {moreError}
</p> </p>
)} )}
+111 -11
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -33,17 +33,30 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first. // 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 groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
let posted: { pattern: string; kind: string }[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => { beforeEach(() => {
groups = [ groups = [
{ id: 1, name: "Default", safe_search: false }, { id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true }, { id: 2, name: "Kids", safe_search: true },
]; ];
deleted = [];
posted = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") { 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" }), { return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429, status: 429,
headers: { "content-type": "application/json", "Retry-After": "5" }, headers: { "content-type": "application/json", "Retry-After": "5" },
@@ -75,6 +88,25 @@ 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 () => { test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
@@ -87,14 +119,59 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy(); expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2); expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement; expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]); expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement; test("the kind selector can select the regex option, not only list it", async () => {
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement; fireEvent.click(trigger("Kind"));
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]); 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");
}); });
test("rule create shows a countdown when rate limited with Retry-After", async () => { test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -116,9 +193,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement; expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]); expect(trigger("Group").textContent).toContain("Default");
expect(groupSelect.value).toBe("1");
}); });
test("the group select falls back to the first group when the default is absent", async () => { test("the group select falls back to the first group when the default is absent", async () => {
@@ -129,5 +205,29 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5"); 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"]));
}); });
+149 -85
View File
@@ -1,13 +1,86 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format"; import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries"; import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types"; import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; 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() { export default function RulesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery()); const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -20,68 +93,65 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact"); const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block"); const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups)); const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) { function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
create.mutate( // A regex pattern is stored and matched byte for byte, so the UI must not
{ group_id: groupId, pattern: pattern.trim(), kind, action }, // edit it: trimming here would make a UI-created rule differ from the same
{ onSuccess: () => setPattern("") }, // 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("") });
} }
function deleteRule(rule: Rule) { function confirmDelete() {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) { if (pendingDelete === null) return;
remove.mutate(rule.id); remove.mutate(pendingDelete.id);
} setPendingDelete(null);
} }
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Rules</h1> <h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p> <p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>Pattern</th> <th {...stylex.props(shared.th)}>Pattern</th>
<th className={thClass}>Kind</th> <th {...stylex.props(shared.th)}>Kind</th>
<th className={thClass}>Action</th> <th {...stylex.props(shared.th)}>Action</th>
<th className={thClass}>Group</th> <th {...stylex.props(shared.th)}>Group</th>
<th className={thClass}>Created</th> <th {...stylex.props(shared.th)}>Created</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rules.map((rule) => ( {rules.map((rule) => (
<tr key={rule.id}> <tr key={rule.id}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td> <td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td> <td {...stylex.props(shared.td)}>{rule.kind}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span <span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
{rule.action} {rule.action}
</span> </span>
</td> </td>
<td className={tdClass}>{rule.group}</td> <td {...stylex.props(shared.td)}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td> <td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<button <button
type="button" type="button"
onClick={() => deleteRule(rule)} onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -94,10 +164,10 @@ export default function RulesPage() {
)} )}
<InlineError error={remove.error} /> <InlineError error={remove.error} />
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3"> <form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">Create rule</h2> <h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div> <div>
<label htmlFor="rule-pattern" className="block text-sm font-medium"> <label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern Pattern
</label> </label>
<input <input
@@ -106,67 +176,61 @@ export default function RulesPage() {
required required
value={pattern} value={pattern}
onChange={(event) => setPattern(event.target.value)} onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com" placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
className={inputClass} // 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)}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div {...stylex.props(styles.fieldGrid)}>
<div> <Select
<label htmlFor="rule-kind" className="block text-sm font-medium"> label="Kind"
Kind
</label>
<select
id="rule-kind"
value={kind} value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)} onChange={(value) => setKind(value as RuleKind)}
className={inputClass} options={KIND_OPTIONS}
> />
<option value="exact">exact</option> <Select
<option value="wildcard">wildcard</option> label="Action"
</select>
</div>
<div>
<label htmlFor="rule-action" className="block text-sm font-medium">
Action
</label>
<select
id="rule-action"
value={action} value={action}
onChange={(event) => setAction(event.target.value as RuleAction)} onChange={(value) => setAction(value as RuleAction)}
className={inputClass} options={ACTION_OPTIONS}
> />
<option value="allow">allow</option> <Select
<option value="block">block</option> label="Group"
</select> value={String(groupId)}
</div> onChange={(value) => setGroupId(Number(value))}
<div> options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
<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> </div>
<div {...stylex.props(styles.submitRow)}>
<button <button
type="submit" type="submit"
disabled={create.isPending || readOnly} disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{create.isPending ? "Creating…" : "Create rule"} {create.isPending ? "Creating…" : "Create rule"}
</button> </button>
</div>
<InlineError error={create.error} /> <InlineError error={create.error} />
</form> </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> </section>
); );
} }
@@ -1,5 +1,22 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority"; 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 * 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. * dismiss button: it stays up for as long as the process runs from a file.
@@ -8,12 +25,9 @@ export default function ReadOnlyConfigBanner() {
const authority = useAuthority(); const authority = useAuthority();
if (authority?.mode !== "managed_file") return null; if (authority?.mode !== "managed_file") return null;
return ( return (
<div <div role="status" {...stylex.props(styles.banner)}>
role="status" Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
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" restart nxdns to change it; the server rejects edits made here.
>
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> </div>
); );
} }
+38 -11
View File
@@ -1,20 +1,47 @@
import * as stylex from "@stylexjs/stylex";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { dismissRestartBanner, useRestartBanner } from "./restartBanner"; import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
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",
},
});
export default function RestartBanner() { export default function RestartBanner() {
const raised = useRestartBanner(); const raised = useRestartBanner();
if (!raised) return null; if (!raised) return null;
return ( return (
<div <div role="status" {...stylex.props(styles.banner)}>
role="status" <span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
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" <button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
>
<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 Dismiss
</button> </button>
</div> </div>
@@ -130,7 +130,11 @@ 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 () => { test("enum and boolean fields diff as their own types", async () => {
await renderPage(); await renderPage();
const logging = screen.getByRole("group", { name: "Logging" }); const logging = screen.getByRole("group", { name: "Logging" });
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } }); // 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.click(within(logging).getByLabelText("hide_domains")); fireEvent.click(within(logging).getByLabelText("hide_domains"));
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(putBodies).toHaveLength(1)); await waitFor(() => expect(putBodies).toHaveLength(1));
+153 -47
View File
@@ -1,12 +1,15 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff"; import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types"; import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner"; import { raiseRestartBanner } from "./restartBanner";
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
import { focusRing } from "@/ui/classes"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean { export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -139,8 +142,121 @@ const SECTIONS: readonly AnySectionDef[] = [
}), }),
]; ];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; const DARK = "@media (prefers-color-scheme: dark)";
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
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)" },
},
});
function FieldRow({ function FieldRow({
section, section,
@@ -156,15 +272,15 @@ function FieldRow({
const id = `${section}.${def.key}`; const id = `${section}.${def.key}`;
if (def.kind === "boolean") { if (def.kind === "boolean") {
return ( return (
<div className="flex items-center gap-2"> <div {...stylex.props(styles.checkboxRow)}>
<input <input
id={id} id={id}
type="checkbox" type="checkbox"
checked={value as boolean} checked={value as boolean}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
</div> </div>
@@ -172,30 +288,20 @@ function FieldRow({
} }
if (Array.isArray(def.kind)) { if (Array.isArray(def.kind)) {
return ( return (
<div className="flex flex-col gap-1"> <Select
<label htmlFor={id} className={LABEL_CLASS}> variant="inline"
{def.key} label={def.key}
</label>
<select
id={id}
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={onChange}
className={fieldInputClass} options={def.kind.map((option) => ({ value: option, label: option }))}
> />
{def.kind.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
); );
} }
if (def.kind === "number") { if (def.kind === "number") {
const numeric = value as number; const numeric = value as number;
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -203,14 +309,14 @@ function FieldRow({
type="number" type="number"
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor={id} className={LABEL_CLASS}> <label htmlFor={id} {...stylex.props(styles.label)}>
{def.key} {def.key}
</label> </label>
<input <input
@@ -218,7 +324,7 @@ function FieldRow({
type="text" type="text"
value={value as string} value={value as string}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
); );
@@ -270,16 +376,16 @@ export default function SettingsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Settings</h1> <h1 {...stylex.props(styles.heading)}>Settings</h1>
<p className="mt-1 text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
Changes are validated as a whole; every setting requires a restart to take effect. Changes are validated as a whole; every setting requires a restart to take effect.
</p> </p>
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6"> <fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
{SECTIONS.map(({ section, title, fields }) => ( {SECTIONS.map(({ section, title, fields }) => (
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800"> <fieldset key={section} {...stylex.props(styles.section)}>
<legend className="px-1 text-sm font-semibold">{title}</legend> <legend {...stylex.props(styles.legend)}>{title}</legend>
<div className="grid gap-3 sm:grid-cols-2"> <div {...stylex.props(styles.fieldGrid)}>
{(fields as readonly AnyFieldDef[]).map((def) => ( {(fields as readonly AnyFieldDef[]).map((def) => (
<FieldRow <FieldRow
key={def.key} key={def.key}
@@ -291,12 +397,12 @@ export default function SettingsPage() {
))} ))}
{section === "web" && ( {section === "web" && (
<> <>
<p className={LABEL_CLASS}> <p {...stylex.props(styles.label)}>
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "} auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
<span className="text-zinc-500">(derived, read-only)</span> <span {...stylex.props(styles.derived)}>(derived, read-only)</span>
</p> </p>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password" className={LABEL_CLASS}> <label htmlFor="web.password" {...stylex.props(styles.label)}>
password password
</label> </label>
<input <input
@@ -305,11 +411,11 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div {...stylex.props(styles.field)}>
<label htmlFor="web.password_confirm" className={LABEL_CLASS}> <label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
confirm password confirm password
</label> </label>
<input <input
@@ -318,17 +424,17 @@ export default function SettingsPage() {
autoComplete="new-password" autoComplete="new-password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
className={fieldInputClass} {...stylex.props(styles.fieldInput, shared.focusRing)}
/> />
</div> </div>
{password !== "" && ( {password !== "" && (
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400"> <p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
Changing the password signs out every session; you will be asked to log Changing the password signs out every session; you will be asked to log
in again. in again.
</p> </p>
)} )}
{passwordsMismatch && ( {passwordsMismatch && (
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400"> <p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match. Passwords do not match.
</p> </p>
)} )}
@@ -337,12 +443,12 @@ export default function SettingsPage() {
</div> </div>
</fieldset> </fieldset>
))} ))}
<div className="flex items-center gap-3"> <div {...stylex.props(styles.submitRow)}>
<button <button
type="submit" type="submit"
disabled={saveDisabled} disabled={saveDisabled}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
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`} {...stylex.props(styles.save, shared.focusRing)}
> >
{mutation.isPending ? "Saving…" : "Save"} {mutation.isPending ? "Saving…" : "Save"}
</button> </button>
+64 -15
View File
@@ -1,7 +1,9 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority"; import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100"; const DEFAULT_PRIORITY = "100";
@@ -16,6 +18,49 @@ interface UpstreamFormProps {
onCancel?: () => void; 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) { export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? ""); const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority)); const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
@@ -45,10 +90,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3"> <form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2> <h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div> <div>
<label htmlFor="upstream-url" className="block text-sm font-medium"> <label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
URL URL
</label> </label>
<input <input
@@ -58,11 +103,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53" placeholder="udp://1.1.1.1:53"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="upstream-priority" className="block text-sm font-medium"> <label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
Priority Priority
</label> </label>
<input <input
@@ -71,11 +116,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
min={0} min={0}
value={priority} value={priority}
onChange={(event) => setPriority(event.target.value)} onChange={(event) => setPriority(event.target.value)}
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
<div> <div>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium"> <label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
TLS name TLS name
</label> </label>
<input <input
@@ -84,32 +129,36 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={tlsName} value={tlsName}
onChange={(event) => setTlsName(event.target.value)} onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one" placeholder="one.one.one.one"
className={inputClass} {...stylex.props(shared.input, shared.focusRing)}
/> />
<p className="mt-1 text-xs text-zinc-500"> <p {...stylex.props(styles.hint)}>
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme. The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p> </p>
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium"> <label {...stylex.props(styles.checkboxLabel)}>
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={enabled}
onChange={(event) => setEnabled(event.target.checked)} onChange={(event) => setEnabled(event.target.checked)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
Enabled Enabled
</label> </label>
<div className="flex items-center gap-2"> <div {...stylex.props(styles.actions)}>
<button <button
type="submit" type="submit"
disabled={busy || readOnly} disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass} {...stylex.props(shared.primaryButton, shared.focusRing)}
> >
{initial === undefined ? "Add upstream" : "Save changes"} {initial === undefined ? "Add upstream" : "Save changes"}
</button> </button>
{onCancel !== undefined && ( {onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}> <button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
>
Cancel Cancel
</button> </button>
)} )}
@@ -134,15 +134,29 @@ test("toggling enabled resends the whole row", async () => {
await screen.findByRole("status"); await screen.findByRole("status");
}); });
test("delete asks for confirmation and skips the request when refused", async () => { /** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
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(); await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false); const dialog = await openDeleteDialog();
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!); 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); 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)); await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE"); expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1"); expect(calls[0]!.url).toBe("/api/upstreams/1");
@@ -185,14 +199,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 () => { test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute(); await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () => writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), { new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409, status: 409,
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!); await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed"); expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
+89 -27
View File
@@ -1,17 +1,62 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries"; import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types"; import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner"; import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm"; import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes"; import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority"; 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() { export default function UpstreamsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery()); const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null); const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient)); const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient)); const save = useMutation(upstreamUpdateMutation(queryClient));
@@ -39,10 +84,10 @@ export default function UpstreamsPage() {
); );
} }
function deleteUpstream(u: Upstream) { function confirmDelete() {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) { if (pendingDelete === null) return;
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() }); remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
} setPendingDelete(null);
} }
const formError = editing === null ? create.error : save.error; const formError = editing === null ? create.error : save.error;
@@ -50,38 +95,38 @@ export default function UpstreamsPage() {
return ( return (
<section> <section>
<h1 className="text-2xl font-semibold">Upstreams</h1> <h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500"> <p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream 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. health table on the Dashboard reflects the running pool, not this list.
</p> </p>
{upstreams.length === 0 ? ( {upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p> <p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
) : ( ) : (
<div className={tableWrapClass}> <div {...stylex.props(shared.tableWrap)}>
<table className="w-full min-w-max border-collapse text-sm"> <table {...stylex.props(styles.table)}>
<thead> <thead>
<tr> <tr>
<th className={thClass}>URL</th> <th {...stylex.props(shared.th)}>URL</th>
<th className={thClass}>Priority</th> <th {...stylex.props(shared.th)}>Priority</th>
<th className={thClass}>Enabled</th> <th {...stylex.props(shared.th)}>Enabled</th>
<th className={thClass}>TLS name</th> <th {...stylex.props(shared.th)}>TLS name</th>
<th className={thClass}> <th {...stylex.props(shared.th)}>
<span className="sr-only">Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{upstreams.map((u) => ( {upstreams.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<span className="block max-w-72 truncate font-medium" title={u.url}> <span {...stylex.props(styles.url)} title={u.url}>
{u.url} {u.url}
</span> </span>
</td> </td>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<input <input
type="checkbox" type="checkbox"
aria-label={`${u.url} enabled`} aria-label={`${u.url} enabled`}
@@ -89,27 +134,31 @@ export default function UpstreamsPage() {
disabled={toggle.isPending || readOnly} disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)} onChange={() => toggleEnabled(u)}
className={focusRing} {...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td> <td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}> <td {...stylex.props(shared.td)}>
<div className="flex gap-3"> <div {...stylex.props(styles.actions)}>
<button <button
type="button" type="button"
onClick={() => setEditing(u)} onClick={() => setEditing(u)}
disabled={readOnly} disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`} {...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
onClick={() => deleteUpstream(u)} onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly} disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined} title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass} {...stylex.props(shared.dangerLinkButton, shared.focusRing)}
> >
Delete Delete
</button> </button>
@@ -132,6 +181,19 @@ export default function UpstreamsPage() {
onSubmit={submitForm} onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)} 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> </section>
); );
} }
+6 -1
View File
@@ -1,5 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
import { styles as shared } from "@/ui/styles";
import InlineError from "./InlineError"; import InlineError from "./InlineError";
test("no retry button without onRetry", () => { test("no retry button without onRetry", () => {
@@ -13,7 +15,10 @@ test("onRetry renders a focusable retry button that calls back", () => {
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />); render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
const button = screen.getByRole("button", { name: "Retry" }); const button = screen.getByRole("button", { name: "Retry" });
expect(button.className).toContain("focus-visible:outline-2"); // The accessibility floor: StyleX compiles the ring to opaque class names, so
// the check is that every class `focusRing` produces landed on the button.
const ring = (stylex.props(shared.focusRing).className ?? "").split(" ");
expect(button.className.split(" ")).toEqual(expect.arrayContaining(ring));
fireEvent.click(button); fireEvent.click(button);
expect(onRetry).toHaveBeenCalledTimes(1); expect(onRetry).toHaveBeenCalledTimes(1);
}); });

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