This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
# Working on simplepages
Read [`README.md`](README.md) first for what this is, and
[`docs/operations.md`](docs/operations.md) for how it is run. This file is for
whoever — human or agent — is changing the code.
Everything here exists to protect one property: **a request sees the whole old
deployment or the whole new one, never a mixture.** If a change makes that
property harder to reason about, it is the wrong change however much code it
saves.
## Before you call a change done
```sh
make fmt vet test
make race
make atomicity # -race -count=20, takes ~80s
go test ./cmd/pages -run TestCLIImportGraph
```
Run the last two whenever you touch `internal/site`, `internal/deploy`, or
anything the CLI imports. `make race` is not optional for changes in those
packages — the interesting failures here are all interleavings.
## Layout
| Path | |
|---|---|
| `api/` | Wire types, error codes, path builders. **Stdlib only** — it is shared with the CLI, and that is the whole reason the CLI stays dependency-free. |
| `cmd/pages-server/` | Assembly: config, storage, recovery, listeners, background workers, signals. Also holds the end-to-end tests. |
| `cmd/pages/` | CLI entry point plus `deps_test.go`, which enforces the import graph. |
| `internal/store/` | SQLite: dual pool, migrations, queries, fsck. |
| `internal/cas/` | Content-addressed blob store. |
| `internal/site/` | ★ The read path: registry, immutable deployment snapshots, URL resolution, the HTTP handler. |
| `internal/deploy/` | Deployment lifecycle, assembly, startup recovery, reconciler, GC. |
| `internal/webroot/` | `$WEBROOT/~name` symlink maintenance. |
| `internal/auth/` | Token mint/parse/verify, middleware, failed-auth rate limiting. |
| `internal/adminapi/` | Management API routes and handlers. |
| `internal/config/`, `internal/httpx/`, `internal/cache/`, `internal/pathutil/`, `internal/version/` | Support. |
| `internal/client/`, `internal/clicmd/`, `internal/cliutil/` | CLI: HTTP client and deploy flow, commands, command tree and rendering. |
## Invariants
Each of these is load-bearing. Changing one is a design decision, not a
refactor.
**1. Load `p.Active()` exactly once per request.** `internal/site/serve.go`
takes the snapshot at the top and every later access uses that value. A second
`Active()` call in the same request is the bug this whole design prevents: the
two loads could straddle an activation and serve a mixed page. `Deployment` is
immutable after construction, so holding the pointer is free and correct.
**2. Activation order is fixed.** Project lock → require `ready` → build the
snapshot → **commit to SQLite**`atomic.Pointer.Store` → best-effort
`webroot.Point`. Database before memory, so a crash between the two restarts
into the state the database already recorded. The reverse order leaves a
process serving A while the database says B. Building the snapshot before the
commit means a failure there changes nothing at all.
**3. Never trust a client-supplied hash.** `cas.Store.Put` rehashes the received
stream and compares against the claimed digest before linking the blob into
place; a mismatch is a 400 and the temp file is discarded. Without this a client
could claim another project's digest, upload arbitrary bytes, and poison that
blob for every project referencing it. This check is what makes cross-project
dedup safe — do not move it, skip it for "already known" digests, or trust a
digest because the transport was TLS.
**4. Tokens never appear in logs, query strings, or error messages.** There is a
test that runs a request through the middleware into a buffer and greps for the
token; keep it passing. The CLI prefers `PAGES_TOKEN` and `--token-file` over
`--token` because argv is world-readable via `/proc/<pid>/cmdline` on a shared
runner, and the help text says so. The bootstrap token file and the CLI config
file are 0600.
**5. All writes go through `db.Tx`.** `store.DB` exposes `Reader()` for queries
and `Tx(ctx, fn)` for everything else. There is deliberately no `Writer()` — the
single write connection plus `BEGIN IMMEDIATE` plus busy retries is the only
thing keeping `SQLITE_BUSY` off the hot path. Tests write through `db.Tx` too.
**6. Upload paths pass both `fs.ValidPath` and `filepath.Localize`,** and are
additionally rejected for NUL or control bytes, a segment over 255 bytes, a
total over 4096, duplicates, and case-insensitive collisions (the assembled tree
may land on a case-insensitive filesystem, where a collision silently
overwrites). One check is not enough: `fs.ValidPath` allows backslashes and
Windows reserved names, `Localize` allows things `ValidPath` rejects.
**7. The read path serves from the CAS by digest.** The only filesystem path
constructed while serving is `cas/ab/cd/<64 hex>`, derived from a `[32]byte`
that came out of a map lookup. No user-controlled string reaches the filesystem,
so read-path traversal is not defended against — it is structurally impossible.
Serving from the assembled directory instead would give that back.
**8. No archive extraction, ever.** Files arrive one at a time; the server never
unpacks or decompresses anything. That is what makes zip bombs and tar-slip
inapplicable rather than mitigated. "Just accept a tarball, it's fewer round
trips" reopens both — it needs a fresh security review, not a patch.
**9. `RequireProject` compares resolved project IDs,** never the name string
from the URL. Names can be reused after a delete; IDs cannot. Same rule for
deployments: confirm `{id}` really belongs to `{name}` before changing anything.
**10. `webroot.Reconcile` only removes entries that are symlinks pointing inside
`$DATA_DIR/deployments`.** `$WEBROOT` belongs to the operator and will contain
files that are none of our business.
**11. Assembled files and blobs are 0444, directories 0755, and client-supplied
modes are ignored.** There is no `mode` column in the schema on purpose, so a
setuid bit has nowhere to be stored even if someone tries to send one. On a
hardlinking filesystem the assembled file *is* the blob's inode — writing to a
file under `$WEBROOT` corrupts it for every project that references it.
**12. The CLI's dependency graph is enforced, not documented.**
`cmd/pages/deps_test.go` fails if `go list -deps ./cmd/pages` reaches
`modernc.org/sqlite`, `modernc.org/libc`, `database/sql`, `github.com/BurntSushi/toml`,
`net/http/httptest`, or `internal/{store,site,deploy,cas,auth,adminapi}`.
`database/sql` is a probe: it can only appear if server code leaked in. If you
need a type in both the CLI and the server, it belongs in `api/` (stdlib only)
or `internal/pathutil` (stdlib only).
## Conventions
**Comments explain why, not what.** The code already says what it does. A
comment earns its place by recording the reasoning that is not recoverable from
the code — why this order, why not the obvious alternative, what breaks if
someone changes it. Match the density of the surrounding file; several of the
sharper decisions are commented in place precisely so a later reader does not
"optimize" them away (`internal/cache`'s note about not caching file handles is
the canonical example).
**Do not over-design.** No metrics nobody reads, no abstraction with one
implementation, no configuration knob without a caller who needs it. If a
simpler thing works, ship the simpler thing.
**Dependencies.** Three direct ones, on purpose: `modernc.org/sqlite` (pure Go,
so `CGO_ENABLED=0` cross-compiles), `github.com/BurntSushi/toml` (server config
only), `golang.org/x/sync/errgroup` (bounded concurrency, both sides). No web
framework, router, ORM, migration tool, CLI framework, logging library
(`log/slog`), or UUID library (`crypto/rand` + hex). Adding a fourth needs a
reason that survives being written down.
**Tests** are table-driven with names that read as sentences
(`TestAnInFlightRequestSurvivesTheContentBeingCollected`). Two gotchas worth
knowing before you debug an unexpected pass: retention tests must patch
`retention_grace_s = 0`, because a deployment that was never activated is aged
from `created_at` and is otherwise protected for the full grace period; and blob
collection tests need a negative `Service.BlobGrace`, since zero means "use the
one-hour default", not "collect now".
**CLI rendering:** `cliutil.Bool` prints `yes`/`no`, and `cliutil.Truncate(s, n)`
returns `n-1` characters plus an ellipsis. Both have caught tests out.
## Scope
v1 (M0M5) is complete: deploy, atomic switch, rollback, crash recovery,
retention and GC, and the atomicity proof tests.
Deferred, deliberately — do not build these speculatively: precompressed
`br`/`gzip` variants (the `deployment_files.encoding` column is already there
for it), per-project header rules, host routing (one domain per project, which
is also the fix for the same-origin limit), autoindex, a metrics endpoint, a
resident-manifest cap, and packaging.
**Known limit:** one server process per database. The in-memory registry is
updated by the process that wrote the change, so a second process on the same
SQLite file serves stale content until it restarts. Horizontal scaling needs a
change-notification mechanism first.