carlos / platform Public

Static sites honour a _redirects file

A static site can now ship a `_redirects` file at the top of its tree, in
the format Netlify introduced and Cloudflare Pages adopted: one
`source destination [status]` rule per line. Sources are paths or
http(s)://host URLs (a host rule matches only that host, for www -> apex);
`:name` captures a segment and a final `*` the rest, as `:splat`. 301
(default), 302, 303, 307 and 308 redirect; 200 serves another file of the
tree at the request's URL (SPA fallback) and 404 serves one as the
not-found page. A file at the path wins unless the status ends in `!`.

`carlos ship`/`deploy` refuse a file with a line the edge cannot use
(PackDir), naming every such line; Netlify conditions and proxying to
other hosts are not supported. The edge parses the file once per version
swap and keeps the rules with that version's tree, never serves the file,
skips a redirect to the URL being requested, and cleans every Location so
no capture can make it protocol-relative. A site without the file is
served exactly as before. Documented under carlos deploy in
docs/site/cli.md.

The root read census is updated with overlay plan14-root-reads-static-redirects
(proposed on #280): PackDir and staticSite.serveTree are re-frozen,
staticSite.serveFile takes serveTree's Open and staticSite.loadRedirects
reads the tree's _redirects once per version swap. Every read stays inside
the extracted, digest-verified tree; the seal moves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by james@keymail.dev pushed by james@keymail.dev ff350c27d9efc011623469cde676dff8ac781a3d parent d18e619
6 files changed, +928 −33
  • docs/site/cli.md +38 −0
  • internal/edge/redirects.go +418 −0
  • internal/edge/redirects_test.go +319 −0
  • internal/edge/static.go +127 −33
  • internal/provision/plan14_root_reads_test.go +15 −0
  • internal/provision/testdata/plan14-root-reads-static-redirects.tsv +11 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (2 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.013s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (19 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (169 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.641s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.918s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.800s
ok  	github.com/carlosframework/platform/internal/activator	60.767s
ok  	github.com/carlosframework/platform/internal/agentsock	0.056s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.030s
ok  	github.com/carlosframework/platform/internal/appconfig	2.836s
ok  	github.com/carlosframework/platform/internal/apperr	0.134s
ok  	github.com/carlosframework/platform/internal/applog	0.403s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	0.920s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.027s
ok  	github.com/carlosframework/platform/internal/caps	0.030s
ok  	github.com/carlosframework/platform/internal/certwarm	0.006s
ok  	github.com/carlosframework/platform/internal/cgroup	0.753s
ok  	github.com/carlosframework/platform/internal/changelog	0.531s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.205s
ok  	github.com/carlosframework/platform/internal/clamd	0.078s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	8.046s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.045s
ok  	github.com/carlosframework/platform/internal/dbquery	0.019s
ok  	github.com/carlosframework/platform/internal/dbreads	2.193s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.014s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.008s
ok  	github.com/carlosframework/platform/internal/docsite	0.024s
ok  	github.com/carlosframework/platform/internal/domains	2.813s
ok  	github.com/carlosframework/platform/internal/doorkey	0.009s
ok  	github.com/carlosframework/platform/internal/economics	0.009s
ok  	github.com/carlosframework/platform/internal/edge	45.659s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.011s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.163s
ok  	github.com/carlosframework/platform/internal/gate	1.662s
ok  	github.com/carlosframework/platform/internal/genselect	0.028s
ok  	github.com/carlosframework/platform/internal/geo	0.025s
ok  	github.com/carlosframework/platform/internal/geoip	0.059s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.207s
ok  	github.com/carlosframework/platform/internal/host	140.381s
ok  	github.com/carlosframework/platform/internal/hostactors	6.277s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.016s
ok  	github.com/carlosframework/platform/internal/hostbox	3.033s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.750s
ok  	github.com/carlosframework/platform/internal/hostenv	0.016s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.016s
ok  	github.com/carlosframework/platform/internal/hostexec	5.317s
ok  	github.com/carlosframework/platform/internal/hostfence	0.042s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.269s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	1.072s
ok  	github.com/carlosframework/platform/internal/hostops	1.918s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.016s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.016s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.637s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.233s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.016s
ok  	github.com/carlosframework/platform/internal/infraview	0.016s
ok  	github.com/carlosframework/platform/internal/instancectl	5.224s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.811s
ok  	github.com/carlosframework/platform/internal/instancefs	0.029s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.024s
ok  	github.com/carlosframework/platform/internal/layout	1.783s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.019s
ok  	github.com/carlosframework/platform/internal/lease	10.126s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.545s
ok  	github.com/carlosframework/platform/internal/ledger	0.025s
ok  	github.com/carlosframework/platform/internal/mail	0.250s
ok  	github.com/carlosframework/platform/internal/maintenance	0.097s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.567s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.099s
ok  	github.com/carlosframework/platform/internal/metrics	0.008s
ok  	github.com/carlosframework/platform/internal/moves	22.310s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.072s
ok  	github.com/carlosframework/platform/internal/objstore	0.309s
ok  	github.com/carlosframework/platform/internal/ops	0.213s
ok  	github.com/carlosframework/platform/internal/park	0.298s
ok  	github.com/carlosframework/platform/internal/plans	0.009s
ok  	github.com/carlosframework/platform/internal/privatefile	0.008s
ok  	github.com/carlosframework/platform/internal/provision	23.474s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.568s
ok  	github.com/carlosframework/platform/internal/relay	7.240s
ok  	github.com/carlosframework/platform/internal/release	26.186s
ok  	github.com/carlosframework/platform/internal/rollout	3.826s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.059s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.573s
ok  	github.com/carlosframework/platform/internal/s3compat	0.006s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.727s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.599s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.146s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.674s
ok  	github.com/carlosframework/platform/internal/status	0.090s
ok  	github.com/carlosframework/platform/internal/steering	0.007s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.281s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.007s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.007s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.006s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.696s
=== internal_console@0 (86 s)
ok  	github.com/carlosframework/platform/internal/console	84.492s
=== internal_console@1 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.034s
=== internal_console@2 (45 s)
ok  	github.com/carlosframework/platform/internal/console	42.799s
=== internal_console@3 (44 s)
ok  	github.com/carlosframework/platform/internal/console	41.656s
=== internal_console@4 (46 s)
ok  	github.com/carlosframework/platform/internal/console	44.365s
=== internal_console@5 (42 s)
ok  	github.com/carlosframework/platform/internal/console	39.788s
=== internal_console@6 (40 s)
ok  	github.com/carlosframework/platform/internal/console	38.468s
=== internal_console@7 (36 s)
ok  	github.com/carlosframework/platform/internal/console	34.181s
=== root@0 (108 s)
ok  	github.com/carlosframework/platform	105.998s
=== root@1 (90 s)
ok  	github.com/carlosframework/platform	87.494s
=== root@2 (82 s)
ok  	github.com/carlosframework/platform	79.621s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50528418 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/23 13:11:57 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E12ABCEBBB114C, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.029s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN   TestDBForwardMinio
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/23 13:11:59 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/23 13:11:59 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/23 13:11:59 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/23 13:11:59 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/23 13:11:59 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/23 13:11:59 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/23 13:11:59 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A37249: could not determine target mailbox
    agent.go:123: dbreads: dbr_wiuiotfvoj2cijzkvgyo2xnzxq: planned 3 files (1544 bytes) and restored to 3 in 9ms
    agent.go:123: dbreads: dbr_wiuiotfvoj2cijzkvgyo2xnzxq: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/23 13:12:00 console: db read dbr_wiuiotfvoj2cijzkvgyo2xnzxq: relayed 1 chunks (102 bytes) in 1.003s, ended done
    agent.go:123: dbreads: dbr_gyk6hhdldy35v2fn4h2wnxqdmi: planned 1 files (720 bytes) and restored to 1 in 7ms
    agent.go:123: dbreads: dbr_gyk6hhdldy35v2fn4h2wnxqdmi: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/23 13:12:01 console: db read dbr_gyk6hhdldy35v2fn4h2wnxqdmi: relayed 1 chunks (102 bytes) in 1.003s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:123: dbreads: dbr_kkxqtwpdl6giwlovakt76kn5ny: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/23 13:12:01 console: db read dbr_kkxqtwpdl6giwlovakt76kn5ny: relayed 0 chunks (0 bytes) in 12ms, ended client gone
    agent.go:123: dbreads: dbr_kkxqtwpdl6giwlovakt76kn5ny: output 5 bytes in 1 confirmed chunks in 13ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:123: dbreads: dbr_gtqvx7zcphbhiy3py7fae2v5bq: planned 3 files (1544 bytes) and restored to 3 in 8ms
2026/10/23 13:12:21 console: db read dbr_gtqvx7zcphbhiy3py7fae2v5bq: relayed 0 chunks (0 bytes) in 19.065s, ended no-result
    agent.go:123: dbreads: dbr_gtqvx7zcphbhiy3py7fae2v5bq: output 5 bytes in 1 confirmed chunks in 19.058s
--- PASS: TestDBForwardMinio (21.39s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.13s)
    --- PASS: TestDBForwardMinio/by_timeout (19.17s)
PASS
ok  	github.com/carlosframework/platform/internal/console	21.404s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index f001dd39e..a7b3f8e27 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -330,6 +330,44 @@ app is served only at its own platform address, so `--host` and
carlos deploy --temporary --ttl 3d ./dist
```
+#### Redirects for a static site
+
+Put a file named `_redirects` at the top of the site directory. It uses the
+format Netlify and Cloudflare Pages use: one rule per line, with a source, a
+destination and an optional status.
+
+```text
+# source destination status
+/old-page /new-page 301
+/blog/* /posts/:splat 301
+/docs/:slug /guide/:slug 302
+/app/* /app/index.html 200
+https://www.example.com/* https://example.com/:splat 301!
+/* /404.html 404
+```
+
+- A source is a path, or a URL with a host. A URL source applies only to
+ requests for that host, so the last-but-one rule sends `www` to the apex.
+- `:name` matches one path segment. A `*` at the end matches the rest of the
+ path, and the destination gets it as `:splat`.
+- The status is 301 when you leave it out. 302, 303, 307 and 308 are also
+ redirects. 200 serves the destination's file at the requested URL, which
+ is how a single-page app serves its client-side routes. 404 serves the
+ destination's file as the not-found page.
+- A 200 or 404 rule takes a path in the same site. A redirect can also go to
+ another host.
+- A request's query string is kept unless the destination has one of its
+ own.
+- When a file exists at the requested path, the file wins. Add `!` to the
+ status (`301!`) to make the rule win.
+- Rules apply in order, and the first rule that matches is used. A line
+ starting with `#` is a comment.
+
+`carlos ship` and `carlos deploy` refuse a `_redirects` file with a line they
+cannot use, and name the line. Netlify's conditions, such as `Country=`, and
+proxying to another host are not supported. A file can have up to 2,000
+rules. The edge does not serve the `_redirects` file itself.
+
#### Two builds at once
When a new build reaches an awake instance, it starts beside the old one,
diff --git a/internal/edge/redirects.go b/internal/edge/redirects.go
new file mode 100644
index 000000000..f38a1d416
--- /dev/null
+++ b/internal/edge/redirects.go
@@ -0,0 +1,418 @@
+// Redirect and rewrite rules for static sites: a `_redirects` file at the root
+// of a shipped tree, in the format Netlify introduced and Cloudflare Pages
+// adopted. One rule per line, "source destination [status]":
+//
+// /old /new 301
+// /blog/* /posts/:splat
+// /docs/:slug /guide/:slug 302
+// /app/* /app/index.html 200
+// https://www.example.com/* https://example.com/:splat 301!
+//
+// The subset served here:
+//
+// - A source is a path, or an http(s) URL whose host must match the
+// request's (the scheme is not compared: the edge serves https only).
+// A segment ":name" captures one path segment; a final "*" captures the
+// rest of the path, "" included, so "/blog/*" also matches "/blog"; a
+// non-empty splat keeps the request's trailing slash.
+// Matching is case-sensitive and ignores a trailing slash.
+// - A destination is a path, or for a redirect an http(s) URL. ":name" and
+// ":splat" are replaced with what the source captured; placeholders in a
+// URL's scheme or host are not. A request's query string is carried over
+// when the destination names none of its own.
+// - The status is 301 (the default), 302, 303, 307 or 308 for a redirect;
+// 200 serves the destination's file at the request's URL (a rewrite) and
+// 404 serves it with a 404 (a custom not-found page). A rewrite's
+// destination is always a file in the same tree, never another host.
+// - A file at the request path wins over every rule (Netlify's
+// "shadowing"), unless the status carries a trailing "!".
+// - The first matching rule applies. A line whose first field starts with
+// "#" is a comment, and so is everything from a field starting with "#".
+//
+// Netlify's conditions (Country=, Language=, Role=...), proxying to another
+// host and the netlify.toml form are not supported. `carlos ship` refuses a
+// tree whose _redirects has a line it cannot use (PackDir), so a site never
+// ships rules the edge would ignore; the edge itself skips such a line and
+// logs it, which only a tree packed by something else can reach.
+package edge
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+)
+
+// redirectsFile is the rules file's name at the root of a static tree. The
+// file is configuration, not content: the edge never serves it.
+const redirectsFile = "_redirects"
+
+// Limits on one _redirects file. Matching is a scan of every rule on each
+// request the tree cannot answer, so the count is bounded the way Cloudflare
+// Pages bounds it.
+const (
+ redirectsMaxBytes = 256 << 10
+ redirectsMaxRules = 2000
+ redirectsMaxLine = 2048
+)
+
+// redirectRule is one parsed line of a _redirects file.
+type redirectRule struct {
+ line int // 1-based line number, for logs
+ host string // lowercased; "" matches any host
+ segs []string // source path segments; ":name" captures one
+ splat bool // the source ends in "*"
+ prefix string // a URL destination's "scheme://host", never substituted
+ to string // the destination template after prefix
+ status int
+ force bool // "!": applies even when a file exists at the request path
+}
+
+// redirectRules is a parsed _redirects file, immutable once built.
+type redirectRules struct {
+ rules []redirectRule
+}
+
+// isRedirect reports whether status is one a redirect rule may answer with.
+func isRedirect(status int) bool {
+ switch status {
+ case 301, 302, 303, 307, 308:
+ return true
+ }
+ return false
+}
+
+// parseRedirects parses a _redirects file. It returns the rules it could use
+// and an error naming every line it could not; callers decide which matters.
+// Ship refuses on any error, and the edge serves the usable rules.
+func parseRedirects(data []byte) (*redirectRules, error) {
+ if len(data) > redirectsMaxBytes {
+ return nil, fmt.Errorf("%s is %d bytes; the limit is %d", redirectsFile, len(data), redirectsMaxBytes)
+ }
+ rs := &redirectRules{}
+ var errs []error
+ // A byte-order mark is what some editors save at the front; it is not
+ // part of the first rule.
+ text := strings.TrimPrefix(string(data), "\ufeff")
+ for i, line := range strings.Split(text, "\n") {
+ n := i + 1
+ line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if len(line) > redirectsMaxLine {
+ errs = append(errs, fmt.Errorf("line %d: longer than %d characters", n, redirectsMaxLine))
+ continue
+ }
+ rule, err := parseRedirectLine(line)
+ if err != nil {
+ errs = append(errs, fmt.Errorf("line %d: %v", n, err))
+ continue
+ }
+ if len(rs.rules) == redirectsMaxRules {
+ errs = append(errs, fmt.Errorf("line %d: more than %d rules", n, redirectsMaxRules))
+ continue
+ }
+ rule.line = n
+ rs.rules = append(rs.rules, rule)
+ }
+ return rs, errors.Join(errs...)
+}
+
+func parseRedirectLine(line string) (redirectRule, error) {
+ var fields []string
+ for _, f := range strings.Fields(line) {
+ if strings.HasPrefix(f, "#") {
+ break // the rest of the line is a comment
+ }
+ fields = append(fields, f)
+ }
+ var rule redirectRule
+ switch {
+ case len(fields) < 2:
+ return rule, errors.New("want a source and a destination")
+ case len(fields) > 3:
+ if strings.Contains(fields[3], "=") {
+ return rule, fmt.Errorf("conditions such as %q are not supported", fields[3])
+ }
+ return rule, fmt.Errorf("unexpected %q after the status", fields[3])
+ }
+
+ rule.status = 301
+ if len(fields) == 3 {
+ st := fields[2]
+ if s, ok := strings.CutSuffix(st, "!"); ok {
+ rule.force, st = true, s
+ }
+ code, err := strconv.Atoi(st)
+ if err != nil || !(isRedirect(code) || code == 200 || code == 404) {
+ return rule, fmt.Errorf("status %q: want 301, 302, 303, 307, 308, 200 or 404, optionally followed by !", fields[2])
+ }
+ rule.status = code
+ }
+
+ src := fields[0]
+ if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
+ u, err := url.Parse(src)
+ if err != nil || u.Host == "" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" {
+ return rule, fmt.Errorf("source %q: want a URL with only a host and a path", src)
+ }
+ rule.host = serverName(u.Hostname()) // as requestHost normalizes the request's
+ src = u.Path
+ if src == "" {
+ src = "/"
+ }
+ }
+ if !strings.HasPrefix(src, "/") {
+ return rule, fmt.Errorf("source %q: want a path starting with / or an http(s) URL", fields[0])
+ }
+ if strings.ContainsAny(src, "?#") {
+ return rule, fmt.Errorf("source %q: query strings and fragments are not matched", fields[0])
+ }
+ names := map[string]bool{}
+ trimmed := strings.Trim(src, "/")
+ for _, seg := range strings.Split(trimmed, "/") {
+ switch {
+ case seg == "":
+ if trimmed != "" {
+ return rule, fmt.Errorf("source %q: empty path segment", fields[0])
+ }
+ case rule.splat:
+ return rule, fmt.Errorf("source %q: * must be the last segment", fields[0])
+ case seg == "*":
+ rule.splat = true
+ case strings.Contains(seg, "*"):
+ return rule, fmt.Errorf("source %q: * must be a whole segment", fields[0])
+ case strings.HasPrefix(seg, ":"):
+ name := seg[1:]
+ if !validPlaceholder(name) || name == "splat" {
+ return rule, fmt.Errorf("source %q: bad placeholder %q", fields[0], seg)
+ }
+ if names[name] {
+ return rule, fmt.Errorf("source %q: placeholder %q appears twice", fields[0], seg)
+ }
+ names[name] = true
+ rule.segs = append(rule.segs, seg)
+ default:
+ // Requests match decoded, as files are found, so a literal
+ // segment is decoded here to meet them: "/caf%C3%A9" matches a
+ // request for /café. A decoded "/" would be two segments, and
+ // no request path has one inside a segment.
+ dec, err := url.PathUnescape(seg)
+ if err != nil || strings.Contains(dec, "/") {
+ return rule, fmt.Errorf("source %q: bad percent-encoding in %q", fields[0], seg)
+ }
+ if strings.HasPrefix(dec, ":") {
+ return rule, fmt.Errorf("source %q: %q would read as a placeholder", fields[0], seg)
+ }
+ if dec == "." || dec == ".." {
+ return rule, fmt.Errorf("source %q: dot segments never match", fields[0])
+ }
+ rule.segs = append(rule.segs, dec)
+ }
+ }
+
+ dst := fields[1]
+ switch {
+ case strings.HasPrefix(dst, "http://") || strings.HasPrefix(dst, "https://"):
+ if !isRedirect(rule.status) {
+ return rule, fmt.Errorf("destination %q: a %d serves a file from this site, so it takes a path, not a URL", dst, rule.status)
+ }
+ u, err := url.Parse(dst)
+ if err != nil || u.Host == "" || u.User != nil {
+ return rule, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ i := len(u.Scheme) + len("://") + len(u.Host)
+ if !strings.EqualFold(dst[:i], u.Scheme+"://"+u.Host) {
+ return rule, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ rule.prefix, rule.to = dst[:i], dst[i:]
+ if rule.to != "" && !strings.ContainsAny(rule.to[:1], "/?#") {
+ return rule, fmt.Errorf("destination %q: not a usable URL", dst)
+ }
+ case strings.HasPrefix(dst, "//") || strings.HasPrefix(dst, "/\\"):
+ return rule, fmt.Errorf("destination %q: name another host with https://", dst)
+ case strings.HasPrefix(dst, "/"):
+ rule.to = dst
+ default:
+ return rule, fmt.Errorf("destination %q: want a path starting with / or an http(s) URL", dst)
+ }
+ return rule, nil
+}
+
+// validPlaceholder: a placeholder name is letters, digits and underscores.
+func validPlaceholder(name string) bool {
+ if name == "" {
+ return false
+ }
+ for _, c := range name {
+ if !(c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+// match reports whether the rule applies to a request for host whose cleaned
+// path splits into reqSegs, and returns what the source captured.
+func (rule *redirectRule) match(host string, reqSegs []string) (map[string]string, bool) {
+ if rule.host != "" && rule.host != host {
+ return nil, false
+ }
+ if len(reqSegs) < len(rule.segs) || !rule.splat && len(reqSegs) != len(rule.segs) {
+ return nil, false
+ }
+ var caps map[string]string
+ for i, seg := range rule.segs {
+ if strings.HasPrefix(seg, ":") {
+ if caps == nil {
+ caps = map[string]string{}
+ }
+ caps[seg[1:]] = reqSegs[i]
+ } else if seg != reqSegs[i] {
+ return nil, false
+ }
+ }
+ if rule.splat {
+ if caps == nil {
+ caps = map[string]string{}
+ }
+ caps["splat"] = strings.Join(reqSegs[len(rule.segs):], "/")
+ }
+ return caps, true
+}
+
+// expand substitutes the captures into the destination template. With escape
+// set, values are percent-encoded for a Location header: per segment in the
+// path, as a query value after "?". Without it they go in raw, for a rewrite's
+// file lookup. A ":name" the source did not capture stays as written.
+func expand(t string, caps map[string]string, escape bool) string {
+ var b strings.Builder
+ inQuery := false
+ for i := 0; i < len(t); {
+ c := t[i]
+ if c == '?' || c == '#' {
+ inQuery = true
+ }
+ if c != ':' {
+ b.WriteByte(c)
+ i++
+ continue
+ }
+ j := i + 1
+ for j < len(t) && validPlaceholder(t[j:j+1]) {
+ j++
+ }
+ v, ok := caps[t[i+1:j]]
+ if !ok {
+ b.WriteString(t[i:j])
+ i = j
+ continue
+ }
+ switch {
+ case !escape:
+ b.WriteString(v)
+ case inQuery:
+ b.WriteString(url.QueryEscape(v))
+ default:
+ parts := strings.Split(v, "/")
+ for k, p := range parts {
+ parts[k] = url.PathEscape(p)
+ }
+ b.WriteString(strings.Join(parts, "/"))
+ }
+ i = j
+ }
+ return b.String()
+}
+
+// redirectAction is what a matched rule asks the handler to do.
+type redirectAction struct {
+ status int
+ location string // a redirect's Location
+ file string // a rewrite's cleaned, rooted path within the tree
+}
+
+// resolve finds the first rule that applies to r's host and the cleaned path
+// p. exists is whether the tree has a file at p, which shadows every rule not
+// forced. rawPath and rawQuery are the request's own, used to refuse a
+// redirect to the URL being requested, which would loop.
+func (rs *redirectRules) resolve(host, p, rawPath, rawQuery string, exists bool) (redirectAction, bool) {
+ if rs == nil || len(rs.rules) == 0 {
+ return redirectAction{}, false
+ }
+ var reqSegs []string
+ if p != "/" {
+ reqSegs = strings.Split(p[1:], "/")
+ }
+ for i := range rs.rules {
+ rule := &rs.rules[i]
+ if exists && !rule.force {
+ continue
+ }
+ caps, ok := rule.match(host, reqSegs)
+ if !ok {
+ continue
+ }
+ if sp := caps["splat"]; sp != "" && strings.HasSuffix(rawPath, "/") {
+ // The cleaned path lost it, and a page moved from /about/ to
+ // /about resolves its relative links against the wrong directory.
+ caps["splat"] = sp + "/"
+ }
+ if !isRedirect(rule.status) {
+ // The template's own query and fragment are cut BEFORE the
+ // captures go in: a decoded capture can hold "?" or "#", and it
+ // is part of the file's name, not the end of it.
+ to := rule.to
+ if k := strings.IndexAny(to, "?#"); k >= 0 {
+ to = to[:k]
+ }
+ return redirectAction{status: rule.status, file: path.Clean("/" + expand(to, caps, false))}, true
+ }
+ // loc is the destination after any scheme://host: path, query.
+ loc := expand(rule.to, caps, true)
+ frag := ""
+ if k := strings.IndexByte(loc, '#'); k >= 0 {
+ loc, frag = loc[:k], loc[k:]
+ }
+ if rule.prefix == "" {
+ // Cleaned here, as http.Redirect would clean it, so the loop
+ // check below sees the Location that is actually sent. Clean
+ // also folds the "//" an empty capture can leave at the front,
+ // which a browser would read as another host.
+ pth, q, hasQ := strings.Cut(loc, "?")
+ trailing := strings.HasSuffix(pth, "/")
+ pth = path.Clean("/" + pth)
+ if trailing && pth != "/" {
+ pth += "/"
+ }
+ loc = pth
+ if hasQ {
+ loc += "?" + q
+ }
+ }
+ if !strings.Contains(loc, "?") && rawQuery != "" {
+ loc += "?" + rawQuery
+ }
+ if rule.prefix == "" || hostOf(rule.prefix) == host {
+ pth, q, _ := strings.Cut(loc, "?")
+ if pth == "" {
+ pth = "/"
+ }
+ if u, err := url.Parse(pth); err == nil && u.Path == rawPath && q == rawQuery {
+ continue // a redirect to this very URL
+ }
+ }
+ return redirectAction{status: rule.status, location: rule.prefix + loc + frag}, true
+ }
+ return redirectAction{}, false
+}
+
+// hostOf is the host of a "scheme://host[:port]" prefix, normalized as
+// requestHost normalizes a request's.
+func hostOf(prefix string) string {
+ _, h, _ := strings.Cut(prefix, "://")
+ return requestHost(h)
+}
diff --git a/internal/edge/redirects_test.go b/internal/edge/redirects_test.go
new file mode 100644
index 000000000..8495a3537
--- /dev/null
+++ b/internal/edge/redirects_test.go
@@ -0,0 +1,319 @@
+package edge
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestParseRedirectsAcceptsTheSupportedForms(t *testing.T) {
+ rs, err := parseRedirects([]byte(`# a comment
+
+/old /new
+/blog/* /posts/:splat 302
+/docs/:slug /guide/:slug 307 # trailing comment
+/app/* /app/index.html 200
+/* /404.html 404
+/forced /elsewhere 301!
+https://www.example.com/* https://example.com/:splat 301!
+/ext https://example.org/x?y=1 308
+` + "/crlf /there 303\r\n"))
+ if err != nil {
+ t.Fatalf("parseRedirects: %v", err)
+ }
+ if _, err := parseRedirects([]byte("\ufeff/bom /ok\n")); err != nil {
+ t.Fatalf("a file starting with a byte-order mark: %v", err)
+ }
+ if got := len(rs.rules); got != 9 {
+ t.Fatalf("parsed %d rules, want 9", got)
+ }
+ if r := rs.rules[0]; r.status != 301 || r.force || r.line != 3 {
+ t.Fatalf("rule 0 = %+v, want a 301 from line 3, not forced", r)
+ }
+ if r := rs.rules[6]; r.host != "www.example.com" || !r.force || !r.splat || r.prefix != "https://example.com" {
+ t.Fatalf("host rule = %+v", r)
+ }
+}
+
+func TestParseRedirectsRefusesWhatItCannotServe(t *testing.T) {
+ for _, line := range []string{
+ "/only-a-source",
+ "/a /b 301 Country=us",
+ "/a /b 301 extra",
+ "/a /b 418",
+ "/a /b 200x",
+ "a /b",
+ "/caf%ZZ /c",
+ "/a%2Fb /c",
+ "/%3Aname /c",
+ "/a b",
+ "/a //evil.example",
+ `/a /\evil.example`,
+ "/a/*/b /c",
+ "/a* /c",
+ "/:splat /c",
+ "/:a/:a /c",
+ "/:a-b /c",
+ "/a//b /c",
+ "/a?x=1 /c",
+ "/a/../b /c",
+ "https://www.example.com:8443/ /c",
+ "/a https://example.org/ 200",
+ "/a https://example.org/ 404",
+ "/a ftp://example.org/",
+ "/a https:///nohost",
+ } {
+ rs, err := parseRedirects([]byte(line + "\n"))
+ if err == nil {
+ t.Errorf("parseRedirects(%q) = %+v, want an error", line, rs.rules)
+ continue
+ }
+ if !strings.Contains(err.Error(), "line 1:") {
+ t.Errorf("parseRedirects(%q) error %q does not name the line", line, err)
+ }
+ if len(rs.rules) != 0 {
+ t.Errorf("parseRedirects(%q) kept %d rules", line, len(rs.rules))
+ }
+ }
+}
+
+func TestParseRedirectsKeepsTheGoodLinesAroundABadOne(t *testing.T) {
+ rs, err := parseRedirects([]byte("/a /b\n/c /d 999\n/e /f\n"))
+ if err == nil || !strings.Contains(err.Error(), "line 2:") {
+ t.Fatalf("err = %v, want one naming line 2", err)
+ }
+ if len(rs.rules) != 2 || rs.rules[1].line != 3 {
+ t.Fatalf("rules = %+v, want lines 1 and 3", rs.rules)
+ }
+}
+
+func TestParseRedirectsLimits(t *testing.T) {
+ if _, err := parseRedirects([]byte(strings.Repeat("#", redirectsMaxBytes+1))); err == nil {
+ t.Fatal("an oversized file parsed")
+ }
+ var b strings.Builder
+ for range redirectsMaxRules + 1 {
+ b.WriteString("/a /b\n")
+ }
+ rs, err := parseRedirects([]byte(b.String()))
+ if err == nil || len(rs.rules) != redirectsMaxRules {
+ t.Fatalf("%d rules, err %v: want %d rules and an error", len(rs.rules), err, redirectsMaxRules)
+ }
+}
+
+func TestPackDirRefusesABadRedirectsFile(t *testing.T) {
+ dir := writeTree(t, map[string]string{
+ "index.html": "home",
+ "_redirects": "/ok /fine\n/a /b 418\n",
+ })
+ _, err := PackDir(dir)
+ if err == nil || !strings.Contains(err.Error(), "_redirects") || !strings.Contains(err.Error(), "line 2:") {
+ t.Fatalf("PackDir = %v, want a refusal naming _redirects line 2", err)
+ }
+ // Only the root file is the rules file; one deeper is content.
+ if _, err := PackDir(writeTree(t, map[string]string{"sub/_redirects": "not rules"})); err != nil {
+ t.Fatalf("PackDir with a nested _redirects: %v", err)
+ }
+}
+
+// redirectFixture ships a site with a _redirects file behind a static
+// handler, and returns a client that does not follow redirects.
+func redirectFixture(t *testing.T, rules string) (*httptest.Server, *http.Client) {
+ t.Helper()
+ store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
+ priv, pub := testKeys(t)
+ shipSite(t, store, "site", "v1", map[string]string{
+ "index.html": "home",
+ "old.html": "old page",
+ "forced.html": "forced page",
+ "style.css": "body{}",
+ "about/index.html": "about",
+ "app/index.html": "the app",
+ "docs/page.html": "a doc",
+ "404.html": "<!doctype html><p>custom not found",
+ "_redirects": rules,
+ })
+ if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
+ h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache"))
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ return srv, client
+}
+
+type redirectResult struct {
+ code int
+ location string
+ body string
+ resp *http.Response
+}
+
+func fetch(t *testing.T, c *http.Client, method, base, host, path string) redirectResult {
+ t.Helper()
+ req, err := http.NewRequest(method, base+path, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", method, path, err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return redirectResult{resp.StatusCode, resp.Header.Get("Location"), string(b), resp}
+}
+
+const siteRules = `
+/old.html /new.html 301
+/forced.html /elsewhere 302!
+/blog/* /posts/:splat 301
+/docs/:slug /guide/:slug?from=:slug 302
+/app/* /app/index.html 200
+https://www.example.com/* https://site.example.com/:splat 301!
+/loop/ /loop 301
+/ext https://example.org/x?y=1 307
+/empty/* /:splat/tail 301
+/frag /target#section 302
+/slash/* /slash/:splat/ 301!
+/caf%C3%A9 /cafe 301
+/files/:n /docs/:n.html 200
+/missing-rewrite /no/such/file 200
+/* /404.html 404
+`
+
+func TestStaticRedirects(t *testing.T) {
+ srv, c := redirectFixture(t, siteRules)
+ const host = "site.example.com"
+ for _, tc := range []struct {
+ host, path string
+ code int
+ location string // checked when code is a redirect
+ body string // checked when code is not
+ }{
+ // A file at the path shadows a rule; "!" overrides that.
+ {host, "/old.html", 200, "", "old page"},
+ {host, "/forced.html", 302, "/elsewhere", ""},
+ // Splats, placeholders, query strings.
+ {host, "/blog/2024/hello", 301, "/posts/2024/hello", ""},
+ {host, "/blog/2024/hello?x=1", 301, "/posts/2024/hello?x=1", ""},
+ {host, "/blog/2024/", 301, "/posts/2024/", ""},
+ {host, "/blog", 301, "/posts/", ""},
+ {host, "/docs/a%20b", 302, "/guide/a%20b?from=a+b", ""},
+ // Paths match decoded, as files are found: %2F separates segments.
+ {host, "/docs/a%2Fb", 404, "", "<!doctype html><p>custom not found"},
+ {host, "/ext?a=b", 307, "https://example.org/x?y=1", ""},
+ {host, "/frag?q=1", 302, "/target?q=1#section", ""},
+ // An existing file outside the rules is untouched.
+ {host, "/docs/page.html", 200, "", "a doc"},
+ // A directory with an index exists; a path under it is rewritten.
+ {host, "/app/", 200, "", "the app"},
+ {host, "/app/settings/profile", 200, "", "the app"},
+ // A redirect to the URL requested is skipped, not looped.
+ {host, "/loop/", 301, "/loop", ""},
+ {host, "/loop", 404, "", "<!doctype html><p>custom not found"},
+ // An empty capture never yields a protocol-relative Location.
+ {host, "/empty/", 301, "/tail", ""},
+ // A trailing-slash rule stops once the slash is there.
+ {host, "/slash/x", 301, "/slash/x/", ""},
+ {host, "/slash/x/", 404, "", "<!doctype html><p>custom not found"},
+ // A percent-encoded source matches the decoded request.
+ {host, "/caf%C3%A9", 301, "/cafe", ""},
+ // A "#" in a capture is part of the file name, not its end.
+ {host, "/files/page", 200, "", "a doc"},
+ {host, "/files/page.html%23", 404, "", "404 page not found\n"},
+ // The host rule matches its host only, and keeps the trailing slash.
+ {"www.example.com", "/about/", 301, "https://site.example.com/about/", ""},
+ {"WWW.Example.com.", "/style.css", 301, "https://site.example.com/style.css", ""},
+ {host, "/about/", 200, "", "about"},
+ // A rewrite to nothing is a plain 404; everything else hits /*.
+ {host, "/missing-rewrite", 404, "", "404 page not found\n"},
+ {host, "/nope", 404, "", "<!doctype html><p>custom not found"},
+ // The rules file is never served.
+ {host, "/_redirects", 404, "", "<!doctype html><p>custom not found"},
+ } {
+ got := fetch(t, c, "GET", srv.URL, tc.host, tc.path)
+ if got.code != tc.code {
+ t.Errorf("GET %s%s = %d (Location %q, body %q), want %d", tc.host, tc.path, got.code, got.location, got.body, tc.code)
+ continue
+ }
+ if tc.code >= 300 && tc.code < 400 {
+ if got.location != tc.location {
+ t.Errorf("GET %s%s Location = %q, want %q", tc.host, tc.path, got.location, tc.location)
+ }
+ } else if got.body != tc.body {
+ t.Errorf("GET %s%s body = %q, want %q", tc.host, tc.path, got.body, tc.body)
+ }
+ if v := got.resp.Header.Get("X-Carlos-Version"); v != "v1" {
+ t.Errorf("GET %s%s X-Carlos-Version = %q, want v1", tc.host, tc.path, v)
+ }
+ if cc := got.resp.Header.Get("Cache-Control"); cc != "no-cache" && tc.code != 404 {
+ t.Errorf("GET %s%s Cache-Control = %q, want no-cache", tc.host, tc.path, cc)
+ }
+ }
+
+ // The not-found page is typed and carries no validator.
+ got := fetch(t, c, "GET", srv.URL, host, "/nope")
+ if ct := got.resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("404 page Content-Type = %q, want text/html", ct)
+ }
+ if et := got.resp.Header.Get("ETag"); et != "" {
+ t.Errorf("404 page ETag = %q, want none", et)
+ }
+ if head := fetch(t, c, "HEAD", srv.URL, host, "/nope"); head.code != 404 || head.body != "" {
+ t.Errorf("HEAD /nope = %d %q, want 404 with no body", head.code, head.body)
+ }
+}
+
+func TestStaticSPAFallbackLeavesAssetsAlone(t *testing.T) {
+ srv, c := redirectFixture(t, "/* /index.html 200\n")
+ const host = "site.example.com"
+ for path, want := range map[string]string{
+ "/style.css": "body{}",
+ "/about/": "about",
+ "/some/client/route": "home",
+ "/_redirects": "home",
+ } {
+ if got := fetch(t, c, "GET", srv.URL, host, path); got.code != 200 || got.body != want {
+ t.Errorf("GET %s = %d %q, want 200 %q", path, got.code, got.body, want)
+ }
+ }
+ // A rewritten page revalidates like the file it serves.
+ first := fetch(t, c, "GET", srv.URL, host, "/some/route")
+ tag := first.resp.Header.Get("ETag")
+ if tag == "" {
+ t.Fatal("rewritten page has no ETag")
+ }
+ req, _ := http.NewRequest("GET", srv.URL+"/other/route", nil)
+ req.Host = host
+ req.Header.Set("If-None-Match", tag)
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNotModified {
+ t.Fatalf("conditional GET of a rewritten page = %d, want 304", resp.StatusCode)
+ }
+}
+
+func TestStaticWithoutRedirectsIsUnchanged(t *testing.T) {
+ _, _, _, srv := staticFixture(t)
+ c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ if got := fetch(t, c, "GET", srv.URL, "site.example.com", "/missing"); got.code != 404 || got.body != "404 page not found\n" {
+ t.Fatalf("GET /missing = %d %q, want the plain 404", got.code, got.body)
+ }
+ if got := fetch(t, c, "GET", srv.URL, "site.example.com", "/docs/page.html/index.html"); got.code != 404 {
+ t.Fatalf("GET under a file = %d, want 404", got.code)
+ }
+}
diff --git a/internal/edge/static.go b/internal/edge/static.go
index a61779f77..2f2fd0907 100644
--- a/internal/edge/static.go
+++ b/internal/edge/static.go
@@ -28,6 +28,7 @@ import (
"path"
"path/filepath"
"sort"
+ "strconv"
"strings"
"sync"
"sync/atomic"
@@ -96,6 +97,14 @@ func PackDir(dir string) ([]byte, error) {
if _, err := tw.Write(b); err != nil {
return nil, err
}
+ if name == redirectsFile {
+ // Refused here rather than skipped at the edge, so a rule the
+ // edge cannot use is an error the shipper sees, not a redirect
+ // that silently never happens.
+ if _, err := parseRedirects(b); err != nil {
+ return nil, fmt.Errorf("pack %s: %s: %w", dir, name, err)
+ }
+ }
}
if err := tw.Close(); err != nil {
return nil, err
@@ -123,10 +132,11 @@ type staticSite struct {
recheck time.Duration
mu sync.Mutex
- version string // currently served version; "" until first resolve
- checkedAt time.Time // when the pointer was last read
- digest string // the served tree's manifest digest
- name string // the served tree's directory: siteDirName(version, digest)
+ version string // currently served version; "" until first resolve
+ checkedAt time.Time // when the pointer was last read
+ digest string // the served tree's manifest digest
+ name string // the served tree's directory: siteDirName(version, digest)
+ redirects *redirectRules // the served tree's _redirects; nil when it has none
// ratchet is #253's per-box ratchet for this process's pointer reads
// (Edge.staticRatchet); nil admits every verified pointer.
@@ -152,7 +162,7 @@ func newStaticHandler(rt registry.Route, store release.Store, pub ed25519.Public
}
func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- dir, version, err := s.currentDir(r.Context())
+ tree, err := s.currentDir(r.Context())
if err != nil {
// Nothing has ever resolved here, so there is no version to name:
// this 502 stays unstamped rather than claiming a build it isn't
@@ -172,7 +182,7 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// stamps what is actually served, never the pointer's unreachable head.
// Set before any body is written, so it lands on the tree's own 404s too
// (the handler serving that tree IS a version).
- w.Header().Set("X-Carlos-Version", version)
+ w.Header().Set("X-Carlos-Version", tree.version)
// A page out of the tree is revalidated before it is reused, and so is
// the tree's own 404. With Last-Modified alone a browser GUESSES a
// freshness lifetime — typically a tenth of the file's age — and reuses
@@ -185,7 +195,15 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// from the errors it writes itself, a 416 or a 400: neither is a status a
// browser caches on a guess.)
w.Header().Set("Cache-Control", "no-cache")
- s.serveTree(dir, w, r)
+ s.serveTree(tree, w, r)
+}
+
+// servedTree is what one request is answered from: an extracted tree, the
+// version it holds, and the rules its _redirects declared. They travel
+// together so a request never pairs one version's files with another's rules.
+type servedTree struct {
+ dir, version string
+ redirects *redirectRules
}
// dir is where the tree named name lives once extracted.
@@ -216,7 +234,8 @@ func (s *staticSite) appDir() string {
// afterwards so ServeHTTP can stamp X-Carlos-Version without re-taking mu (and
// without the window in between, where a concurrent resolve could swap
// s.version and make the header describe a different tree than the one served).
-func (s *staticSite) currentDir(ctx context.Context) (dir, version string, err error) {
+// The tree's redirect rules come back with it for the same reason.
+func (s *staticSite) currentDir(ctx context.Context) (servedTree, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.version != "" && s.recheck > 0 && time.Since(s.checkedAt) < s.recheck {
@@ -224,7 +243,7 @@ func (s *staticSite) currentDir(ctx context.Context) (dir, version string, err e
// version, and stamping that version is honest by construction — the
// header names what the bytes are, not what the pointer may have
// advanced to since the last check.
- return s.dir(s.name), s.version, nil
+ return s.served(), nil
}
cp, err := release.Channel(ctx, s.store, s.pub, s.acct, s.app, s.channel)
if err != nil {
@@ -242,22 +261,48 @@ func (s *staticSite) currentDir(ctx context.Context) (dir, version string, err e
return s.stale(fmt.Errorf("swap current -> %s: %w", name, err))
}
s.version, s.digest, s.name = cp.Version, digest, name
+ s.redirects = s.loadRedirects(s.dir(name), cp.Version)
}
s.checkedAt = time.Now()
- return s.dir(s.name), s.version, nil
+ return s.served(), nil
+}
+
+// served is the tree currently being served. The caller holds mu.
+func (s *staticSite) served() servedTree {
+ return servedTree{dir: s.dir(s.name), version: s.version, redirects: s.redirects}
+}
+
+// loadRedirects parses the _redirects at the root of an extracted tree, once
+// per version swap. A tree without one has no rules. A line that does not
+// parse is logged and skipped: ship refuses such a tree (PackDir), so only a
+// tree packed some other way gets here, and its remaining rules still apply.
+func (s *staticSite) loadRedirects(dir, version string) *redirectRules {
+ data, err := os.ReadFile(filepath.Join(dir, redirectsFile))
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ log.Printf("edge: static %s/%s: %s %s: %v — serving without its rules", s.app, s.channel, version, redirectsFile, err)
+ return nil
+ }
+ rules, err := parseRedirects(data)
+ if err != nil {
+ log.Printf("edge: static %s/%s: %s %s: %s", s.app, s.channel, version, redirectsFile, strings.ReplaceAll(err.Error(), "\n", "; "))
+ }
+ return rules
}
// stale is the degraded path: keep serving the version we already verified
// and extracted — and name it, so the response header describes the CACHED
// tree rather than the head this handler could not reach — or fail if there
// has never been one.
-func (s *staticSite) stale(err error) (string, string, error) {
+func (s *staticSite) stale(err error) (servedTree, error) {
if s.version == "" {
- return "", "", err
+ return servedTree{}, err
}
log.Printf("edge: static %s/%s: %v — still serving cached %s", s.app, s.channel, err, s.version)
s.checkedAt = time.Now() // back off: retry next recheck window, not next request
- return s.dir(s.name), s.version, nil
+ return s.served(), nil
}
// ensureExtracted makes the version's verified tree exist and returns its
@@ -398,7 +443,9 @@ func extractSiteTar(data []byte, dst string) error {
// serveTree answers one request from an extracted version dir:
// http.FileServer semantics with index.html for directories and a clean 404
-// (never a directory listing) when neither the file nor an index exists.
+// (never a directory listing) when neither the file nor an index exists —
+// after the tree's _redirects (redirects.go) has had its say. A rule applies
+// only where no file answers the path, unless it is forced.
//
// It serves the file itself rather than through http.ServeFile, because
// ServeFile offers the file's mtime as Last-Modified and answers
@@ -412,7 +459,7 @@ func extractSiteTar(data []byte, dst string) error {
// The validator is the file's content (tagCache). What ServeFile did besides
// is kept by hand: the refusal of a ".." element, and the redirect off a
// trailing /index.html onto its directory.
-func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Request) {
+func (s *staticSite) serveTree(tree servedTree, w http.ResponseWriter, r *http.Request) {
if containsDotDot(r.URL.Path) {
// The Clean below would resolve it harmlessly inside the tree; this
// keeps the answer ServeFile always gave such a path.
@@ -421,27 +468,54 @@ func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Reques
}
// Clean rooted at "/" so no request path can climb out of the tree.
p := path.Clean("/" + r.URL.Path)
- fp := filepath.Join(dir, filepath.FromSlash(p))
- fi, err := os.Stat(fp)
- if err == nil && fi.IsDir() {
- fp = filepath.Join(fp, "index.html")
- fi, err = os.Stat(fp)
- }
- if err != nil || fi.IsDir() {
- http.NotFound(w, r)
- return
- }
- if strings.HasSuffix(r.URL.Path, "/index.html") {
- // Relative, as ServeFile wrote it, so it survives any prefix the
- // request arrived under.
- loc := "./"
- if q := r.URL.RawQuery; q != "" {
- loc += "?" + q
+ status, rewritten := http.StatusOK, false
+ for {
+ fp := filepath.Join(tree.dir, filepath.FromSlash(p))
+ fi, err := os.Stat(fp)
+ if err == nil && fi.IsDir() {
+ fp = filepath.Join(fp, "index.html")
+ fi, err = os.Stat(fp)
}
- w.Header().Set("Location", loc)
- w.WriteHeader(http.StatusMovedPermanently)
+ // The _redirects file is configuration and answers nothing.
+ found := err == nil && !fi.IsDir() && p != "/"+redirectsFile
+ if !rewritten {
+ if act, ok := tree.redirects.resolve(requestHost(r.Host), p, r.URL.Path, r.URL.RawQuery, found); ok {
+ if act.location != "" {
+ http.Redirect(w, r, act.location, act.status)
+ return
+ }
+ // A rewrite serves another file of the tree at this URL. It is
+ // looked up once: rules are not consulted a second time, and a
+ // destination with no file is a plain 404.
+ p, status, rewritten = act.file, act.status, true
+ continue
+ }
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+ if !rewritten && strings.HasSuffix(r.URL.Path, "/index.html") {
+ // Relative, as ServeFile wrote it, so it survives any prefix the
+ // request arrived under.
+ loc := "./"
+ if q := r.URL.RawQuery; q != "" {
+ loc += "?" + q
+ }
+ w.Header().Set("Location", loc)
+ w.WriteHeader(http.StatusMovedPermanently)
+ return
+ }
+ s.serveFile(fp, p, fi, status, w, r)
return
}
+}
+
+// serveFile sends the tree file at fp, whose tree path is p (for logs). A 200
+// gets a content tag and net/http's conditional and range handling. Any other
+// status — a _redirects 404 page — is the file's bytes under that status,
+// with no validator: ServeContent knows only how to succeed.
+func (s *staticSite) serveFile(fp, p string, fi os.FileInfo, status int, w http.ResponseWriter, r *http.Request) {
f, err := os.Open(fp)
if err != nil {
// ServeFile's mapping, kept: a file that vanished since the Stat is
@@ -459,6 +533,26 @@ func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Reques
return
}
defer f.Close()
+ if status != http.StatusOK {
+ ctype := mime.TypeByExtension(filepath.Ext(fp))
+ if ctype == "" {
+ var head [512]byte
+ n, _ := io.ReadFull(f, head[:])
+ ctype = http.DetectContentType(head[:n])
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ log.Printf("edge: static %s/%s: rewind %s: %v", s.app, s.channel, p, err)
+ http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
+ w.WriteHeader(status)
+ if r.Method != http.MethodHead {
+ io.Copy(w, f)
+ }
+ return
+ }
// Stat the OPEN file: its identity is what a remembered tag is checked
// against, and it is the file whose bytes are about to be sent.
if ofi, err := f.Stat(); err == nil && !ofi.IsDir() {
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 5b5d181c5..a38913fe7 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1408,6 +1408,21 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-db-region-forward-copy.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay static-redirects (static sites honour a _redirects
+ // file), loaded last, after db-region-forward-copy. Proposed; ruling
+ // asked of the census owner on #280. PackDir and staticSite.serveTree are
+ // re-frozen; staticSite.serveFile takes serveTree's Open, and
+ // staticSite.loadRedirects reads the tree's _redirects once per version
+ // swap. Every read stays inside the extracted, verified tree. Frozen
+ // declarations are added, so the seal moves; plans-3a-creation-caps's
+ // (unchanged by db-region-forward-copy) is checked first.
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-static-redirects.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "65857c29501dc7676f52921bea79d08217542ecb1d775c67d20a3460c851d831"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-static-redirects.tsv b/internal/provision/testdata/plan14-root-reads-static-redirects.tsv
new file mode 100644
index 000000000..3ee529d89
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-static-redirects.tsv
@@ -0,0 +1,11 @@
+# Census overlay static-redirects (static sites honour a _redirects file: redirects, rewrites and a not-found page), loaded last, after db-region-forward-copy. PROPOSED-static-redirects; the ruling is asked of the census owner on #280.
+# PackDir (the CLI's site packer) and staticSite.serveTree (the edge's static handler) are re-frozen; serveTree's Open moves into the new staticSite.serveFile, and the new staticSite.loadRedirects reads the tree's _redirects once per version swap. Every read stays inside the extracted, digest-verified tree; none is a root decision. Frozen declarations are added, so the seal moves; the internal/edge envelope is re-pinned.
+@legacy 4c0e4dfcd4f19d14eaf317510eb7968c4388507a61dd1504b62d7ece172b80a9 internal/edge/static.go PackDir legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=PROPOSED-static-redirects;identity=the carlos CLI (carlos ship and carlos deploy --kind static, on the member's own machine);basis=also parses the root _redirects file it has already read (the same ReadFile) and refuses the pack when a line does not parse; no read added, offsets unchanged file:ReadFile@626,file:WalkDir@71 16bf7592a19c80d6447dc6a9b3fa5dd65e881dbe3b0c6efd8b510efb3cf6c120
+@legacy ac8b1bc328c04f742fd0c2245adb9258ea689062f83226017f18d638bb795aff internal/edge/static.go staticSite.serveTree legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=the same index-aware Stat lookup inside the extracted tree, now run at most twice (the request path, then once for a _redirects rewrite destination cleaned rooted inside the same tree); the Open moves to serveFile; the _redirects file itself is never answered; no root decision file:Stat@545,file:Stat@642 c0e1003c7eb524445bada46e0fb35f456ae82273db6a58d8cc3163f5017cc065
+@legacy fa5fa8fd610a49985faa232d832a492c6d02e6365394e11e022a83a7377bea59 internal/edge/static.go staticSite.serveFile legacy accepted-legacy accepted-legacy;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input; serveTree's former Open and open-file Stat of the file serveTree resolved inside the extracted, digest-verified tree, unchanged, plus a non-200 branch that copies the same open file under a _redirects 404 status; no root decision file:Open@126,file:Stat@1503 4f2f2217d586cbb588f3a96af125f064e6818911dc9e7e92f70db3e0aaa9e498
+@legacy 5c2e4f1044884dc6400ccacc618ffaa4746507b27f57bc4eebda54f989018898 internal/edge/static.go staticSite.loadRedirects legacy accepted-legacy accepted-legacy;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input; reads the _redirects file at the root of the extracted, digest-verified tree once per version swap, under the site's mutex, into immutable per-version rules; an unreadable or bad file only loses rules; no root decision file:ReadFile@87 31999f705b500658bc4ef627b978a4e4f2917321056856f167ed00751c9de303
+internal/edge/static.go PackDir legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=PROPOSED-static-redirects;identity=the carlos CLI (carlos ship and carlos deploy --kind static, on the member's own machine);basis=also parses the root _redirects file it has already read (the same ReadFile) and refuses the pack when a line does not parse; no read added, offsets unchanged file:ReadFile@626,file:WalkDir@71 16bf7592a19c80d6447dc6a9b3fa5dd65e881dbe3b0c6efd8b510efb3cf6c120
+internal/edge/static.go staticSite.serveTree legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=the same index-aware Stat lookup inside the extracted tree, now run at most twice (the request path, then once for a _redirects rewrite destination cleaned rooted inside the same tree); the Open moves to serveFile; the _redirects file itself is never answered; no root decision file:Stat@545,file:Stat@642 c0e1003c7eb524445bada46e0fb35f456ae82273db6a58d8cc3163f5017cc065
+internal/edge/static.go staticSite.serveFile legacy accepted-legacy accepted-legacy;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input; serveTree's former Open and open-file Stat of the file serveTree resolved inside the extracted, digest-verified tree, unchanged, plus a non-200 branch that copies the same open file under a _redirects 404 status; no root decision file:Open@126,file:Stat@1503 4f2f2217d586cbb588f3a96af125f064e6818911dc9e7e92f70db3e0aaa9e498
+internal/edge/static.go staticSite.loadRedirects legacy accepted-legacy accepted-legacy;rev3=PROPOSED-static-redirects;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input; reads the _redirects file at the root of the extracted, digest-verified tree once per version swap, under the site's mutex, into immutable per-version rules; an unreadable or bad file only loses rules; no root decision file:ReadFile@87 31999f705b500658bc4ef627b978a4e4f2917321056856f167ed00751c9de303
+@coverage internal/edge f9465bb2791d0fbbe9743431d3da5d24ab4397d6f1f0ec2c2533d3cc091a7487