167 lines
9.0 KiB
Markdown
167 lines
9.0 KiB
Markdown
# 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 (M0–M5) 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.
|