carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 9da43d1b01f6786e04998f0ab077a9a1c2af3613 parent ae5e811
4 files changed, +204 −40
  • internal/edge/edge.go +8 −0
  • internal/edge/static.go +59 −39
  • internal/edge/static_cache_test.go +1 −1
  • internal/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)
+ }
+}