carlos / platform Public

release: the asset path grammar and the manifest gate (part 1b)

ValidAssetPaths enforces the spec 3.1 grammar with the approved refusals
EAC-3 to EAC-7. validateManifest calls it after the capabilities check and
refuses an assets block on a static release with EAC-8. EAC-3 to EAC-7 join
assetCacheCopyBuilt; EAC-8 waits for cli.go in Task 2.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6dcf5896fb0682a4739cc2e8956f5a6f0bf99152 parent 67c8bdf
4 files changed, +202 −2
  • approved_asset_cache_copy_test.go +3 −2
  • internal/release/assets.go +69 −0
  • internal/release/assets_test.go +117 −0
  • internal/release/release.go +13 −0
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go
index 2e9619b51..99da8225c 100644
--- a/approved_asset_cache_copy_test.go
+++ b/approved_asset_cache_copy_test.go
@@ -37,8 +37,9 @@ var assetCacheCopyIDs = func() []string {
// assetCacheCopyBuilt is every id whose text the code or docs carry. The task
// that writes a row's text adds its id here in the same commit, once Paul has
-// approved the row. It starts empty: the copy review comes first.
-var assetCacheCopyBuilt = []string{}
+// approved the row. EAC-8 is not here yet: its row names cli.go, which Task 2
+// writes (release.go carries the same sentence for the manifest gate).
+var assetCacheCopyBuilt = []string{"EAC-3", "EAC-4", "EAC-5", "EAC-6", "EAC-7"}
func mustParseAssetCacheCopyRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/release/assets.go b/internal/release/assets.go
index 7aa7453b2..17fafa957 100644
--- a/internal/release/assets.go
+++ b/internal/release/assets.go
@@ -1,5 +1,12 @@
package release
+import (
+ "errors"
+ "fmt"
+ "path"
+ "strings"
+)
+
// Assets is a release's declaration of which URL path prefixes hold its
// content-addressed static assets (hashed bundles, images, fonts) that an
// edge may cache. ADDITIVE to schema 1: see Manifest.Assets.
@@ -10,3 +17,65 @@ type Assets struct {
// Paths are URL path prefixes such as "/assets/".
Paths []string `json:"paths"`
}
+
+// maxAssetPaths and maxAssetPathBytes are the grammar's two size limits.
+const (
+ maxAssetPaths = 8
+ maxAssetPathBytes = 128
+)
+
+// ValidAssetPaths is the one gate for a release's asset path prefixes. Ship,
+// deploy and the console's ship door all call it, and validateManifest calls
+// it on every manifest write and read, so a declaration is checked the same
+// way everywhere. A nil or empty list is valid and declares nothing.
+//
+// The grammar is byte-wise. Each path:
+// - starts and ends with "/", and is not "/" alone (that would cache every
+// page of the site);
+// - is at most 128 bytes, and is already path.Clean'd apart from its
+// trailing slash (no "//" and no "." parts);
+// - contains none of "%", "?", "#", "*" or "..".
+//
+// The list holds at most 8 paths, and no path is a prefix of another (a
+// duplicate is a prefix of its twin). "/Assets/" and "/assets/" are distinct.
+//
+// The errors are the approved copy rows EAC-3 to EAC-7 (docs/superpowers/
+// plans/2026-10-24-edge-asset-cache-copy.md), verbatim.
+func ValidAssetPaths(paths []string) error {
+ if len(paths) > maxAssetPaths {
+ return errAssetPathList()
+ }
+ for _, p := range paths {
+ if err := validAssetPath(p); err != nil {
+ return err
+ }
+ }
+ for i, a := range paths {
+ for j, b := range paths {
+ if i != j && strings.HasPrefix(b, a) {
+ return errAssetPathList()
+ }
+ }
+ }
+ return nil
+}
+
+func errAssetPathList() error {
+ return errors.New("declare at most 8 asset paths, and none inside another, such as /a/ and /a/b/. drop the extras and try again.")
+}
+
+func validAssetPath(p string) error {
+ if p == "/" {
+ return errors.New("asset path / would cache every page on the site. name the folder your build writes to, for example /assets/.")
+ }
+ if len(p) < 2 || p[0] != '/' || p[len(p)-1] != '/' {
+ return fmt.Errorf("asset path %s must start and end with /. name the folder your build writes to, for example /assets/.", p)
+ }
+ if strings.ContainsAny(p, "%?#*") || strings.Contains(p, "..") {
+ return fmt.Errorf("asset path %s cannot contain %%, ?, #, * or \"..\". write a plain folder path such as /assets/.", p)
+ }
+ if len(p) > maxAssetPathBytes || strings.Contains(p, "//") || path.Clean(p[:len(p)-1])+"/" != p {
+ return fmt.Errorf("asset path %s must be at most 128 bytes, with no doubled slashes and no . parts. write it the way a clean folder path reads, for example /_app/immutable/.", p)
+ }
+ return nil
+}
diff --git a/internal/release/assets_test.go b/internal/release/assets_test.go
index 3e838194d..afc4f7c38 100644
--- a/internal/release/assets_test.go
+++ b/internal/release/assets_test.go
@@ -2,6 +2,7 @@ package release
import (
"encoding/json"
+ "fmt"
"reflect"
"strings"
"testing"
@@ -56,3 +57,119 @@ func TestManifestAssetsRoundTrip(t *testing.T) {
t.Fatalf("old manifest parsed with Assets = %+v, want nil", old.Assets)
}
}
+
+// The approved refusal texts (copy record EAC-3 to EAC-8), written out here
+// independently of the code so a rewording in assets.go fails a test.
+const (
+ eac3 = "asset path %s must start and end with /. name the folder your build writes to, for example /assets/."
+ eac4 = "asset path / would cache every page on the site. name the folder your build writes to, for example /assets/."
+ eac5 = `asset path %s cannot contain %%, ?, #, * or "..". write a plain folder path such as /assets/.`
+ eac6 = "asset path %s must be at most 128 bytes, with no doubled slashes and no . parts. write it the way a clean folder path reads, for example /_app/immutable/."
+ eac7 = "declare at most 8 asset paths, and none inside another, such as /a/ and /a/b/. drop the extras and try again."
+ eac8 = "a static site is already served from every region, so it has no asset folders to declare. leave --assets off."
+)
+
+func TestValidAssetPathsTable(t *testing.T) {
+ eight := []string{"/a/", "/b/", "/c/", "/d/", "/e/", "/f/", "/g/", "/h/"}
+ long128 := "/" + strings.Repeat("a", 126) + "/"
+ long129 := "/" + strings.Repeat("a", 127) + "/"
+ if len(long128) != 128 || len(long129) != 129 {
+ t.Fatalf("fixture lengths %d, %d", len(long128), len(long129))
+ }
+ cases := []struct {
+ name string
+ paths []string
+ want string // "" accepts
+ }{
+ {"nil", nil, ""},
+ {"empty", []string{}, ""},
+ {"assets", []string{"/assets/"}, ""},
+ {"immutable", []string{"/_app/immutable/"}, ""},
+ {"two", []string{"/assets/", "/_app/immutable/"}, ""},
+ {"eight siblings", eight, ""},
+ {"case distinct", []string{"/Assets/", "/assets/"}, ""},
+ {"128 bytes", []string{long128}, ""},
+ {"similar names not nested", []string{"/a/", "/ab/"}, ""},
+
+ {"empty string", []string{""}, fmt.Sprintf(eac3, "")},
+ {"no leading slash", []string{"assets/"}, fmt.Sprintf(eac3, "assets/")},
+ {"no trailing slash", []string{"/assets"}, fmt.Sprintf(eac3, "/assets")},
+ {"root", []string{"/"}, eac4},
+ {"doubled slash", []string{"/a//b/"}, fmt.Sprintf(eac6, "/a//b/")},
+ {"only slashes", []string{"//"}, fmt.Sprintf(eac6, "//")},
+ {"trailing dot part", []string{"/a/./"}, fmt.Sprintf(eac6, "/a/./")},
+ {"dot part", []string{"/a/./b/"}, fmt.Sprintf(eac6, "/a/./b/")},
+ {"dot dot part", []string{"/a/../b/"}, fmt.Sprintf(eac5, "/a/../b/")},
+ {"percent", []string{"/a%2f/"}, fmt.Sprintf(eac5, "/a%2f/")},
+ {"query", []string{"/a?/"}, fmt.Sprintf(eac5, "/a?/")},
+ {"fragment", []string{"/a#/"}, fmt.Sprintf(eac5, "/a#/")},
+ {"star", []string{"/a*/"}, fmt.Sprintf(eac5, "/a*/")},
+ {"129 bytes", []string{long129}, fmt.Sprintf(eac6, long129)},
+ {"nine entries", append(append([]string{}, eight...), "/i/"), eac7},
+ {"nested", []string{"/a/", "/a/b/"}, eac7},
+ {"nested reversed", []string{"/a/b/", "/a/"}, eac7},
+ {"duplicate", []string{"/a/", "/a/"}, eac7},
+ {"bad entry reports the entry", []string{"/ok/", "bad"}, fmt.Sprintf(eac3, "bad")},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ err := ValidAssetPaths(c.paths)
+ if c.want == "" {
+ if err != nil {
+ t.Fatalf("ValidAssetPaths(%q) = %v, want nil", c.paths, err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("ValidAssetPaths(%q) = nil, want %q", c.paths, c.want)
+ }
+ if err.Error() != c.want {
+ t.Fatalf("ValidAssetPaths(%q) =\n%q\nwant\n%q", c.paths, err.Error(), c.want)
+ }
+ })
+ }
+}
+
+func TestValidateManifestRefusesBadAssets(t *testing.T) {
+ m := goldenLabeledManifestValue()
+ m.Assets = &Assets{Paths: []string{"/assets/", "/_app/immutable/"}}
+ if err := validateManifest(m); err != nil {
+ t.Fatalf("a good assets block was refused: %v", err)
+ }
+ m.Assets = &Assets{}
+ if err := validateManifest(m); err != nil {
+ t.Fatalf("an empty assets block on a binary was refused: %v", err)
+ }
+ for name, paths := range map[string][]string{
+ "root": {"/"},
+ "nested": {"/a/", "/a/b/"},
+ "clean": {"/a/./b/"},
+ } {
+ m.Assets = &Assets{Paths: paths}
+ err := validateManifest(m)
+ if err == nil {
+ t.Fatalf("%s: validateManifest accepted assets %q", name, paths)
+ }
+ if want := ValidAssetPaths(paths); want == nil || err.Error() != want.Error() {
+ t.Fatalf("%s: validateManifest = %v, want the grammar's own refusal %v", name, err, want)
+ }
+ }
+}
+
+func TestValidateManifestRefusesAssetsOnStatic(t *testing.T) {
+ m := goldenLabeledManifestValue()
+ m.Kind = "static"
+ if err := validateManifest(m); err != nil {
+ t.Fatalf("static without assets was refused: %v", err)
+ }
+ for name, a := range map[string]*Assets{
+ "with paths": {Paths: []string{"/assets/"}},
+ "empty": {},
+ } {
+ m.Assets = a
+ err := validateManifest(m)
+ if err == nil || err.Error() != eac8 {
+ t.Fatalf("%s: validateManifest = %v, want %q", name, err, eac8)
+ }
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index c579fa95b..6cdf8c32a 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -380,6 +380,19 @@ func validateManifest(m *Manifest) error {
if err := caps.ValidCapabilities(m.App, m.Capabilities); err != nil {
return err
}
+ // The assets block follows the same two-sided rule as capabilities, and
+ // its refusals are the approved copy rows EAC-3 to EAC-8, returned
+ // unwrapped so the operator reads the sentence Paul approved. A static
+ // site is served from every region already, so it declares none; any
+ // non-nil block is refused, even an empty one.
+ if m.Assets != nil {
+ if m.Kind == "static" {
+ return errors.New("a static site is already served from every region, so it has no asset folders to declare. leave --assets off.")
+ }
+ if err := ValidAssetPaths(m.Assets.Paths); err != nil {
+ return err
+ }
+ }
return nil
}