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>
4 files changed,
+202
−2
approved_asset_cache_copy_test.go+3 −2internal/release/assets.go+69 −0internal/release/assets_test.go+117 −0internal/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 |
| } |