settings-1 task 9: static sites check pointer digests and key their cache by them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+204
−40
internal/edge/edge.go+8 −0internal/edge/static.go+59 −39internal/edge/static_cache_test.go+1 −1internal/edge/staticdigest_test.go+136 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 4f95941a8..8dd195e72 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -148,6 +148,9 @@ type Edge struct { |
| staticPub ed25519.PublicKey |
| staticCacheDir string |
| staticRecheck time.Duration |
| + // staticRatchet is #253's ratchet for static pointer reads: marks under |
| + // <dataDir>/pointer-digests, root's under /opt read too. |
| + staticRatchet *release.DigestRatchet |
| // usageMeter, when non-nil, is where ServeHTTP records the request/byte |
| // count of every route it serves (see EnableUsage). Atomic because it is |
| // armed by the agent role after Edge construction, mirroring how |
| @@ -560,6 +563,7 @@ func (e *Edge) staticHandlerFor(rt registry.Route) http.Handler { |
| h := newStaticHandler(rt, e.staticStore, e.staticPub, e.staticCacheDir) |
| if ss, ok := h.(*staticSite); ok { |
| ss.recheck = e.staticRecheck |
| + ss.ratchet = e.staticRatchet |
| } |
| return h |
| } |
| @@ -3339,6 +3343,10 @@ func enableStaticFromEnv(e *Edge, dataDir string) error { |
| store = release.NewDirStore(dir) |
| } |
| e.EnableStatic(store, pub, filepath.Join(dataDir, "static")) |
| + // The process's one ratchet for this directory (Task 8): the agent's |
| + // adoption passes and this reader share it. |
| + e.staticRatchet = release.SharedDigestRatchet(release.EdgeRatchetDir(dataDir), |
| + []string{release.RootRatchetDir(release.OptDir())}, release.RequirePointerDigest(os.Getenv)) |
| // Rebuild the route table so static routes that loaded as 404 stubs in |
| // New's startup reload get real handlers. |
| return e.Reload() |
diff --git a/internal/edge/static.go b/internal/edge/static.go| index 247ca200d..a61779f77 100644 |
| --- a/internal/edge/static.go |
| +++ b/internal/edge/static.go |
| @@ -125,6 +125,12 @@ type staticSite struct { |
| 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) |
| + |
| + // ratchet is #253's per-box ratchet for this process's pointer reads |
| + // (Edge.staticRatchet); nil admits every verified pointer. |
| + ratchet *release.DigestRatchet |
| } |
| // newStaticHandler builds the serving handler for one static route. The |
| @@ -182,11 +188,17 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| s.serveTree(dir, w, r) |
| } |
| -// dir is where version's tree lives once extracted. |
| -func (s *staticSite) dir(version string) string { |
| - return filepath.Join(s.appDir(), version) |
| +// dir is where the tree named name lives once extracted. |
| +func (s *staticSite) dir(name string) string { |
| + return filepath.Join(s.appDir(), name) |
| } |
| +// siteDirName keys the extracted-site cache by the verified manifest digest |
| +// as well as the version (#253, spec section 4.6), so a tree extracted from |
| +// other bytes under a reused name is never reused. A bare version name, the |
| +// key before this change, never matches. |
| +func siteDirName(version, digest string) string { return version + "@" + digest } |
| + |
| // appDir is this app's cache root, under its account: app names are unique |
| // per ACCOUNT, so two accounts' "docs" sites must never share a tree. |
| func (s *staticSite) appDir() string { |
| @@ -212,23 +224,27 @@ 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.version), s.version, nil |
| + return s.dir(s.name), s.version, nil |
| } |
| cp, err := release.Channel(ctx, s.store, s.pub, s.acct, s.app, s.channel) |
| if err != nil { |
| return s.stale(fmt.Errorf("resolve channel: %w", err)) |
| } |
| - if cp.Version != s.version { |
| - if err := s.ensureExtracted(ctx, cp.Version); err != nil { |
| + if err := s.ratchet.Admit(s.acct, cp); err != nil { |
| + return s.stale(err) |
| + } |
| + if want := cp.Digest; cp.Version != s.version || (want != "" && want != s.digest) { |
| + name, digest, err := s.ensureExtracted(ctx, cp.Version, want) |
| + if err != nil { |
| return s.stale(fmt.Errorf("stage %s: %w", cp.Version, err)) |
| } |
| - if err := s.pointCurrent(cp.Version); err != nil { |
| - return s.stale(fmt.Errorf("swap current -> %s: %w", cp.Version, err)) |
| + if err := s.pointCurrent(name); err != nil { |
| + return s.stale(fmt.Errorf("swap current -> %s: %w", name, err)) |
| } |
| - s.version = cp.Version |
| + s.version, s.digest, s.name = cp.Version, digest, name |
| } |
| s.checkedAt = time.Now() |
| - return s.dir(s.version), s.version, nil |
| + return s.dir(s.name), s.version, nil |
| } |
| // stale is the degraded path: keep serving the version we already verified |
| @@ -241,27 +257,31 @@ func (s *staticSite) stale(err error) (string, string, error) { |
| } |
| 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.version), s.version, nil |
| + return s.dir(s.name), s.version, nil |
| } |
| -// ensureExtracted makes <cacheDir>/<app>/<version>/ exist and hold the |
| -// release's verified site tree. The tar's sha256 is checked against the |
| -// manifest BEFORE extraction — tampered artifact bytes never touch the |
| -// serving path — and extraction goes to a temp dir renamed into place, so |
| -// the version dir's existence means "complete and verified". app and version |
| -// are path-safe by construction: both came out of a signature-verified |
| -// pointer whose fields passed ValidApp/ValidVersion. |
| -func (s *staticSite) ensureExtracted(ctx context.Context, version string) error { |
| - dst := s.dir(version) |
| - if _, err := os.Stat(dst); err == nil { |
| - return nil // already staged by an earlier check (or a sibling handler) |
| - } |
| - m, err := release.GetManifest(ctx, s.store, s.acct, s.app, version) |
| +// ensureExtracted makes the version's verified tree exist and returns its |
| +// directory name and manifest digest. want is the pointer entry's signed |
| +// digest: when set, the manifest must hash to it before anything is |
| +// downloaded (#253, spec section 4.6), and either way the tree is keyed by the |
| +// digest of the manifest actually used. The tar's sha256 is checked against |
| +// that manifest before extraction, and extraction goes to a temp dir renamed |
| +// into place, so a directory's existence means "complete and verified". app |
| +// and version are path-safe by construction: both came out of a |
| +// signature-verified pointer whose fields passed ValidApp/ValidVersion. |
| +func (s *staticSite) ensureExtracted(ctx context.Context, version, want string) (string, string, error) { |
| + m, raw, err := release.CheckManifestDigest(ctx, s.store, s.acct, s.app, version, want) |
| if err != nil { |
| - return err |
| + return "", "", err |
| + } |
| + digest := release.ManifestDigest(raw) |
| + name := siteDirName(version, digest) |
| + dst := s.dir(name) |
| + if _, err := os.Stat(dst); err == nil { |
| + return name, digest, nil // extracted by an earlier check or a sibling handler |
| } |
| if m.Kind != "static" { |
| - return fmt.Errorf("release %s/%s is kind %q, not static", s.app, version, m.Kind) |
| + return "", "", fmt.Errorf("release %s/%s is kind %q, not static", s.app, version, m.Kind) |
| } |
| var art *release.Artifact |
| for _, a := range m.Artifacts { |
| @@ -271,50 +291,50 @@ func (s *staticSite) ensureExtracted(ctx context.Context, version string) error |
| } |
| } |
| if art == nil { |
| - return fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) |
| + return "", "", fmt.Errorf("release %s/%s has no %s artifact", s.app, version, siteArtifactFile) |
| } |
| data, _, err := release.GetQualified(ctx, s.store, release.ArtifactKey(s.acct, s.app, version, art.File)) |
| if err != nil { |
| - return err |
| + return "", "", err |
| } |
| sum := sha256.Sum256(data) |
| if hex.EncodeToString(sum[:]) != art.SHA256 { |
| - return fmt.Errorf("%s sha256 mismatch (manifest %s, stored %s) — refusing to extract", |
| + return "", "", fmt.Errorf("%s sha256 mismatch (manifest %s, stored %s): refusing to extract", |
| siteArtifactFile, art.SHA256, hex.EncodeToString(sum[:])) |
| } |
| if int64(len(data)) != art.Size { |
| - return fmt.Errorf("%s size mismatch (manifest %d, stored %d)", siteArtifactFile, art.Size, len(data)) |
| + return "", "", fmt.Errorf("%s size mismatch (manifest %d, stored %d)", siteArtifactFile, art.Size, len(data)) |
| } |
| appDir := s.appDir() |
| if err := os.MkdirAll(appDir, 0o755); err != nil { |
| - return err |
| + return "", "", err |
| } |
| tmp, err := os.MkdirTemp(appDir, ".extract-") |
| if err != nil { |
| - return err |
| + return "", "", err |
| } |
| if err := extractSiteTar(data, tmp); err != nil { |
| os.RemoveAll(tmp) |
| - return err |
| + return "", "", err |
| } |
| if err := os.Rename(tmp, dst); err != nil { |
| os.RemoveAll(tmp) |
| if _, serr := os.Stat(dst); serr == nil { |
| - return nil // lost the staging race; the winner's tree is identical |
| + return name, digest, nil // lost the staging race; the winner's tree is identical |
| } |
| - return err |
| + return "", "", err |
| } |
| - return nil |
| + return name, digest, nil |
| } |
| -// pointCurrent atomically swaps the app's `current` symlink to version's dir |
| +// pointCurrent atomically swaps the app's `current` symlink to the tree named name |
| // (temp symlink + rename — the same swap versions.go's PointLive uses), so |
| // anything looking at the cache dir sees the version flip as one atomic step. |
| -func (s *staticSite) pointCurrent(version string) error { |
| +func (s *staticSite) pointCurrent(name string) error { |
| link := filepath.Join(s.appDir(), "current") |
| tmp := link + ".tmp" |
| _ = os.Remove(tmp) // a leftover from a crashed swap |
| - if err := os.Symlink(version, tmp); err != nil { |
| + if err := os.Symlink(name, tmp); err != nil { |
| return err |
| } |
| if err := os.Rename(tmp, link); err != nil { |
diff --git a/internal/edge/static_cache_test.go b/internal/edge/static_cache_test.go| index ff7d9178f..04b23a846 100644 |
| --- a/internal/edge/static_cache_test.go |
| +++ b/internal/edge/static_cache_test.go |
| @@ -74,7 +74,7 @@ func siteServerTree(t *testing.T, app, version string, files map[string]string) |
| h.ServeHTTP(w, r) |
| })) |
| t.Cleanup(srv.Close) |
| - return srv, h.(*staticSite).dir(version) |
| + return srv, h.(*staticSite).dir(siteDirOf(t, store, app, version)) |
| } |
| // A static response used to carry Last-Modified and nothing else, and a |
diff --git a/internal/edge/staticdigest_test.go b/internal/edge/staticdigest_test.go| new file mode 100644 |
| index 000000000..a6ad3708a |
| --- /dev/null |
| +++ b/internal/edge/staticdigest_test.go |
| @@ -0,0 +1,136 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func siteDirOf(t *testing.T, store release.Store, app, version string) string { |
| + t.Helper() |
| + raw, _, err := store.Get(context.Background(), release.ManifestKey(release.OpsAccount, app, version)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return siteDirName(version, release.ManifestDigest(raw)) |
| +} |
| + |
| +func digestSite(t *testing.T, ratchet *release.DigestRatchet) (release.Store, *staticSite, *httptest.Server, func(string, map[string]string)) { |
| + t.Helper() |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + priv, pub := testKeys(t) |
| + if err := release.WriteDigestMarker(context.Background(), store, release.RootSigner(priv), release.OpsAccount, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + promote := func(v string, files map[string]string) { |
| + shipSite(t, store, "site", v, files) |
| + if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", v); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + promote("v1", map[string]string{"index.html": "home v1"}) |
| + 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")).(*staticSite) |
| + h.recheck = 0 |
| + h.ratchet = ratchet |
| + srv := httptest.NewServer(h) |
| + t.Cleanup(srv.Close) |
| + return store, h, srv, promote |
| +} |
| + |
| +func body(t *testing.T, url string) (string, string) { |
| + t.Helper() |
| + res, err := http.Get(url) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + b, _ := io.ReadAll(res.Body) |
| + return string(b), res.Header.Get("X-Carlos-Version") |
| +} |
| + |
| +// Spec §8: a static site whose version name is reused, under a pointer with |
| +// digests, is refused before extraction. |
| +func TestStaticRefusesAReusedNameBeforeExtraction(t *testing.T) { |
| + store, h, srv, promote := digestSite(t, nil) |
| + if b, v := body(t, srv.URL+"/"); b != "home v1" || v != "v1" { |
| + t.Fatalf("first serve = %q %q", b, v) |
| + } |
| + saved, _, _ := store.Get(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x")) |
| + promote("v2", map[string]string{"index.html": "home v2"}) |
| + if b, _ := body(t, srv.URL+"/"); b != "home v2" { |
| + t.Fatalf("v2 not served: %q", b) |
| + } |
| + if err := release.DeleteVersion(context.Background(), store, release.OpsAccount, "site", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipSite(t, store, "site", "v1", map[string]string{"index.html": "planted"}) |
| + _, etag, _ := store.Get(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x")) |
| + if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "site", "canary/x"), saved, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, v := body(t, srv.URL+"/"); b == "planted" || v != "v2" { |
| + t.Fatalf("after the replay = %q %q; want v2 still served", b, v) |
| + } |
| + if _, err := os.Stat(filepath.Join(h.appDir(), siteDirOf(t, store, "site", "v1"))); err == nil { |
| + t.Fatal("the reused name's archive was extracted") |
| + } |
| +} |
| + |
| +// Spec §8: a cached tree from other bytes is not served. |
| +func TestStaticNeverServesACachedTreeFromOtherBytes(t *testing.T) { |
| + store, h, srv, _ := digestSite(t, nil) |
| + other := filepath.Join(h.appDir(), siteDirName("v1", release.ManifestDigest([]byte("other manifest")))) |
| + if err := os.MkdirAll(other, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(other, "index.html"), []byte("stale tree"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, _ := body(t, srv.URL+"/"); b != "home v1" { |
| + t.Fatalf("served %q from a tree keyed by another digest", b) |
| + } |
| + if _, err := os.Stat(filepath.Join(h.appDir(), siteDirOf(t, store, "site", "v1"), "index.html")); err != nil { |
| + t.Fatalf("not extracted under its own digest: %v", err) |
| + } |
| +} |
| + |
| +// Review Focus 5. |
| +func TestStaticNeverReusesALegacyCacheDir(t *testing.T) { |
| + _, h, srv, _ := digestSite(t, nil) |
| + legacy := filepath.Join(h.appDir(), "v1") |
| + if err := os.MkdirAll(legacy, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(legacy, "index.html"), []byte("pre-upgrade tree"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, _ := body(t, srv.URL+"/"); b != "home v1" { |
| + t.Fatalf("served %q from the bare-version directory", b) |
| + } |
| +} |
| + |
| +func TestStaticRatchetRefusesALegacyPointerAfterDigests(t *testing.T) { |
| + r := release.NewDigestRatchet(t.TempDir(), nil, false) |
| + _, _, srv, _ := digestSite(t, r) |
| + if _, v := body(t, srv.URL+"/"); v != "v1" { |
| + t.Fatalf("version %q", v) |
| + } |
| + if err := r.Admit(release.OpsAccount, &release.ChannelPointer{App: "site", Channel: "canary/x", Version: "v1"}); err == nil { |
| + t.Fatal("the edge's ratchet did not arm on the digest-bearing pointer it served") |
| + } |
| +} |
| + |
| +func TestStaticWithoutDigestsServesAsBefore(t *testing.T) { |
| + _, _, _, srv := staticFixture(t) |
| + if b, v := body(t, srv.URL+"/"); b != "home v1" || v != "v1" { |
| + t.Fatalf("legacy site = %q %q", b, v) |
| + } |
| +} |