detached 58268c8

perf(cef): internal systemview startup (Plan 05) (#415)

* feat(cef): integrate residency lifecycle with quiescence

* fix(cef): address senior review blockers on residency

* test(cef): characterize systemview asset decode and response classes (05.P1)

* feat(cef): bundle-scoped immutable systemview WASM cache (05.P2.1)

* feat(cef): bind systemview WASM cache to handler bundle lifetime (05.P2.2-P2.3)

* feat(cef): versioned systemview asset manifest with generator (05.P3.1)

* feat(cef): version shell references from captured bundle manifest (05.P3.2)

* feat(cef): bind immutable headers to verified served bytes (05.P3.3)

* feat(systemviews): one-fetch instantiation and explicit readiness (05.P4)

* fix(cef): address senior review on systemview startup lot

* build(systemviews): pin artifact toolchain and trim paths for reproducible WASM

* build(systemviews): seal hermetic WASM recipe against local go env

* fix(systemviews): address Copilot review on startup lot
commit
58268c876a80eb02b901423545ffc15b4ed4f58f
author
bnema · · 2026-09-08 11:37 UTC
committer
GitHub · · 2026-09-08 11:37 UTC
parent
acef41d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +3365 -37
M Makefile +15 -4
@@ -34,6 +34,14 @@ WASM_GOFLAGS?=$(GOFLAGS)
3434 # Linker flags
3535 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
3636
37+# Blessed toolchain for systemview artifacts, read from go.mod so there is
38+# exactly one version to bump. The verify-generated CI gate rebuilds these
39+# artifacts with setup-go's pinned toolchain: recipe-level GOTOOLCHAIN
40+# (not ambient environment) plus GOENV=off (ignore the developer's go env
41+# file — e.g. a local GOEXPERIMENT changes runtime codegen) is what makes
42+# local builds byte-identical to CI's.
43+SYSTEMVIEWS_GOTOOLCHAIN?=$(shell grep '^toolchain ' go.mod | awk '{print $$2}')
44+
3745 # Disable optimizations and inlining for ALL packages. purego callbacks
3846 # create mixed Go/C stack frames; the Go runtime (stack growth, GC,
3947 # scheduler) cannot reliably traverse optimised frames across these
@@ -62,21 +70,23 @@ build: build-systemviews ## Build the application (pure Go, no CGO)
6270
6371 generate-systemviews: ## Generate Go code from systemviews templ components
6472 @echo "Generating systemviews templ components..."
65- go tool templ generate -path internal/ui/systemviews -include-version=false
73+ GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" GOENV=off go tool templ generate -path internal/ui/systemviews -include-version=false
6674 @echo "Systemviews templ generation complete"
6775
6876 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
6977 @echo "Building systemviews wasm assets..."
7078 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
7179 @mkdir -p assets/systemviews
72- @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
73- GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
80+ @cp "$$(GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
81+ GOFLAGS="$(WASM_GOFLAGS)" GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" GOENV=off GOOS=js GOARCH=wasm go build -trimpath -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
7482 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
83+ go run ./cmd/systemviews-assets -dir assets/systemviews
7584 @echo "Systemviews build complete"
7685
7786 build-quick: ## Build quickly for backend development
7887 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
7988 @mkdir -p $(DIST_DIR)
89+ go run ./cmd/systemviews-assets -check-if-present -dir assets/systemviews
8090 GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
8191 @rm -f $(DIST_DIR)/cef-helper
8292 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
@@ -149,7 +159,8 @@ verify-generated: ## Verify generated systemviews artifacts are committed
149159
150160 verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset
151161 @echo "Verifying embedded systemviews WASM asset..."
152- DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run TestWebUIAssetsIncludesCompressedSystemviewsWASM ./assets
162+ DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run 'TestWebUIAssetsIncludesCompressedSystemviewsWASM|TestAssetManifestMatchesEmbeddedArtifacts' ./assets
163+ go run ./cmd/systemviews-assets -check -dir assets/systemviews
153164
154165 # Linting
155166 lint: ## Run golangci-lint
@@ -34,6 +34,14 @@ WASM_GOFLAGS?=$(GOFLAGS)
34 # Linker flags34 # Linker flags
35 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"35 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
36 36
37+# Blessed toolchain for systemview artifacts, read from go.mod so there is
38+# exactly one version to bump. The verify-generated CI gate rebuilds these
39+# artifacts with setup-go's pinned toolchain: recipe-level GOTOOLCHAIN
40+# (not ambient environment) plus GOENV=off (ignore the developer's go env
41+# file — e.g. a local GOEXPERIMENT changes runtime codegen) is what makes
42+# local builds byte-identical to CI's.
43+SYSTEMVIEWS_GOTOOLCHAIN?=$(shell grep '^toolchain ' go.mod | awk '{print $$2}')
44+
37 # Disable optimizations and inlining for ALL packages. purego callbacks45 # Disable optimizations and inlining for ALL packages. purego callbacks
38 # create mixed Go/C stack frames; the Go runtime (stack growth, GC,46 # create mixed Go/C stack frames; the Go runtime (stack growth, GC,
39 # scheduler) cannot reliably traverse optimised frames across these47 # scheduler) cannot reliably traverse optimised frames across these
@@ -62,21 +70,23 @@ build: build-systemviews ## Build the application (pure Go, no CGO)
62 70
63 generate-systemviews: ## Generate Go code from systemviews templ components71 generate-systemviews: ## Generate Go code from systemviews templ components
64 @echo "Generating systemviews templ components..."72 @echo "Generating systemviews templ components..."
65- go tool templ generate -path internal/ui/systemviews -include-version=false73+ GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" GOENV=off go tool templ generate -path internal/ui/systemviews -include-version=false
66 @echo "Systemviews templ generation complete"74 @echo "Systemviews templ generation complete"
67 75
68 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime76 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
69 @echo "Building systemviews wasm assets..."77 @echo "Building systemviews wasm assets..."
70 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }78 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
71 @mkdir -p assets/systemviews79 @mkdir -p assets/systemviews
72- @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js80+ @cp "$$(GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
73- GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews81+ GOFLAGS="$(WASM_GOFLAGS)" GOTOOLCHAIN="$(SYSTEMVIEWS_GOTOOLCHAIN)" GOENV=off GOOS=js GOARCH=wasm go build -trimpath -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
74 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm82 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
83+ go run ./cmd/systemviews-assets -dir assets/systemviews
75 @echo "Systemviews build complete"84 @echo "Systemviews build complete"
76 85
77 build-quick: ## Build quickly for backend development86 build-quick: ## Build quickly for backend development
78 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."87 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
79 @mkdir -p $(DIST_DIR)88 @mkdir -p $(DIST_DIR)
89+ go run ./cmd/systemviews-assets -check-if-present -dir assets/systemviews
80 GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)90 GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
81 @rm -f $(DIST_DIR)/cef-helper91 @rm -f $(DIST_DIR)/cef-helper
82 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"92 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
@@ -149,7 +159,8 @@ verify-generated: ## Verify generated systemviews artifacts are committed
149 159
150 verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset160 verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset
151 @echo "Verifying embedded systemviews WASM asset..."161 @echo "Verifying embedded systemviews WASM asset..."
152- DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run TestWebUIAssetsIncludesCompressedSystemviewsWASM ./assets162+ DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run 'TestWebUIAssetsIncludesCompressedSystemviewsWASM|TestAssetManifestMatchesEmbeddedArtifacts' ./assets
163+ go run ./cmd/systemviews-assets -check -dir assets/systemviews
153 164
154 # Linting165 # Linting
155 lint: ## Run golangci-lint166 lint: ## Run golangci-lint
A assets/manifest.go +128 -0
@@ -0,0 +1,128 @@
1+package assets
2+
3+// Content manifest for served systemview assets (Plan 05 P3.1).
4+//
5+// The manifest pins the SHA-256 and size of every uncompressed immutable
6+// asset the CEF scheme handler may serve with long-lived cache headers.
7+// It is generated by cmd/systemviews-assets after the WASM build and
8+// committed; verification compares it against the actually embedded
9+// artifacts, never against Git state. Encoding is canonical JSON with
10+// sorted keys and no timestamps or paths, so identical inputs always
11+// produce identical bytes.
12+
13+import (
14+ "bytes"
15+ "crypto/sha256"
16+ "encoding/hex"
17+ "encoding/json"
18+ "fmt"
19+ "io"
20+ "io/fs"
21+)
22+
23+// ManifestVersion is the only accepted manifest schema version.
24+const ManifestVersion = 1
25+
26+// ManifestName is the manifest file name inside assets/systemviews.
27+const ManifestName = "asset-manifest.json"
28+
29+// ManifestFiles lists every served uncompressed asset pinned by the
30+// manifest, in canonical order.
31+var ManifestFiles = []string{"systemviews.css", "systemviews.wasm", "wasm_exec.js"}
32+
33+// FileEntry pins one asset's content identity.
34+type FileEntry struct {
35+ SHA256 string `json:"sha256"`
36+ Size int64 `json:"size"`
37+}
38+
39+// Manifest pins the content identity of servable immutable assets.
40+type Manifest struct {
41+ Version int `json:"version"`
42+ Files map[string]FileEntry `json:"files"`
43+}
44+
45+// ParseManifest decodes and validates manifest bytes. Unknown files,
46+// missing pins, malformed digests, and version mismatches are all hard
47+// errors: a trust root must fail closed, never serve unverified immutable
48+// content. Every pin must be exactly 64 lowercase hex characters: pins
49+// are interpolated into shell HTML/JS, so anything else is rejected
50+// before any shell use rather than escaped after it.
51+func ParseManifest(data []byte) (Manifest, error) {
52+ var m Manifest
53+ dec := json.NewDecoder(bytes.NewReader(data))
54+ dec.DisallowUnknownFields()
55+ if err := dec.Decode(&m); err != nil {
56+ return Manifest{}, fmt.Errorf("decode asset manifest: %w", err)
57+ }
58+ var extra json.RawMessage
59+ if err := dec.Decode(&extra); err != io.EOF {
60+ return Manifest{}, fmt.Errorf("asset manifest has trailing data")
61+ }
62+ if m.Version != ManifestVersion {
63+ return Manifest{}, fmt.Errorf("unsupported asset manifest version %d", m.Version)
64+ }
65+ if len(m.Files) != len(ManifestFiles) {
66+ return Manifest{}, fmt.Errorf("asset manifest pins %d files, want %d", len(m.Files), len(ManifestFiles))
67+ }
68+ for _, name := range ManifestFiles {
69+ entry, ok := m.Files[name]
70+ if !ok {
71+ return Manifest{}, fmt.Errorf("asset manifest missing pin for %s", name)
72+ }
73+ if !validDigest(entry.SHA256) {
74+ return Manifest{}, fmt.Errorf("asset manifest has invalid pin for %s", name)
75+ }
76+ if entry.Size < 0 {
77+ return Manifest{}, fmt.Errorf("asset manifest has invalid size for %s", name)
78+ }
79+ }
80+ return m, nil
81+}
82+
83+// validDigest reports whether s is a SHA-256 hex digest: exactly 64
84+// lowercase hexadecimal characters, nothing else.
85+func validDigest(s string) bool {
86+ if len(s) != 64 {
87+ return false
88+ }
89+ for i := 0; i < len(s); i++ {
90+ digit := s[i] >= '0' && s[i] <= '9'
91+ lowerHex := s[i] >= 'a' && s[i] <= 'f'
92+ if !digit && !lowerHex {
93+ return false
94+ }
95+ }
96+ return true
97+}
98+
99+// Bytes renders the canonical manifest encoding: indented JSON with sorted
100+// keys, no timestamps or paths, terminated by a single newline.
101+func (m Manifest) Bytes() ([]byte, error) {
102+ data, err := json.MarshalIndent(m, "", " ")
103+ if err != nil {
104+ return nil, fmt.Errorf("encode asset manifest: %w", err)
105+ }
106+ return append(data, '\n'), nil
107+}
108+
109+// Verify checks every pinned file against its actual bytes in fsys. Names
110+// resolve at the filesystem root; callers pass fs.Sub when artifacts live
111+// under a prefix (e.g. the embedded systemviews directory).
112+func (m Manifest) Verify(fsys fs.FS) error {
113+ for _, name := range ManifestFiles {
114+ want := m.Files[name]
115+ data, err := fs.ReadFile(fsys, name)
116+ if err != nil {
117+ return fmt.Errorf("read pinned asset %s: %w", name, err)
118+ }
119+ sum := sha256.Sum256(data)
120+ if got := hex.EncodeToString(sum[:]); got != want.SHA256 {
121+ return fmt.Errorf("pinned asset %s digest mismatch", name)
122+ }
123+ if int64(len(data)) != want.Size {
124+ return fmt.Errorf("pinned asset %s size mismatch: got %d, want %d", name, len(data), want.Size)
125+ }
126+ }
127+ return nil
128+}
@@ -0,0 +1,128 @@
1+package assets
2+
3+// Content manifest for served systemview assets (Plan 05 P3.1).
4+//
5+// The manifest pins the SHA-256 and size of every uncompressed immutable
6+// asset the CEF scheme handler may serve with long-lived cache headers.
7+// It is generated by cmd/systemviews-assets after the WASM build and
8+// committed; verification compares it against the actually embedded
9+// artifacts, never against Git state. Encoding is canonical JSON with
10+// sorted keys and no timestamps or paths, so identical inputs always
11+// produce identical bytes.
12+
13+import (
14+ "bytes"
15+ "crypto/sha256"
16+ "encoding/hex"
17+ "encoding/json"
18+ "fmt"
19+ "io"
20+ "io/fs"
21+)
22+
23+// ManifestVersion is the only accepted manifest schema version.
24+const ManifestVersion = 1
25+
26+// ManifestName is the manifest file name inside assets/systemviews.
27+const ManifestName = "asset-manifest.json"
28+
29+// ManifestFiles lists every served uncompressed asset pinned by the
30+// manifest, in canonical order.
31+var ManifestFiles = []string{"systemviews.css", "systemviews.wasm", "wasm_exec.js"}
32+
33+// FileEntry pins one asset's content identity.
34+type FileEntry struct {
35+ SHA256 string `json:"sha256"`
36+ Size int64 `json:"size"`
37+}
38+
39+// Manifest pins the content identity of servable immutable assets.
40+type Manifest struct {
41+ Version int `json:"version"`
42+ Files map[string]FileEntry `json:"files"`
43+}
44+
45+// ParseManifest decodes and validates manifest bytes. Unknown files,
46+// missing pins, malformed digests, and version mismatches are all hard
47+// errors: a trust root must fail closed, never serve unverified immutable
48+// content. Every pin must be exactly 64 lowercase hex characters: pins
49+// are interpolated into shell HTML/JS, so anything else is rejected
50+// before any shell use rather than escaped after it.
51+func ParseManifest(data []byte) (Manifest, error) {
52+ var m Manifest
53+ dec := json.NewDecoder(bytes.NewReader(data))
54+ dec.DisallowUnknownFields()
55+ if err := dec.Decode(&m); err != nil {
56+ return Manifest{}, fmt.Errorf("decode asset manifest: %w", err)
57+ }
58+ var extra json.RawMessage
59+ if err := dec.Decode(&extra); err != io.EOF {
60+ return Manifest{}, fmt.Errorf("asset manifest has trailing data")
61+ }
62+ if m.Version != ManifestVersion {
63+ return Manifest{}, fmt.Errorf("unsupported asset manifest version %d", m.Version)
64+ }
65+ if len(m.Files) != len(ManifestFiles) {
66+ return Manifest{}, fmt.Errorf("asset manifest pins %d files, want %d", len(m.Files), len(ManifestFiles))
67+ }
68+ for _, name := range ManifestFiles {
69+ entry, ok := m.Files[name]
70+ if !ok {
71+ return Manifest{}, fmt.Errorf("asset manifest missing pin for %s", name)
72+ }
73+ if !validDigest(entry.SHA256) {
74+ return Manifest{}, fmt.Errorf("asset manifest has invalid pin for %s", name)
75+ }
76+ if entry.Size < 0 {
77+ return Manifest{}, fmt.Errorf("asset manifest has invalid size for %s", name)
78+ }
79+ }
80+ return m, nil
81+}
82+
83+// validDigest reports whether s is a SHA-256 hex digest: exactly 64
84+// lowercase hexadecimal characters, nothing else.
85+func validDigest(s string) bool {
86+ if len(s) != 64 {
87+ return false
88+ }
89+ for i := 0; i < len(s); i++ {
90+ digit := s[i] >= '0' && s[i] <= '9'
91+ lowerHex := s[i] >= 'a' && s[i] <= 'f'
92+ if !digit && !lowerHex {
93+ return false
94+ }
95+ }
96+ return true
97+}
98+
99+// Bytes renders the canonical manifest encoding: indented JSON with sorted
100+// keys, no timestamps or paths, terminated by a single newline.
101+func (m Manifest) Bytes() ([]byte, error) {
102+ data, err := json.MarshalIndent(m, "", " ")
103+ if err != nil {
104+ return nil, fmt.Errorf("encode asset manifest: %w", err)
105+ }
106+ return append(data, '\n'), nil
107+}
108+
109+// Verify checks every pinned file against its actual bytes in fsys. Names
110+// resolve at the filesystem root; callers pass fs.Sub when artifacts live
111+// under a prefix (e.g. the embedded systemviews directory).
112+func (m Manifest) Verify(fsys fs.FS) error {
113+ for _, name := range ManifestFiles {
114+ want := m.Files[name]
115+ data, err := fs.ReadFile(fsys, name)
116+ if err != nil {
117+ return fmt.Errorf("read pinned asset %s: %w", name, err)
118+ }
119+ sum := sha256.Sum256(data)
120+ if got := hex.EncodeToString(sum[:]); got != want.SHA256 {
121+ return fmt.Errorf("pinned asset %s digest mismatch", name)
122+ }
123+ if int64(len(data)) != want.Size {
124+ return fmt.Errorf("pinned asset %s size mismatch: got %d, want %d", name, len(data), want.Size)
125+ }
126+ }
127+ return nil
128+}
A assets/manifest_test.go +148 -0
@@ -0,0 +1,148 @@
1+package assets
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "io/fs"
8+ "os"
9+ "strings"
10+ "testing"
11+
12+ "github.com/andybalholm/brotli"
13+)
14+
15+func TestParseManifestRejectsBadInputs(t *testing.T) {
16+ t.Parallel()
17+
18+ good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
19+ pin := func(digest string, size int) string {
20+ return fmt.Sprintf(`"sha256":%q,"size":%d`, digest, size)
21+ }
22+ entry := func(css, wasm, js string) string {
23+ return `{"version":1,"files":{"systemviews.css":{` + css + `},"systemviews.wasm":{` + wasm + `},"wasm_exec.js":{` + js + `}}}`
24+ }
25+ valid := entry(pin(good, 1), pin(good, 2), pin(good, 3))
26+ if _, err := ParseManifest([]byte(valid)); err != nil {
27+ t.Fatalf("ParseManifest(valid) error = %v", err)
28+ }
29+
30+ cases := map[string]string{
31+ "bad version": strings.Replace(valid, `"version":1`, `"version":2`, 1),
32+ "missing pin": `{"version":1,"files":{"systemviews.css":{` + pin(good, 1) + `},"systemviews.wasm":{` + pin(good, 2) + `}}}`,
33+ "unknown file": strings.TrimSuffix(valid, `}`) + `,"evil.js":{` + pin(good, 4) + `}}`,
34+ "empty digest": entry(pin("", 1), pin(good, 2), pin(good, 3)),
35+ "short digest": entry(pin("abc", 1), pin(good, 2), pin(good, 3)),
36+ "long digest": entry(pin(good+"00", 1), pin(good, 2), pin(good, 3)),
37+ "nonhex digest": entry(pin(strings.Repeat("g", 64), 1), pin(good, 2),
38+ pin(good, 3)),
39+ "upper digest": entry(pin(strings.Repeat("A", 64), 1), pin(good, 2),
40+ pin(good, 3)),
41+ // Pins breaking out of the shell's quoted attributes must fail
42+ // closed instead of reaching HTML/JS interpolation.
43+ "quote breakout": entry(pin(good, 1),
44+ pin("');alert(1);//"+strings.Repeat("0", 52), 2), pin(good, 3)),
45+ "markup pin": entry(pin(good, 1),
46+ pin("<script>alert(1)</"+"script>"+strings.Repeat("0", 39), 2), pin(good, 3)),
47+ "not JSON": `not json`,
48+ "trailing data": valid + `{}`,
49+ }
50+ for name, input := range cases {
51+ t.Run(name, func(t *testing.T) {
52+ t.Parallel()
53+ if _, err := ParseManifest([]byte(input)); err == nil {
54+ t.Fatalf("ParseManifest(%s) succeeded, want error", name)
55+ }
56+ })
57+ }
58+}
59+
60+func TestManifestBytesAreCanonical(t *testing.T) {
61+ t.Parallel()
62+
63+ good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
64+ m := Manifest{Version: ManifestVersion, Files: map[string]FileEntry{
65+ "wasm_exec.js": {SHA256: good, Size: 3},
66+ "systemviews.wasm": {SHA256: good, Size: 2},
67+ "systemviews.css": {SHA256: good, Size: 1},
68+ }}
69+ first, err := m.Bytes()
70+ if err != nil {
71+ t.Fatalf("Bytes() error = %v", err)
72+ }
73+ second, err := m.Bytes()
74+ if err != nil {
75+ t.Fatalf("Bytes() error = %v", err)
76+ }
77+ if !bytes.Equal(first, second) {
78+ t.Fatal("manifest encoding is not reproducible")
79+ }
80+ if !bytes.HasSuffix(first, []byte("\n")) || bytes.Contains(first, []byte("1970")) {
81+ t.Fatalf("manifest must end with newline and carry no timestamps:\n%s", first)
82+ }
83+ // Sorted keys: css < wasm < wasm_exec.
84+ css := bytes.Index(first, []byte("systemviews.css"))
85+ wasm := bytes.Index(first, []byte("systemviews.wasm"))
86+ js := bytes.Index(first, []byte("wasm_exec.js"))
87+ if css >= wasm || wasm >= js {
88+ t.Fatalf("manifest keys not sorted:\n%s", first)
89+ }
90+ if _, err := ParseManifest(first); err != nil {
91+ t.Fatalf("canonical bytes do not round-trip: %v", err)
92+ }
93+}
94+
95+// TestAssetManifestMatchesEmbeddedArtifacts validates every manifest entry
96+// against the actually embedded artifacts, including raw/compressed WASM
97+// agreement. It skips when the generated bundle is absent, mirroring the
98+// existing WASM presence test.
99+func TestAssetManifestMatchesEmbeddedArtifacts(t *testing.T) {
100+ data, err := WebUIAssets.ReadFile("systemviews/" + ManifestName)
101+ if err != nil {
102+ if os.Getenv("DUMBER_REQUIRE_SYSTEMVIEWS_WASM") == "" {
103+ t.Skipf("generated asset manifest not present; run make build-systemviews: %v", err)
104+ }
105+ t.Fatalf("read embedded asset manifest: %v", err)
106+ }
107+ m, err := ParseManifest(data)
108+ if err != nil {
109+ t.Fatalf("ParseManifest(embedded) error = %v", err)
110+ }
111+ sub, err := fs.Sub(WebUIAssets, "systemviews")
112+ if err != nil {
113+ t.Fatalf("fs.Sub(embedded systemviews) error = %v", err)
114+ }
115+ if err := m.Verify(sub); err != nil {
116+ t.Fatalf("embedded artifacts do not match manifest: %v", err)
117+ }
118+ // The manifest pins raw bytes, but serving decodes the compressed
119+ // artifact as authoritative: the embedded .br must agree too, or a
120+ // stale compressed bundle would pass verification yet fail serving.
121+ if err := verifyEmbeddedCompressedAgreement(sub, m); err != nil {
122+ t.Fatalf("embedded compressed WASM disagrees: %v", err)
123+ }
124+}
125+
126+// verifyEmbeddedCompressedAgreement checks the embedded .br decodes to
127+// exactly the pinned raw bytes under a bounded read. Test-only: production
128+// agreement checks live in cmd/systemviews-assets; this keeps the assets
129+// package dependency-free.
130+func verifyEmbeddedCompressedAgreement(fsys fs.FS, m Manifest) error {
131+ want := m.Files["systemviews.wasm"]
132+ compressed, err := fs.ReadFile(fsys, "systemviews.wasm.br")
133+ if err != nil {
134+ return fmt.Errorf("read embedded systemviews.wasm.br: %w", err)
135+ }
136+ raw, err := fs.ReadFile(fsys, "systemviews.wasm")
137+ if err != nil {
138+ return fmt.Errorf("read embedded systemviews.wasm: %w", err)
139+ }
140+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), 64*1024*1024+1))
141+ if err != nil {
142+ return fmt.Errorf("decompress embedded systemviews.wasm.br: %w", err)
143+ }
144+ if !bytes.Equal(data, raw) || int64(len(raw)) != want.Size {
145+ return fmt.Errorf("embedded systemviews.wasm.br does not match pinned raw bytes")
146+ }
147+ return nil
148+}
@@ -0,0 +1,148 @@
1+package assets
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "io/fs"
8+ "os"
9+ "strings"
10+ "testing"
11+
12+ "github.com/andybalholm/brotli"
13+)
14+
15+func TestParseManifestRejectsBadInputs(t *testing.T) {
16+ t.Parallel()
17+
18+ good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
19+ pin := func(digest string, size int) string {
20+ return fmt.Sprintf(`"sha256":%q,"size":%d`, digest, size)
21+ }
22+ entry := func(css, wasm, js string) string {
23+ return `{"version":1,"files":{"systemviews.css":{` + css + `},"systemviews.wasm":{` + wasm + `},"wasm_exec.js":{` + js + `}}}`
24+ }
25+ valid := entry(pin(good, 1), pin(good, 2), pin(good, 3))
26+ if _, err := ParseManifest([]byte(valid)); err != nil {
27+ t.Fatalf("ParseManifest(valid) error = %v", err)
28+ }
29+
30+ cases := map[string]string{
31+ "bad version": strings.Replace(valid, `"version":1`, `"version":2`, 1),
32+ "missing pin": `{"version":1,"files":{"systemviews.css":{` + pin(good, 1) + `},"systemviews.wasm":{` + pin(good, 2) + `}}}`,
33+ "unknown file": strings.TrimSuffix(valid, `}`) + `,"evil.js":{` + pin(good, 4) + `}}`,
34+ "empty digest": entry(pin("", 1), pin(good, 2), pin(good, 3)),
35+ "short digest": entry(pin("abc", 1), pin(good, 2), pin(good, 3)),
36+ "long digest": entry(pin(good+"00", 1), pin(good, 2), pin(good, 3)),
37+ "nonhex digest": entry(pin(strings.Repeat("g", 64), 1), pin(good, 2),
38+ pin(good, 3)),
39+ "upper digest": entry(pin(strings.Repeat("A", 64), 1), pin(good, 2),
40+ pin(good, 3)),
41+ // Pins breaking out of the shell's quoted attributes must fail
42+ // closed instead of reaching HTML/JS interpolation.
43+ "quote breakout": entry(pin(good, 1),
44+ pin("');alert(1);//"+strings.Repeat("0", 52), 2), pin(good, 3)),
45+ "markup pin": entry(pin(good, 1),
46+ pin("<script>alert(1)</"+"script>"+strings.Repeat("0", 39), 2), pin(good, 3)),
47+ "not JSON": `not json`,
48+ "trailing data": valid + `{}`,
49+ }
50+ for name, input := range cases {
51+ t.Run(name, func(t *testing.T) {
52+ t.Parallel()
53+ if _, err := ParseManifest([]byte(input)); err == nil {
54+ t.Fatalf("ParseManifest(%s) succeeded, want error", name)
55+ }
56+ })
57+ }
58+}
59+
60+func TestManifestBytesAreCanonical(t *testing.T) {
61+ t.Parallel()
62+
63+ good := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
64+ m := Manifest{Version: ManifestVersion, Files: map[string]FileEntry{
65+ "wasm_exec.js": {SHA256: good, Size: 3},
66+ "systemviews.wasm": {SHA256: good, Size: 2},
67+ "systemviews.css": {SHA256: good, Size: 1},
68+ }}
69+ first, err := m.Bytes()
70+ if err != nil {
71+ t.Fatalf("Bytes() error = %v", err)
72+ }
73+ second, err := m.Bytes()
74+ if err != nil {
75+ t.Fatalf("Bytes() error = %v", err)
76+ }
77+ if !bytes.Equal(first, second) {
78+ t.Fatal("manifest encoding is not reproducible")
79+ }
80+ if !bytes.HasSuffix(first, []byte("\n")) || bytes.Contains(first, []byte("1970")) {
81+ t.Fatalf("manifest must end with newline and carry no timestamps:\n%s", first)
82+ }
83+ // Sorted keys: css < wasm < wasm_exec.
84+ css := bytes.Index(first, []byte("systemviews.css"))
85+ wasm := bytes.Index(first, []byte("systemviews.wasm"))
86+ js := bytes.Index(first, []byte("wasm_exec.js"))
87+ if css >= wasm || wasm >= js {
88+ t.Fatalf("manifest keys not sorted:\n%s", first)
89+ }
90+ if _, err := ParseManifest(first); err != nil {
91+ t.Fatalf("canonical bytes do not round-trip: %v", err)
92+ }
93+}
94+
95+// TestAssetManifestMatchesEmbeddedArtifacts validates every manifest entry
96+// against the actually embedded artifacts, including raw/compressed WASM
97+// agreement. It skips when the generated bundle is absent, mirroring the
98+// existing WASM presence test.
99+func TestAssetManifestMatchesEmbeddedArtifacts(t *testing.T) {
100+ data, err := WebUIAssets.ReadFile("systemviews/" + ManifestName)
101+ if err != nil {
102+ if os.Getenv("DUMBER_REQUIRE_SYSTEMVIEWS_WASM") == "" {
103+ t.Skipf("generated asset manifest not present; run make build-systemviews: %v", err)
104+ }
105+ t.Fatalf("read embedded asset manifest: %v", err)
106+ }
107+ m, err := ParseManifest(data)
108+ if err != nil {
109+ t.Fatalf("ParseManifest(embedded) error = %v", err)
110+ }
111+ sub, err := fs.Sub(WebUIAssets, "systemviews")
112+ if err != nil {
113+ t.Fatalf("fs.Sub(embedded systemviews) error = %v", err)
114+ }
115+ if err := m.Verify(sub); err != nil {
116+ t.Fatalf("embedded artifacts do not match manifest: %v", err)
117+ }
118+ // The manifest pins raw bytes, but serving decodes the compressed
119+ // artifact as authoritative: the embedded .br must agree too, or a
120+ // stale compressed bundle would pass verification yet fail serving.
121+ if err := verifyEmbeddedCompressedAgreement(sub, m); err != nil {
122+ t.Fatalf("embedded compressed WASM disagrees: %v", err)
123+ }
124+}
125+
126+// verifyEmbeddedCompressedAgreement checks the embedded .br decodes to
127+// exactly the pinned raw bytes under a bounded read. Test-only: production
128+// agreement checks live in cmd/systemviews-assets; this keeps the assets
129+// package dependency-free.
130+func verifyEmbeddedCompressedAgreement(fsys fs.FS, m Manifest) error {
131+ want := m.Files["systemviews.wasm"]
132+ compressed, err := fs.ReadFile(fsys, "systemviews.wasm.br")
133+ if err != nil {
134+ return fmt.Errorf("read embedded systemviews.wasm.br: %w", err)
135+ }
136+ raw, err := fs.ReadFile(fsys, "systemviews.wasm")
137+ if err != nil {
138+ return fmt.Errorf("read embedded systemviews.wasm: %w", err)
139+ }
140+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), 64*1024*1024+1))
141+ if err != nil {
142+ return fmt.Errorf("decompress embedded systemviews.wasm.br: %w", err)
143+ }
144+ if !bytes.Equal(data, raw) || int64(len(raw)) != want.Size {
145+ return fmt.Errorf("embedded systemviews.wasm.br does not match pinned raw bytes")
146+ }
147+ return nil
148+}
A assets/systemviews/asset-manifest.json +17 -0
@@ -0,0 +1,17 @@
1+{
2+ "version": 1,
3+ "files": {
4+ "systemviews.css": {
5+ "sha256": "dcb45554912b04e850ec66d6a8c71050a7fab03124bf7d96c3e97e043345a847",
6+ "size": 19551
7+ },
8+ "systemviews.wasm": {
9+ "sha256": "77211e1f943a71c1eca160aad9a48bc57e8eee33c6e8fbd3b300b302afaf5c6f",
10+ "size": 8168388
11+ },
12+ "wasm_exec.js": {
13+ "sha256": "0c949f4996f9a89698e4b5c586de32249c3b69b7baadb64d220073cc04acba14",
14+ "size": 16992
15+ }
16+ }
17+}
@@ -0,0 +1,17 @@
1+{
2+ "version": 1,
3+ "files": {
4+ "systemviews.css": {
5+ "sha256": "dcb45554912b04e850ec66d6a8c71050a7fab03124bf7d96c3e97e043345a847",
6+ "size": 19551
7+ },
8+ "systemviews.wasm": {
9+ "sha256": "77211e1f943a71c1eca160aad9a48bc57e8eee33c6e8fbd3b300b302afaf5c6f",
10+ "size": 8168388
11+ },
12+ "wasm_exec.js": {
13+ "sha256": "0c949f4996f9a89698e4b5c586de32249c3b69b7baadb64d220073cc04acba14",
14+ "size": 16992
15+ }
16+ }
17+}
M assets/systemviews/index.html +54 -12
@@ -8,10 +8,20 @@
88 <link rel="stylesheet" href="./systemviews.css">
99 <script src="./wasm_exec.js"></script>
1010 <script>
11+ // One terminal-error guard: the loading shell reports its first fatal
12+ // failure exactly once. Streaming fallback noise stays console-only.
13+ let terminalErrorReported = false;
1114 function renderWasmError(prefixText, err) {
15+ console.error(prefixText, err);
16+ if (terminalErrorReported) return;
17+ terminalErrorReported = true;
1218 const render = () => {
1319 const app = document.getElementById('app');
1420 if (!app) return;
21+ // innerHTML replacement alone does not clear outer state: drop
22+ // the busy flag so assistive tech leaves the loading state.
23+ app.removeAttribute('aria-busy');
24+ app.setAttribute('data-failed', 'true');
1525 app.innerHTML = '';
1626 const message = document.createElement('div');
1727 message.className = 'sv-error';
@@ -27,26 +37,37 @@
2737 } else {
2838 render();
2939 }
30- console.error(prefixText, err);
3140 }
3241
42+ // Fetch exactly once: retain a clone for byte fallback before streaming
43+ // starts, so a streaming failure never issues a second fetch. An
44+ // unsuccessful status fails fast without consuming the body.
3345 function instantiateWasm(go, wasmURL) {
34- const instantiateFromBytes = () => fetch(wasmURL)
46+ const instantiateFromBytes = (bytes) => WebAssembly.instantiate(bytes, go.importObject);
47+
48+ if (!WebAssembly.instantiateStreaming) {
49+ return fetch(wasmURL)
50+ .then((response) => {
51+ if (!response.ok) {
52+ throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
53+ }
54+ return response.arrayBuffer();
55+ })
56+ .then(instantiateFromBytes);
57+ }
58+ let fallback = null;
59+ return fetch(wasmURL)
3560 .then((response) => {
3661 if (!response.ok) {
3762 throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
3863 }
39- return response.arrayBuffer();
64+ fallback = response.clone();
65+ return WebAssembly.instantiateStreaming(response, go.importObject);
4066 })
41- .then((bytes) => WebAssembly.instantiate(bytes, go.importObject));
42-
43- if (!WebAssembly.instantiateStreaming) {
44- return instantiateFromBytes();
45- }
46- return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject)
4767 .catch((err) => {
68+ if (!fallback) throw err;
4869 console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err);
49- return instantiateFromBytes();
70+ return fallback.arrayBuffer().then(instantiateFromBytes);
5071 });
5172 }
5273
@@ -56,14 +77,35 @@
5677 renderWasmError('Go WebAssembly runtime failed to load: ');
5778 } else {
5879 const go = new Go();
80+ // Record the runtime exit code: wasm_exec.js routes runtime.wasmExit
81+ // through go.exit, whose default only logs. Reporting the actual
82+ // status beats the misleading generic suffix on unexpected exits.
83+ let goExitCode = null;
84+ const goExit = go.exit.bind(go);
85+ go.exit = (code) => {
86+ goExitCode = code;
87+ goExit(code);
88+ };
5989 instantiateWasm(go, './systemviews.wasm')
90+ // The runtime stays alive: a resolved go.run means main returned
91+ // instead of serving, which is itself a terminal failure.
6092 .then((result) => go.run(result.instance))
61- .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err));
93+ .then(
94+ () => {
95+ // Go renders fatal mount/bind failures itself and marks
96+ // data-failed: never overwrite its terminal state here.
97+ const app = document.getElementById('app');
98+ if (app && app.dataset && app.dataset.failed) return;
99+ const code = goExitCode === null ? 'unknown' : String(goExitCode);
100+ renderWasmError('systemviews runtime exited: ', new Error(`exit status ${code}`));
101+ },
102+ (err) => renderWasmError('Failed to load systemviews WebAssembly: ', err),
103+ );
62104 }
63105 </script>
64106 </head>
65107 <body>
66- <main id="app">Loading systemviews…</main>
108+ <main id="app" aria-busy="true">Loading systemviews…</main>
67109 <noscript>
68110 <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>
69111 </noscript>
@@ -8,10 +8,20 @@
8 <link rel="stylesheet" href="./systemviews.css">8 <link rel="stylesheet" href="./systemviews.css">
9 <script src="./wasm_exec.js"></script>9 <script src="./wasm_exec.js"></script>
10 <script>10 <script>
11+ // One terminal-error guard: the loading shell reports its first fatal
12+ // failure exactly once. Streaming fallback noise stays console-only.
13+ let terminalErrorReported = false;
11 function renderWasmError(prefixText, err) {14 function renderWasmError(prefixText, err) {
15+ console.error(prefixText, err);
16+ if (terminalErrorReported) return;
17+ terminalErrorReported = true;
12 const render = () => {18 const render = () => {
13 const app = document.getElementById('app');19 const app = document.getElementById('app');
14 if (!app) return;20 if (!app) return;
21+ // innerHTML replacement alone does not clear outer state: drop
22+ // the busy flag so assistive tech leaves the loading state.
23+ app.removeAttribute('aria-busy');
24+ app.setAttribute('data-failed', 'true');
15 app.innerHTML = '';25 app.innerHTML = '';
16 const message = document.createElement('div');26 const message = document.createElement('div');
17 message.className = 'sv-error';27 message.className = 'sv-error';
@@ -27,26 +37,37 @@
27 } else {37 } else {
28 render();38 render();
29 }39 }
30- console.error(prefixText, err);
31 }40 }
32 41
42+ // Fetch exactly once: retain a clone for byte fallback before streaming
43+ // starts, so a streaming failure never issues a second fetch. An
44+ // unsuccessful status fails fast without consuming the body.
33 function instantiateWasm(go, wasmURL) {45 function instantiateWasm(go, wasmURL) {
34- const instantiateFromBytes = () => fetch(wasmURL)46+ const instantiateFromBytes = (bytes) => WebAssembly.instantiate(bytes, go.importObject);
47+
48+ if (!WebAssembly.instantiateStreaming) {
49+ return fetch(wasmURL)
50+ .then((response) => {
51+ if (!response.ok) {
52+ throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
53+ }
54+ return response.arrayBuffer();
55+ })
56+ .then(instantiateFromBytes);
57+ }
58+ let fallback = null;
59+ return fetch(wasmURL)
35 .then((response) => {60 .then((response) => {
36 if (!response.ok) {61 if (!response.ok) {
37 throw new Error(`HTTP ${response.status} loading ${wasmURL}`);62 throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
38 }63 }
39- return response.arrayBuffer();64+ fallback = response.clone();
65+ return WebAssembly.instantiateStreaming(response, go.importObject);
40 })66 })
41- .then((bytes) => WebAssembly.instantiate(bytes, go.importObject));
42-
43- if (!WebAssembly.instantiateStreaming) {
44- return instantiateFromBytes();
45- }
46- return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject)
47 .catch((err) => {67 .catch((err) => {
68+ if (!fallback) throw err;
48 console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err);69 console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err);
49- return instantiateFromBytes();70+ return fallback.arrayBuffer().then(instantiateFromBytes);
50 });71 });
51 }72 }
52 73
@@ -56,14 +77,35 @@
56 renderWasmError('Go WebAssembly runtime failed to load: ');77 renderWasmError('Go WebAssembly runtime failed to load: ');
57 } else {78 } else {
58 const go = new Go();79 const go = new Go();
80+ // Record the runtime exit code: wasm_exec.js routes runtime.wasmExit
81+ // through go.exit, whose default only logs. Reporting the actual
82+ // status beats the misleading generic suffix on unexpected exits.
83+ let goExitCode = null;
84+ const goExit = go.exit.bind(go);
85+ go.exit = (code) => {
86+ goExitCode = code;
87+ goExit(code);
88+ };
59 instantiateWasm(go, './systemviews.wasm')89 instantiateWasm(go, './systemviews.wasm')
90+ // The runtime stays alive: a resolved go.run means main returned
91+ // instead of serving, which is itself a terminal failure.
60 .then((result) => go.run(result.instance))92 .then((result) => go.run(result.instance))
61- .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err));93+ .then(
94+ () => {
95+ // Go renders fatal mount/bind failures itself and marks
96+ // data-failed: never overwrite its terminal state here.
97+ const app = document.getElementById('app');
98+ if (app && app.dataset && app.dataset.failed) return;
99+ const code = goExitCode === null ? 'unknown' : String(goExitCode);
100+ renderWasmError('systemviews runtime exited: ', new Error(`exit status ${code}`));
101+ },
102+ (err) => renderWasmError('Failed to load systemviews WebAssembly: ', err),
103+ );
62 }104 }
63 </script>105 </script>
64 </head>106 </head>
65 <body>107 <body>
66- <main id="app">Loading systemviews…</main>108+ <main id="app" aria-busy="true">Loading systemviews…</main>
67 <noscript>109 <noscript>
68 <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>110 <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>
69 </noscript>111 </noscript>
A cmd/systemviews-assets/main.go +170 -0
@@ -0,0 +1,170 @@
1+// Command systemviews-assets generates and checks the content manifest
2+// for served systemview assets (Plan 05 P3.1).
3+//
4+// Generation (default) reads the uncompressed servables from -dir,
5+// requires the compressed WASM to decompress to the same bytes under a
6+// bounded read, and writes asset-manifest.json. It fails loudly on stale
7+// or missing inputs instead of writing a partial manifest.
8+//
9+// Check mode (-check) validates the committed manifest against the
10+// artifacts on disk and exits nonzero on any mismatch, for CI and
11+// release verification.
12+package main
13+
14+import (
15+ "bytes"
16+ "crypto/sha256"
17+ "encoding/hex"
18+ "flag"
19+ "fmt"
20+ "io"
21+ "io/fs"
22+ "os"
23+ "path/filepath"
24+
25+ "github.com/andybalholm/brotli"
26+
27+ "github.com/bnema/dumber/assets"
28+)
29+
30+// maxManifestWASMBytes bounds manifest-time decompression, mirroring the
31+// serving bound so a manifest can never bless what serving would reject.
32+const maxManifestWASMBytes = 64 * 1024 * 1024
33+
34+// manifestFilePerm matches committed repo asset files.
35+const manifestFilePerm = 0o644
36+
37+func main() {
38+ dir := flag.String("dir", "assets/systemviews", "directory holding the built systemview assets")
39+ check := flag.Bool("check", false, "verify the committed manifest against disk artifacts instead of regenerating")
40+ checkIfPresent := flag.Bool("check-if-present", false, "like -check, but pass with a notice when no built WASM exists (for quick builds)")
41+ flag.Parse()
42+
43+ if err := run(*dir, *check, *checkIfPresent); err != nil {
44+ fmt.Fprintf(os.Stderr, "systemviews-assets: %v\n", err)
45+ os.Exit(1)
46+ }
47+}
48+
49+func run(dir string, check, checkIfPresent bool) error {
50+ manifestPath := filepath.Join(dir, assets.ManifestName)
51+ if checkIfPresent {
52+ // Serving treats the compressed WASM as authoritative, so a
53+ // stale .br without raw (or vice versa) is a partial bundle
54+ // that must fail validation, never skip it.
55+ _, rawErr := os.Stat(filepath.Join(dir, "systemviews.wasm"))
56+ _, brErr := os.Stat(filepath.Join(dir, "systemviews.wasm.br"))
57+ if os.IsNotExist(rawErr) && os.IsNotExist(brErr) {
58+ fmt.Printf("no built systemviews WASM in %s; skipping manifest check\n", dir)
59+ return nil
60+ }
61+ return checkTree(dir, manifestPath)
62+ }
63+ if check {
64+ return checkTree(dir, manifestPath)
65+ }
66+ m, err := collectManifest(os.DirFS(dir))
67+ if err != nil {
68+ return err
69+ }
70+ data, err := m.Bytes()
71+ if err != nil {
72+ return err
73+ }
74+ if err := os.WriteFile(manifestPath, data, manifestFilePerm); err != nil {
75+ return fmt.Errorf("write %s: %w", manifestPath, err)
76+ }
77+ fmt.Printf("wrote %s pinning %d assets\n", manifestPath, len(assets.ManifestFiles))
78+ return nil
79+}
80+
81+// checkTree validates the committed manifest against disk artifacts:
82+// pins, raw bytes, and raw/compressed WASM agreement. Generation checks
83+// agreement at write time, but only the check path guards a later
84+// compressed-only swap, which serving would treat as authoritative.
85+func checkTree(dir, manifestPath string) error {
86+ data, err := os.ReadFile(manifestPath)
87+ if err != nil {
88+ return fmt.Errorf("read %s: run 'make build-systemviews' first: %w", manifestPath, err)
89+ }
90+ m, err := assets.ParseManifest(data)
91+ if err != nil {
92+ return err
93+ }
94+ fsys := os.DirFS(dir)
95+ if verr := m.Verify(fsys); verr != nil {
96+ return verr
97+ }
98+ raw, err := readBoundedManifestFile(fsys, "systemviews.wasm", maxManifestWASMBytes)
99+ if err != nil {
100+ return fmt.Errorf("read servable systemviews.wasm: %w", err)
101+ }
102+ if err := requireCompressedAgreement(fsys, raw, maxManifestWASMBytes); err != nil {
103+ return err
104+ }
105+ fmt.Printf("manifest %s matches %d pinned assets with agreeing compressed WASM\n", manifestPath, len(assets.ManifestFiles))
106+ return nil
107+}
108+
109+// collectManifest pins every manifest file from fsys. The raw and
110+// compressed WASM must agree under bounded decompression; any missing,
111+// corrupt, or oversized input is a hard error.
112+func collectManifest(fsys fs.FS) (assets.Manifest, error) {
113+ files := make(map[string]assets.FileEntry, len(assets.ManifestFiles))
114+ for _, name := range assets.ManifestFiles {
115+ data, err := readBoundedManifestFile(fsys, name, maxManifestWASMBytes)
116+ if err != nil {
117+ return assets.Manifest{}, fmt.Errorf("read servable asset %s: %w", name, err)
118+ }
119+ if name == "systemviews.wasm" {
120+ if err := requireCompressedAgreement(fsys, data, maxManifestWASMBytes); err != nil {
121+ return assets.Manifest{}, err
122+ }
123+ }
124+ sum := sha256.Sum256(data)
125+ files[name] = assets.FileEntry{SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
126+ }
127+ return assets.Manifest{Version: assets.ManifestVersion, Files: files}, nil
128+}
129+
130+// readBoundedManifestFile reads a manifest input with the pre-allocation
131+// bound the runtime loader enforces, so generation and checks cannot be
132+// pushed into unbounded allocation by a corrupt or hostile artifact.
133+func readBoundedManifestFile(fsys fs.FS, name string, maxBytes int) ([]byte, error) {
134+ f, err := fsys.Open(name)
135+ if err != nil {
136+ return nil, err
137+ }
138+ defer func() { _ = f.Close() }()
139+ data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
140+ if err != nil {
141+ return nil, fmt.Errorf("read %s: %w", name, err)
142+ }
143+ if len(data) > maxBytes {
144+ return nil, fmt.Errorf("%s exceeds %d bytes", name, maxBytes)
145+ }
146+ return data, nil
147+}
148+
149+// requireCompressedAgreement ensures the shipped .br decompresses to
150+// exactly the raw bytes being pinned. Both inputs arrive bounded, and the
151+// decoded output is rejected past the bound before comparison, mirroring
152+// the runtime loader: without the sentinel check a max+1-byte bundle
153+// would bless here what serving refuses there.
154+func requireCompressedAgreement(fsys fs.FS, raw []byte, maxBytes int) error {
155+ compressed, err := readBoundedManifestFile(fsys, "systemviews.wasm.br", maxBytes)
156+ if err != nil {
157+ return fmt.Errorf("read compressed systemviews.wasm.br: %w", err)
158+ }
159+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), int64(maxBytes)+1))
160+ if err != nil {
161+ return fmt.Errorf("decompress systemviews.wasm.br: %w", err)
162+ }
163+ if len(data) > maxBytes {
164+ return fmt.Errorf("systemviews.wasm.br decompresses past %d bytes", maxBytes)
165+ }
166+ if !bytes.Equal(data, raw) {
167+ return fmt.Errorf("systemviews.wasm.br decompresses to %d bytes, raw file has %d", len(data), len(raw))
168+ }
169+ return nil
170+}
@@ -0,0 +1,170 @@
1+// Command systemviews-assets generates and checks the content manifest
2+// for served systemview assets (Plan 05 P3.1).
3+//
4+// Generation (default) reads the uncompressed servables from -dir,
5+// requires the compressed WASM to decompress to the same bytes under a
6+// bounded read, and writes asset-manifest.json. It fails loudly on stale
7+// or missing inputs instead of writing a partial manifest.
8+//
9+// Check mode (-check) validates the committed manifest against the
10+// artifacts on disk and exits nonzero on any mismatch, for CI and
11+// release verification.
12+package main
13+
14+import (
15+ "bytes"
16+ "crypto/sha256"
17+ "encoding/hex"
18+ "flag"
19+ "fmt"
20+ "io"
21+ "io/fs"
22+ "os"
23+ "path/filepath"
24+
25+ "github.com/andybalholm/brotli"
26+
27+ "github.com/bnema/dumber/assets"
28+)
29+
30+// maxManifestWASMBytes bounds manifest-time decompression, mirroring the
31+// serving bound so a manifest can never bless what serving would reject.
32+const maxManifestWASMBytes = 64 * 1024 * 1024
33+
34+// manifestFilePerm matches committed repo asset files.
35+const manifestFilePerm = 0o644
36+
37+func main() {
38+ dir := flag.String("dir", "assets/systemviews", "directory holding the built systemview assets")
39+ check := flag.Bool("check", false, "verify the committed manifest against disk artifacts instead of regenerating")
40+ checkIfPresent := flag.Bool("check-if-present", false, "like -check, but pass with a notice when no built WASM exists (for quick builds)")
41+ flag.Parse()
42+
43+ if err := run(*dir, *check, *checkIfPresent); err != nil {
44+ fmt.Fprintf(os.Stderr, "systemviews-assets: %v\n", err)
45+ os.Exit(1)
46+ }
47+}
48+
49+func run(dir string, check, checkIfPresent bool) error {
50+ manifestPath := filepath.Join(dir, assets.ManifestName)
51+ if checkIfPresent {
52+ // Serving treats the compressed WASM as authoritative, so a
53+ // stale .br without raw (or vice versa) is a partial bundle
54+ // that must fail validation, never skip it.
55+ _, rawErr := os.Stat(filepath.Join(dir, "systemviews.wasm"))
56+ _, brErr := os.Stat(filepath.Join(dir, "systemviews.wasm.br"))
57+ if os.IsNotExist(rawErr) && os.IsNotExist(brErr) {
58+ fmt.Printf("no built systemviews WASM in %s; skipping manifest check\n", dir)
59+ return nil
60+ }
61+ return checkTree(dir, manifestPath)
62+ }
63+ if check {
64+ return checkTree(dir, manifestPath)
65+ }
66+ m, err := collectManifest(os.DirFS(dir))
67+ if err != nil {
68+ return err
69+ }
70+ data, err := m.Bytes()
71+ if err != nil {
72+ return err
73+ }
74+ if err := os.WriteFile(manifestPath, data, manifestFilePerm); err != nil {
75+ return fmt.Errorf("write %s: %w", manifestPath, err)
76+ }
77+ fmt.Printf("wrote %s pinning %d assets\n", manifestPath, len(assets.ManifestFiles))
78+ return nil
79+}
80+
81+// checkTree validates the committed manifest against disk artifacts:
82+// pins, raw bytes, and raw/compressed WASM agreement. Generation checks
83+// agreement at write time, but only the check path guards a later
84+// compressed-only swap, which serving would treat as authoritative.
85+func checkTree(dir, manifestPath string) error {
86+ data, err := os.ReadFile(manifestPath)
87+ if err != nil {
88+ return fmt.Errorf("read %s: run 'make build-systemviews' first: %w", manifestPath, err)
89+ }
90+ m, err := assets.ParseManifest(data)
91+ if err != nil {
92+ return err
93+ }
94+ fsys := os.DirFS(dir)
95+ if verr := m.Verify(fsys); verr != nil {
96+ return verr
97+ }
98+ raw, err := readBoundedManifestFile(fsys, "systemviews.wasm", maxManifestWASMBytes)
99+ if err != nil {
100+ return fmt.Errorf("read servable systemviews.wasm: %w", err)
101+ }
102+ if err := requireCompressedAgreement(fsys, raw, maxManifestWASMBytes); err != nil {
103+ return err
104+ }
105+ fmt.Printf("manifest %s matches %d pinned assets with agreeing compressed WASM\n", manifestPath, len(assets.ManifestFiles))
106+ return nil
107+}
108+
109+// collectManifest pins every manifest file from fsys. The raw and
110+// compressed WASM must agree under bounded decompression; any missing,
111+// corrupt, or oversized input is a hard error.
112+func collectManifest(fsys fs.FS) (assets.Manifest, error) {
113+ files := make(map[string]assets.FileEntry, len(assets.ManifestFiles))
114+ for _, name := range assets.ManifestFiles {
115+ data, err := readBoundedManifestFile(fsys, name, maxManifestWASMBytes)
116+ if err != nil {
117+ return assets.Manifest{}, fmt.Errorf("read servable asset %s: %w", name, err)
118+ }
119+ if name == "systemviews.wasm" {
120+ if err := requireCompressedAgreement(fsys, data, maxManifestWASMBytes); err != nil {
121+ return assets.Manifest{}, err
122+ }
123+ }
124+ sum := sha256.Sum256(data)
125+ files[name] = assets.FileEntry{SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
126+ }
127+ return assets.Manifest{Version: assets.ManifestVersion, Files: files}, nil
128+}
129+
130+// readBoundedManifestFile reads a manifest input with the pre-allocation
131+// bound the runtime loader enforces, so generation and checks cannot be
132+// pushed into unbounded allocation by a corrupt or hostile artifact.
133+func readBoundedManifestFile(fsys fs.FS, name string, maxBytes int) ([]byte, error) {
134+ f, err := fsys.Open(name)
135+ if err != nil {
136+ return nil, err
137+ }
138+ defer func() { _ = f.Close() }()
139+ data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
140+ if err != nil {
141+ return nil, fmt.Errorf("read %s: %w", name, err)
142+ }
143+ if len(data) > maxBytes {
144+ return nil, fmt.Errorf("%s exceeds %d bytes", name, maxBytes)
145+ }
146+ return data, nil
147+}
148+
149+// requireCompressedAgreement ensures the shipped .br decompresses to
150+// exactly the raw bytes being pinned. Both inputs arrive bounded, and the
151+// decoded output is rejected past the bound before comparison, mirroring
152+// the runtime loader: without the sentinel check a max+1-byte bundle
153+// would bless here what serving refuses there.
154+func requireCompressedAgreement(fsys fs.FS, raw []byte, maxBytes int) error {
155+ compressed, err := readBoundedManifestFile(fsys, "systemviews.wasm.br", maxBytes)
156+ if err != nil {
157+ return fmt.Errorf("read compressed systemviews.wasm.br: %w", err)
158+ }
159+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), int64(maxBytes)+1))
160+ if err != nil {
161+ return fmt.Errorf("decompress systemviews.wasm.br: %w", err)
162+ }
163+ if len(data) > maxBytes {
164+ return fmt.Errorf("systemviews.wasm.br decompresses past %d bytes", maxBytes)
165+ }
166+ if !bytes.Equal(data, raw) {
167+ return fmt.Errorf("systemviews.wasm.br decompresses to %d bytes, raw file has %d", len(data), len(raw))
168+ }
169+ return nil
170+}
A cmd/systemviews-assets/main_test.go +213 -0
@@ -0,0 +1,213 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "testing/fstest"
9+
10+ "github.com/andybalholm/brotli"
11+
12+ "github.com/bnema/dumber/assets"
13+)
14+
15+func writeFixture(t *testing.T, dir, name string, data []byte) {
16+ t.Helper()
17+ if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
18+ t.Fatalf("write fixture %s: %v", name, err)
19+ }
20+}
21+
22+func fixtureBundle(t *testing.T, wasm, js, css []byte) string {
23+ t.Helper()
24+ dir := t.TempDir()
25+ writeFixture(t, dir, "systemviews.wasm", wasm)
26+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, wasm))
27+ writeFixture(t, dir, "wasm_exec.js", js)
28+ writeFixture(t, dir, "systemviews.css", css)
29+ return dir
30+}
31+
32+func brotliCompressForGeneratorTest(t *testing.T, data []byte) []byte {
33+ t.Helper()
34+ var buf bytes.Buffer
35+ w := brotli.NewWriter(&buf)
36+ if _, err := w.Write(data); err != nil {
37+ t.Fatalf("brotli write: %v", err)
38+ }
39+ if err := w.Close(); err != nil {
40+ t.Fatalf("brotli close: %v", err)
41+ }
42+ return buf.Bytes()
43+}
44+
45+func TestCollectManifestPinsAllFiles(t *testing.T) {
46+ t.Parallel()
47+
48+ dir := fixtureBundle(t, []byte("\x00asm-m"), []byte("/* js */"), []byte("/* css */"))
49+ m, err := collectManifest(os.DirFS(dir))
50+ if err != nil {
51+ t.Fatalf("collectManifest() error = %v", err)
52+ }
53+ if m.Version != assets.ManifestVersion {
54+ t.Fatalf("version = %d", m.Version)
55+ }
56+ if len(m.Files) != len(assets.ManifestFiles) {
57+ t.Fatalf("pinned %d files", len(m.Files))
58+ }
59+ if err := m.Verify(os.DirFS(dir)); err != nil {
60+ t.Fatalf("collected manifest does not verify: %v", err)
61+ }
62+}
63+
64+func TestRunWritesReproducibleManifest(t *testing.T) {
65+ t.Parallel()
66+
67+ dir := fixtureBundle(t, []byte("\x00asm-repro"), []byte("/* js */"), []byte("/* css */"))
68+ if err := run(dir, false, false); err != nil {
69+ t.Fatalf("run() error = %v", err)
70+ }
71+ first, err := os.ReadFile(filepath.Join(dir, assets.ManifestName))
72+ if err != nil {
73+ t.Fatalf("read manifest: %v", err)
74+ }
75+ if err := run(dir, false, false); err != nil {
76+ t.Fatalf("run() again error = %v", err)
77+ }
78+ second, readErr := os.ReadFile(filepath.Join(dir, assets.ManifestName))
79+ if readErr != nil {
80+ t.Fatalf("read manifest: %v", readErr)
81+ }
82+ if !bytes.Equal(first, second) {
83+ t.Fatal("manifest generation is not reproducible")
84+ }
85+ if err := run(dir, true, false); err != nil {
86+ t.Fatalf("run(check) error = %v", err)
87+ }
88+}
89+
90+func TestRunFailsOnMissingFile(t *testing.T) {
91+ t.Parallel()
92+
93+ dir := t.TempDir()
94+ writeFixture(t, dir, "systemviews.wasm", []byte("\x00asm"))
95+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm")))
96+ // wasm_exec.js and systemviews.css missing: loud failure, no manifest.
97+ if err := run(dir, false, false); err == nil {
98+ t.Fatal("run() with missing files succeeded, want error")
99+ }
100+ if _, err := os.Stat(filepath.Join(dir, assets.ManifestName)); !os.IsNotExist(err) {
101+ t.Fatal("partial manifest written on failure")
102+ }
103+}
104+
105+func TestRunFailsOnStaleDigest(t *testing.T) {
106+ t.Parallel()
107+
108+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
109+ if err := run(dir, false, false); err != nil {
110+ t.Fatalf("run() error = %v", err)
111+ }
112+ // Mutate an artifact after generation: check must fail, never bless it.
113+ writeFixture(t, dir, "systemviews.css", []byte("/* css v2 */"))
114+ if err := run(dir, true, false); err == nil {
115+ t.Fatal("run(check) with mutated artifact succeeded, want error")
116+ }
117+}
118+
119+func TestCollectManifestRejectsRawCompressedMismatch(t *testing.T) {
120+ t.Parallel()
121+
122+ fsys := fstest.MapFS{
123+ "systemviews.wasm": {Data: []byte("\x00asm-raw")},
124+ "systemviews.wasm.br": {Data: brotliCompressForGeneratorTest(t, []byte("\x00asm-other"))},
125+ "wasm_exec.js": {Data: []byte("/* js */")},
126+ "systemviews.css": {Data: []byte("/* css */")},
127+ }
128+ if _, err := collectManifest(fsys); err == nil {
129+ t.Fatal("collectManifest() with raw/compressed mismatch succeeded, want error")
130+ }
131+}
132+
133+func TestRunCheckFailsWithoutManifest(t *testing.T) {
134+ t.Parallel()
135+
136+ if err := run(t.TempDir(), true, false); err == nil {
137+ t.Fatal("run(check) without manifest succeeded, want error")
138+ }
139+}
140+
141+func TestRunCheckRejectsCompressedOnlySwap(t *testing.T) {
142+ t.Parallel()
143+
144+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
145+ if err := run(dir, false, false); err != nil {
146+ t.Fatalf("run() error = %v", err)
147+ }
148+ // Swap only the compressed artifact for different valid bytes: raw
149+ // and manifest still agree, but serving would decode the stale .br.
150+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm-v2")))
151+ if err := run(dir, true, false); err == nil {
152+ t.Fatal("run(check) with swapped compressed WASM succeeded, want error")
153+ }
154+}
155+
156+func TestRunCheckIfPresentSkipsMissingBundle(t *testing.T) {
157+ t.Parallel()
158+
159+ if err := run(t.TempDir(), false, true); err != nil {
160+ t.Fatalf("run(check-if-present) without bundle error = %v", err)
161+ }
162+}
163+
164+func TestRunCheckIfPresentRejectsStaleBundle(t *testing.T) {
165+ t.Parallel()
166+
167+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
168+ if err := run(dir, false, false); err != nil {
169+ t.Fatalf("run() error = %v", err)
170+ }
171+ writeFixture(t, dir, "systemviews.css", []byte("/* css v2 */"))
172+ if err := run(dir, false, true); err == nil {
173+ t.Fatal("run(check-if-present) with stale artifact succeeded, want error")
174+ }
175+}
176+
177+func TestRunCheckIfPresentRejectsPartialBundle(t *testing.T) {
178+ t.Parallel()
179+
180+ // Compressed-only partial bundle: serving would use the stale .br,
181+ // so check-if-present must validate (and fail) instead of skipping.
182+ dir := t.TempDir()
183+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm-stale")))
184+ if err := run(dir, false, true); err == nil {
185+ t.Fatal("run(check-if-present) with .br-only bundle succeeded, want error")
186+ }
187+
188+ // Raw-only partial bundle: same rule in the other direction.
189+ dir = t.TempDir()
190+ writeFixture(t, dir, "systemviews.wasm", []byte("\x00asm-raw-only"))
191+ if err := run(dir, false, true); err == nil {
192+ t.Fatal("run(check-if-present) with raw-only bundle succeeded, want error")
193+ }
194+}
195+
196+func TestRequireCompressedAgreementRejectsSentinelSize(t *testing.T) {
197+ t.Parallel()
198+
199+ // Both artifacts at max+1 with agreeing bytes: the bound must reject
200+ // before equality blesses what the runtime loader refuses.
201+ const bound = 16
202+ raw := bytes.Repeat([]byte{0x7a}, bound+1)
203+ fsys := fstest.MapFS{
204+ "systemviews.wasm": {Data: raw},
205+ "systemviews.wasm.br": {Data: brotliCompressForGeneratorTest(t, raw)},
206+ }
207+ if err := requireCompressedAgreement(fsys, raw, bound); err == nil {
208+ t.Fatal("requireCompressedAgreement at max+1 succeeded, want oversize error")
209+ }
210+ if _, err := readBoundedManifestFile(fsys, "systemviews.wasm", bound); err == nil {
211+ t.Fatal("readBoundedManifestFile at max+1 succeeded, want oversize error")
212+ }
213+}
@@ -0,0 +1,213 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "testing/fstest"
9+
10+ "github.com/andybalholm/brotli"
11+
12+ "github.com/bnema/dumber/assets"
13+)
14+
15+func writeFixture(t *testing.T, dir, name string, data []byte) {
16+ t.Helper()
17+ if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
18+ t.Fatalf("write fixture %s: %v", name, err)
19+ }
20+}
21+
22+func fixtureBundle(t *testing.T, wasm, js, css []byte) string {
23+ t.Helper()
24+ dir := t.TempDir()
25+ writeFixture(t, dir, "systemviews.wasm", wasm)
26+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, wasm))
27+ writeFixture(t, dir, "wasm_exec.js", js)
28+ writeFixture(t, dir, "systemviews.css", css)
29+ return dir
30+}
31+
32+func brotliCompressForGeneratorTest(t *testing.T, data []byte) []byte {
33+ t.Helper()
34+ var buf bytes.Buffer
35+ w := brotli.NewWriter(&buf)
36+ if _, err := w.Write(data); err != nil {
37+ t.Fatalf("brotli write: %v", err)
38+ }
39+ if err := w.Close(); err != nil {
40+ t.Fatalf("brotli close: %v", err)
41+ }
42+ return buf.Bytes()
43+}
44+
45+func TestCollectManifestPinsAllFiles(t *testing.T) {
46+ t.Parallel()
47+
48+ dir := fixtureBundle(t, []byte("\x00asm-m"), []byte("/* js */"), []byte("/* css */"))
49+ m, err := collectManifest(os.DirFS(dir))
50+ if err != nil {
51+ t.Fatalf("collectManifest() error = %v", err)
52+ }
53+ if m.Version != assets.ManifestVersion {
54+ t.Fatalf("version = %d", m.Version)
55+ }
56+ if len(m.Files) != len(assets.ManifestFiles) {
57+ t.Fatalf("pinned %d files", len(m.Files))
58+ }
59+ if err := m.Verify(os.DirFS(dir)); err != nil {
60+ t.Fatalf("collected manifest does not verify: %v", err)
61+ }
62+}
63+
64+func TestRunWritesReproducibleManifest(t *testing.T) {
65+ t.Parallel()
66+
67+ dir := fixtureBundle(t, []byte("\x00asm-repro"), []byte("/* js */"), []byte("/* css */"))
68+ if err := run(dir, false, false); err != nil {
69+ t.Fatalf("run() error = %v", err)
70+ }
71+ first, err := os.ReadFile(filepath.Join(dir, assets.ManifestName))
72+ if err != nil {
73+ t.Fatalf("read manifest: %v", err)
74+ }
75+ if err := run(dir, false, false); err != nil {
76+ t.Fatalf("run() again error = %v", err)
77+ }
78+ second, readErr := os.ReadFile(filepath.Join(dir, assets.ManifestName))
79+ if readErr != nil {
80+ t.Fatalf("read manifest: %v", readErr)
81+ }
82+ if !bytes.Equal(first, second) {
83+ t.Fatal("manifest generation is not reproducible")
84+ }
85+ if err := run(dir, true, false); err != nil {
86+ t.Fatalf("run(check) error = %v", err)
87+ }
88+}
89+
90+func TestRunFailsOnMissingFile(t *testing.T) {
91+ t.Parallel()
92+
93+ dir := t.TempDir()
94+ writeFixture(t, dir, "systemviews.wasm", []byte("\x00asm"))
95+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm")))
96+ // wasm_exec.js and systemviews.css missing: loud failure, no manifest.
97+ if err := run(dir, false, false); err == nil {
98+ t.Fatal("run() with missing files succeeded, want error")
99+ }
100+ if _, err := os.Stat(filepath.Join(dir, assets.ManifestName)); !os.IsNotExist(err) {
101+ t.Fatal("partial manifest written on failure")
102+ }
103+}
104+
105+func TestRunFailsOnStaleDigest(t *testing.T) {
106+ t.Parallel()
107+
108+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
109+ if err := run(dir, false, false); err != nil {
110+ t.Fatalf("run() error = %v", err)
111+ }
112+ // Mutate an artifact after generation: check must fail, never bless it.
113+ writeFixture(t, dir, "systemviews.css", []byte("/* css v2 */"))
114+ if err := run(dir, true, false); err == nil {
115+ t.Fatal("run(check) with mutated artifact succeeded, want error")
116+ }
117+}
118+
119+func TestCollectManifestRejectsRawCompressedMismatch(t *testing.T) {
120+ t.Parallel()
121+
122+ fsys := fstest.MapFS{
123+ "systemviews.wasm": {Data: []byte("\x00asm-raw")},
124+ "systemviews.wasm.br": {Data: brotliCompressForGeneratorTest(t, []byte("\x00asm-other"))},
125+ "wasm_exec.js": {Data: []byte("/* js */")},
126+ "systemviews.css": {Data: []byte("/* css */")},
127+ }
128+ if _, err := collectManifest(fsys); err == nil {
129+ t.Fatal("collectManifest() with raw/compressed mismatch succeeded, want error")
130+ }
131+}
132+
133+func TestRunCheckFailsWithoutManifest(t *testing.T) {
134+ t.Parallel()
135+
136+ if err := run(t.TempDir(), true, false); err == nil {
137+ t.Fatal("run(check) without manifest succeeded, want error")
138+ }
139+}
140+
141+func TestRunCheckRejectsCompressedOnlySwap(t *testing.T) {
142+ t.Parallel()
143+
144+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
145+ if err := run(dir, false, false); err != nil {
146+ t.Fatalf("run() error = %v", err)
147+ }
148+ // Swap only the compressed artifact for different valid bytes: raw
149+ // and manifest still agree, but serving would decode the stale .br.
150+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm-v2")))
151+ if err := run(dir, true, false); err == nil {
152+ t.Fatal("run(check) with swapped compressed WASM succeeded, want error")
153+ }
154+}
155+
156+func TestRunCheckIfPresentSkipsMissingBundle(t *testing.T) {
157+ t.Parallel()
158+
159+ if err := run(t.TempDir(), false, true); err != nil {
160+ t.Fatalf("run(check-if-present) without bundle error = %v", err)
161+ }
162+}
163+
164+func TestRunCheckIfPresentRejectsStaleBundle(t *testing.T) {
165+ t.Parallel()
166+
167+ dir := fixtureBundle(t, []byte("\x00asm-v1"), []byte("/* js */"), []byte("/* css */"))
168+ if err := run(dir, false, false); err != nil {
169+ t.Fatalf("run() error = %v", err)
170+ }
171+ writeFixture(t, dir, "systemviews.css", []byte("/* css v2 */"))
172+ if err := run(dir, false, true); err == nil {
173+ t.Fatal("run(check-if-present) with stale artifact succeeded, want error")
174+ }
175+}
176+
177+func TestRunCheckIfPresentRejectsPartialBundle(t *testing.T) {
178+ t.Parallel()
179+
180+ // Compressed-only partial bundle: serving would use the stale .br,
181+ // so check-if-present must validate (and fail) instead of skipping.
182+ dir := t.TempDir()
183+ writeFixture(t, dir, "systemviews.wasm.br", brotliCompressForGeneratorTest(t, []byte("\x00asm-stale")))
184+ if err := run(dir, false, true); err == nil {
185+ t.Fatal("run(check-if-present) with .br-only bundle succeeded, want error")
186+ }
187+
188+ // Raw-only partial bundle: same rule in the other direction.
189+ dir = t.TempDir()
190+ writeFixture(t, dir, "systemviews.wasm", []byte("\x00asm-raw-only"))
191+ if err := run(dir, false, true); err == nil {
192+ t.Fatal("run(check-if-present) with raw-only bundle succeeded, want error")
193+ }
194+}
195+
196+func TestRequireCompressedAgreementRejectsSentinelSize(t *testing.T) {
197+ t.Parallel()
198+
199+ // Both artifacts at max+1 with agreeing bytes: the bound must reject
200+ // before equality blesses what the runtime loader refuses.
201+ const bound = 16
202+ raw := bytes.Repeat([]byte{0x7a}, bound+1)
203+ fsys := fstest.MapFS{
204+ "systemviews.wasm": {Data: raw},
205+ "systemviews.wasm.br": {Data: brotliCompressForGeneratorTest(t, raw)},
206+ }
207+ if err := requireCompressedAgreement(fsys, raw, bound); err == nil {
208+ t.Fatal("requireCompressedAgreement at max+1 succeeded, want oversize error")
209+ }
210+ if _, err := readBoundedManifestFile(fsys, "systemviews.wasm", bound); err == nil {
211+ t.Fatal("readBoundedManifestFile at max+1 succeeded, want oversize error")
212+ }
213+}
M cmd/systemviews/main.go +6 -1
@@ -3,6 +3,7 @@
33 package main
44
55 import (
6+ "os"
67 "syscall/js"
78
89 "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
@@ -21,9 +22,13 @@ func main() {
2122 }
2223 app := newBridgeApp(systemviews.NewDOM(), href, bridge)
2324
25+ // A Run failure is terminal and exits nonzero without a Go panic
26+ // trap: the shell's promise handler renders the fatal state and the
27+ // fixture observes the exit code. The runtime executes exactly once;
28+ // select{} below keeps it alive so go.run never resolves.
2429 if err := app.Run(); err != nil {
2530 js.Global().Get("console").Call("error", err.Error())
26- panic(err)
31+ os.Exit(1)
2732 }
2833
2934 select {}
@@ -3,6 +3,7 @@
3 package main3 package main
4 4
5 import (5 import (
6+ "os"
6 "syscall/js"7 "syscall/js"
7 8
8 "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"9 "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
@@ -21,9 +22,13 @@ func main() {
21 }22 }
22 app := newBridgeApp(systemviews.NewDOM(), href, bridge)23 app := newBridgeApp(systemviews.NewDOM(), href, bridge)
23 24
25+ // A Run failure is terminal and exits nonzero without a Go panic
26+ // trap: the shell's promise handler renders the fatal state and the
27+ // fixture observes the exit code. The runtime executes exactly once;
28+ // select{} below keeps it alive so go.run never resolves.
24 if err := app.Run(); err != nil {29 if err := app.Run(); err != nil {
25 js.Global().Get("console").Call("error", err.Error())30 js.Global().Get("console").Call("error", err.Error())
26- panic(err)31+ os.Exit(1)
27 }32 }
28 33
29 select {}34 select {}
M go.mod +2 -0
@@ -2,6 +2,8 @@ module github.com/bnema/dumber
22
33 go 1.26
44
5+toolchain go1.26.7
6+
57 require (
68 github.com/a-h/templ v0.3.1020
79 github.com/andybalholm/brotli v1.2.3
@@ -2,6 +2,8 @@ module github.com/bnema/dumber
2 2
3 go 1.263 go 1.26
4 4
5+toolchain go1.26.7
6+
5 require (7 require (
6 github.com/a-h/templ v0.3.10208 github.com/a-h/templ v0.3.1020
7 github.com/andybalholm/brotli v1.2.39 github.com/andybalholm/brotli v1.2.3
M internal/infrastructure/cef/scheme_handler.go +246 -20
@@ -3,8 +3,9 @@ package cef
33 import (
44 "bytes"
55 "context"
6- "embed"
6+ "crypto/sha256"
77 "encoding/base64"
8+ "encoding/hex"
89 "encoding/json"
910 "fmt"
1011 "html"
@@ -26,6 +27,8 @@ import (
2627 "github.com/bnema/dumber/internal/infrastructure/webutil"
2728 "github.com/bnema/dumber/internal/logging"
2829 "github.com/rs/zerolog"
30+
31+ "github.com/bnema/dumber/assets"
2932 )
3033
3134 // Scheme path constants matching WebKit's naming.
@@ -53,11 +56,18 @@ var cefNewResourceHandler = purecef.NewResourceHandler
5356 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
5457 // internal https://dumber.invalid origin used by CEF.
5558 type dumbSchemeHandler struct {
56- ctx context.Context
57- messageRouter *MessageRouter
58- faviconResolver port.FaviconSystemviewResolver
59- assets embed.FS
60- assetsSet bool
59+ ctx context.Context
60+ messageRouter *MessageRouter
61+ faviconResolver port.FaviconSystemviewResolver
62+ // assets is the captured immutable asset filesystem. Together with
63+ // bundle it forms one replaceable bundle state: installing a different
64+ // FS replaces both, and in-flight requests retain their captured pair.
65+ // A replaced bundle's cached WASM stays alive until its last in-flight
66+ // request finishes, so old and new bundles briefly overlap in memory;
67+ // the retained cache budget is one WASM per active bundle. Production
68+ // embeds are immutable; no live file watching is added.
69+ assets fs.FS
70+ bundle *systemviewAssetBundle
6171 assetDir string
6272 logger zerolog.Logger
6373 currentConfigPayload func() ([]byte, error)
@@ -111,12 +121,14 @@ func newDumbSchemeHandler(
111121 }, nil
112122 }
113123
114-// setAssets sets the embedded filesystem containing systemviews assets.
115-func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
124+// setAssets installs one immutable asset filesystem and its fresh WASM
125+// cache as a single bundle state. In-flight requests keep their captured
126+// bundle and can never publish old bytes into the new cache.
127+func (h *dumbSchemeHandler) setAssets(assetsFS fs.FS) {
116128 h.mu.Lock()
117129 defer h.mu.Unlock()
118- h.assets = assets
119- h.assetsSet = true
130+ h.assets = assetsFS
131+ h.bundle = newSystemviewAssetBundle(assetsFS)
120132 h.logger.Debug().Msg("assets filesystem configured")
121133 }
122134
@@ -555,13 +567,13 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri
555567 })
556568 }
557569
558-// handleAsset serves static files from the embedded filesystem.
570+// handleAsset serves static files from the captured asset bundle.
559571 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
560572 h.mu.RLock()
561- hasAssets := h.assetsSet
573+ assetsFS, bundle := h.assets, h.bundle
562574 h.mu.RUnlock()
563575
564- if !hasAssets {
576+ if assetsFS == nil || bundle == nil {
565577 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
566578 }
567579
@@ -578,7 +590,21 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
578590 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
579591 }
580592
581- data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
593+ // The bundle WASM is served from the once-loaded immutable cache
594+ // through a deferred handler: Create returns immediately and the
595+ // decode runs off the CEF IO thread. The shell keeps its usable
596+ // relative URLs on disk but is served with content-versioned
597+ // references from the same captured bundle. Every other asset keeps
598+ // the legacy synchronous per-request read path.
599+ if relPath == indexHTML {
600+ return h.serveVersionedShell(assetsFS, bundle, fullPath)
601+ }
602+ if isVersionedAssetFile(relPath) {
603+ v, present, valid := parseAssetVersion(u)
604+ return h.serveVersionedStatic(assetsFS, bundle, fullPath, relPath, v, present, valid)
605+ }
606+
607+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
582608 if err != nil {
583609 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
584610 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -591,7 +617,202 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
591617 Int("size", len(data)).
592618 Msg("serving asset")
593619
594- return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
620+ // Unversioned static assets are servable but explicitly noncacheable;
621+ // only version-verified bytes earn immutable headers below.
622+ return newStaticResourceHandler(http.StatusOK, contentType, data,
623+ map[string]string{"Cache-Control": noStoreCacheControl})
624+}
625+
626+// immutableCacheControl grants long-lived private caching to exactly one
627+// verified content version. It is served only for allowlisted static
628+// files whose ?v= digest matches the SHA-256 of the exact bytes served.
629+const immutableCacheControl = "private, max-age=31536000, immutable"
630+
631+// noStoreCacheControl marks responses that must never be cached: the
632+// unversioned shell and static assets, dynamic API content, and every
633+// version-gating error.
634+const noStoreCacheControl = "no-store"
635+
636+// serveVersionedShell serves the internal shell with its known static
637+// references rewritten to content-versioned URLs (?v=<sha256>) from the
638+// captured bundle's manifest. The shell itself is unversioned and stays
639+// noncacheable; version queries on the shell are ignored because the
640+// shell document is never immutable. An invalid manifest yields a clear
641+// noncacheable error instead of unverified versioned content.
642+func (h *dumbSchemeHandler) serveVersionedShell(assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath string) purecef.ResourceHandler {
643+ data, err := readAssetWithEncoding(assetsFS, fullPath, indexHTML)
644+ if err != nil {
645+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("shell not found")
646+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
647+ }
648+ m, err := bundle.Manifest()
649+ if err != nil {
650+ h.logger.Error().Err(err).Msg("asset manifest invalid; refusing to version shell")
651+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
652+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
653+ map[string]string{"Cache-Control": noStoreCacheControl})
654+ }
655+ return newStaticResourceHandler(http.StatusOK, getMimeType(indexHTML), versionShellRefs(data, m),
656+ map[string]string{"Cache-Control": noStoreCacheControl})
657+}
658+
659+// versionShellRefs rewrites the shell's known static references with
660+// content-version query values: ./systemviews.wasm becomes
661+// ./systemviews.wasm?v=<digest>, likewise for the pinned JS and CSS.
662+// Only manifest-pinned files are rewritten and already-versioned
663+// references pass through untouched, so the on-disk shell keeps concrete
664+// usable relative URLs for alternate shells.
665+func versionShellRefs(shell []byte, m assets.Manifest) []byte {
666+ out := shell
667+ for _, name := range assets.ManifestFiles {
668+ plain := "./" + name
669+ if bytes.Contains(out, []byte(plain+"?v=")) {
670+ continue
671+ }
672+ out = bytes.ReplaceAll(out, []byte(plain), []byte(plain+"?v="+m.Files[name].SHA256))
673+ }
674+ return out
675+}
676+
677+// isVersionedAssetFile reports whether relPath is a canonical allowlisted
678+// static file: exactly the manifest-pinned servables. Only these may earn
679+// immutable headers; everything else (including the manifest itself and
680+// the shell) stays on the noncacheable path.
681+func isVersionedAssetFile(relPath string) bool {
682+ for _, name := range assets.ManifestFiles {
683+ if relPath == name {
684+ return true
685+ }
686+ }
687+ return false
688+}
689+
690+// parseAssetVersion extracts the content-version query value with
691+// exactly-one semantics: absent reports present=false (unversioned
692+// request), while duplicate or empty values report present=true with
693+// valid=false (a version-gating error, never an unversioned request).
694+// Malformed escape sequences are fail-closed too: u.Query silently drops
695+// unparseable components, so the raw query is parsed explicitly and any
696+// parse error alongside a v parameter rejects the request.
697+func parseAssetVersion(u *url.URL) (v string, present, valid bool) {
698+ if u == nil {
699+ return "", false, false
700+ }
701+ vals, qerr := url.ParseQuery(u.RawQuery)
702+ if qerr != nil {
703+ // A malformed query carrying a v parameter is a version-gating
704+ // error; without one it is an ordinary unversioned request.
705+ return "", hasVersionParam(u.RawQuery), false
706+ }
707+ vs, ok := vals["v"]
708+ if !ok || len(vs) == 0 {
709+ return "", false, false
710+ }
711+ if len(vs) != 1 || vs[0] == "" {
712+ return "", true, false
713+ }
714+ return vs[0], true, true
715+}
716+
717+// hasVersionParam reports whether the raw query carries a v parameter,
718+// used only to classify malformed queries fail-closed.
719+func hasVersionParam(rawQuery string) bool {
720+ for _, part := range strings.Split(rawQuery, "&") {
721+ name, _, _ := strings.Cut(part, "=")
722+ if name == "v" {
723+ return true
724+ }
725+ }
726+ return false
727+}
728+
729+// serveVersionedStatic serves one allowlisted static file with byte-bound
730+// cache policy: exactly one valid ?v= matching the SHA-256 of the exact
731+// served bytes earns immutable headers. URL-to-manifest equality alone is
732+// insufficient — the bytes are hashed and compared. Duplicate, malformed,
733+// or mismatching versions, missing artifacts, and stale manifests all
734+// return a noncacheable error; unversioned requests serve explicitly
735+// noncacheable without verification.
736+func (h *dumbSchemeHandler) serveVersionedStatic(
737+ assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath, v string, present, valid bool,
738+) purecef.ResourceHandler {
739+ if present && !valid {
740+ return h.versionError(fullPath)
741+ }
742+ // Unversioned requests never consult the manifest: they serve
743+ // explicitly noncacheable without verification, including on
744+ // manifest-less bundles.
745+ if !present {
746+ return h.serveUnversionedStatic(assetsFS, bundle, fullPath, relPath)
747+ }
748+ m, err := bundle.Manifest()
749+ if err != nil {
750+ h.logger.Error().Err(err).Msg("asset manifest invalid; refusing versioned static")
751+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
752+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
753+ map[string]string{"Cache-Control": noStoreCacheControl})
754+ }
755+ pin, ok := m.Files[relPath]
756+ if !ok {
757+ h.logger.Error().Str("path", fullPath).Msg("asset manifest missing pin; refusing versioned static")
758+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
759+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
760+ map[string]string{"Cache-Control": noStoreCacheControl})
761+ }
762+ if v != pin.SHA256 {
763+ return h.versionError(fullPath)
764+ }
765+ if relPath == "systemviews.wasm" {
766+ return newSystemviewWASMResourceHandler(h.ctx, bundle, true, pin.SHA256)
767+ }
768+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
769+ if err != nil {
770+ return h.versionError(fullPath)
771+ }
772+ if sha256Hex(data) != pin.SHA256 {
773+ h.logger.Debug().Str("path", fullPath).Msg("versioned asset bytes do not match manifest pin")
774+ return h.versionError(fullPath)
775+ }
776+ h.logger.Debug().
777+ Str("path", fullPath).
778+ Str("content_type", getMimeType(relPath)).
779+ Int("size", len(data)).
780+ Msg("serving verified immutable asset")
781+ return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
782+ map[string]string{"Cache-Control": immutableCacheControl})
783+}
784+
785+// serveUnversionedStatic serves an allowlisted file requested without a
786+// version: readable, explicitly noncacheable, unverified.
787+func (h *dumbSchemeHandler) serveUnversionedStatic(
788+ assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath string,
789+) purecef.ResourceHandler {
790+ if relPath == "systemviews.wasm" {
791+ return newSystemviewWASMResourceHandler(h.ctx, bundle, false, "")
792+ }
793+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
794+ if err != nil {
795+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
796+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
797+ }
798+ return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
799+ map[string]string{"Cache-Control": noStoreCacheControl})
800+}
801+
802+// versionError returns a noncacheable not-found for version-gating
803+// failures: duplicates, malformed values, mismatches, and missing
804+// versioned bytes all read as not found without inviting caching or
805+// enumeration.
806+func (h *dumbSchemeHandler) versionError(fullPath string) purecef.ResourceHandler {
807+ h.logger.Debug().Str("path", fullPath).Msg("versioned asset rejected")
808+ return newStaticResourceHandler(http.StatusNotFound, "text/html; charset=utf-8", errorPageBody(http.StatusNotFound, "Asset not found"),
809+ map[string]string{"Cache-Control": noStoreCacheControl})
810+}
811+
812+// sha256Hex digests exact served bytes for manifest comparison.
813+func sha256Hex(data []byte) string {
814+ sum := sha256.Sum256(data)
815+ return hex.EncodeToString(sum[:])
595816 }
596817
597818 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
@@ -617,9 +838,9 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
617838 return fullPath, cleanRelPath, true
618839 }
619840
620-func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) {
841+func readAssetWithEncoding(assetsFS fs.FS, fullPath, relPath string) ([]byte, error) {
621842 if strings.HasSuffix(relPath, ".wasm") {
622- if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
843+ if compressed, err := fs.ReadFile(assetsFS, fullPath+".br"); err == nil {
623844 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
624845 if err != nil {
625846 return nil, err
@@ -630,7 +851,7 @@ func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, erro
630851 return data, nil
631852 }
632853 }
633- data, err := fs.ReadFile(assets, fullPath)
854+ data, err := fs.ReadFile(assetsFS, fullPath)
634855 return data, err
635856 }
636857
@@ -807,9 +1028,14 @@ func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentT
8071028 }
8081029
8091030 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
1031+ return h.newRawResourceHandler(status, "text/html; charset=utf-8", errorPageBody(status, msg))
1032+}
1033+
1034+// errorPageBody renders the shared plain error document. Deferred handlers
1035+// reuse it so failures stay byte-identical to the synchronous error path.
1036+func errorPageBody(status int, msg string) []byte {
8101037 escaped := html.EscapeString(msg)
811- body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
812- return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
1038+ return []byte(fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped))
8131039 }
8141040
8151041 func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
@@ -3,8 +3,9 @@ package cef
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6- "embed"6+ "crypto/sha256"
7 "encoding/base64"7 "encoding/base64"
8+ "encoding/hex"
8 "encoding/json"9 "encoding/json"
9 "fmt"10 "fmt"
10 "html"11 "html"
@@ -26,6 +27,8 @@ import (
26 "github.com/bnema/dumber/internal/infrastructure/webutil"27 "github.com/bnema/dumber/internal/infrastructure/webutil"
27 "github.com/bnema/dumber/internal/logging"28 "github.com/bnema/dumber/internal/logging"
28 "github.com/rs/zerolog"29 "github.com/rs/zerolog"
30+
31+ "github.com/bnema/dumber/assets"
29 )32 )
30 33
31 // Scheme path constants matching WebKit's naming.34 // Scheme path constants matching WebKit's naming.
@@ -53,11 +56,18 @@ var cefNewResourceHandler = purecef.NewResourceHandler
53 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual56 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
54 // internal https://dumber.invalid origin used by CEF.57 // internal https://dumber.invalid origin used by CEF.
55 type dumbSchemeHandler struct {58 type dumbSchemeHandler struct {
56- ctx context.Context59+ ctx context.Context
57- messageRouter *MessageRouter60+ messageRouter *MessageRouter
58- faviconResolver port.FaviconSystemviewResolver61+ faviconResolver port.FaviconSystemviewResolver
59- assets embed.FS62+ // assets is the captured immutable asset filesystem. Together with
60- assetsSet bool63+ // bundle it forms one replaceable bundle state: installing a different
64+ // FS replaces both, and in-flight requests retain their captured pair.
65+ // A replaced bundle's cached WASM stays alive until its last in-flight
66+ // request finishes, so old and new bundles briefly overlap in memory;
67+ // the retained cache budget is one WASM per active bundle. Production
68+ // embeds are immutable; no live file watching is added.
69+ assets fs.FS
70+ bundle *systemviewAssetBundle
61 assetDir string71 assetDir string
62 logger zerolog.Logger72 logger zerolog.Logger
63 currentConfigPayload func() ([]byte, error)73 currentConfigPayload func() ([]byte, error)
@@ -111,12 +121,14 @@ func newDumbSchemeHandler(
111 }, nil121 }, nil
112 }122 }
113 123
114-// setAssets sets the embedded filesystem containing systemviews assets.124+// setAssets installs one immutable asset filesystem and its fresh WASM
115-func (h *dumbSchemeHandler) setAssets(assets embed.FS) {125+// cache as a single bundle state. In-flight requests keep their captured
126+// bundle and can never publish old bytes into the new cache.
127+func (h *dumbSchemeHandler) setAssets(assetsFS fs.FS) {
116 h.mu.Lock()128 h.mu.Lock()
117 defer h.mu.Unlock()129 defer h.mu.Unlock()
118- h.assets = assets130+ h.assets = assetsFS
119- h.assetsSet = true131+ h.bundle = newSystemviewAssetBundle(assetsFS)
120 h.logger.Debug().Msg("assets filesystem configured")132 h.logger.Debug().Msg("assets filesystem configured")
121 }133 }
122 134
@@ -555,13 +567,13 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri
555 })567 })
556 }568 }
557 569
558-// handleAsset serves static files from the embedded filesystem.570+// handleAsset serves static files from the captured asset bundle.
559 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {571 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
560 h.mu.RLock()572 h.mu.RLock()
561- hasAssets := h.assetsSet573+ assetsFS, bundle := h.assets, h.bundle
562 h.mu.RUnlock()574 h.mu.RUnlock()
563 575
564- if !hasAssets {576+ if assetsFS == nil || bundle == nil {
565 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")577 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
566 }578 }
567 579
@@ -578,7 +590,21 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
578 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")590 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
579 }591 }
580 592
581- data, err := readAssetWithEncoding(h.assets, fullPath, relPath)593+ // The bundle WASM is served from the once-loaded immutable cache
594+ // through a deferred handler: Create returns immediately and the
595+ // decode runs off the CEF IO thread. The shell keeps its usable
596+ // relative URLs on disk but is served with content-versioned
597+ // references from the same captured bundle. Every other asset keeps
598+ // the legacy synchronous per-request read path.
599+ if relPath == indexHTML {
600+ return h.serveVersionedShell(assetsFS, bundle, fullPath)
601+ }
602+ if isVersionedAssetFile(relPath) {
603+ v, present, valid := parseAssetVersion(u)
604+ return h.serveVersionedStatic(assetsFS, bundle, fullPath, relPath, v, present, valid)
605+ }
606+
607+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
582 if err != nil {608 if err != nil {
583 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")609 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
584 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")610 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -591,7 +617,202 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
591 Int("size", len(data)).617 Int("size", len(data)).
592 Msg("serving asset")618 Msg("serving asset")
593 619
594- return newStaticResourceHandler(http.StatusOK, contentType, data, nil)620+ // Unversioned static assets are servable but explicitly noncacheable;
621+ // only version-verified bytes earn immutable headers below.
622+ return newStaticResourceHandler(http.StatusOK, contentType, data,
623+ map[string]string{"Cache-Control": noStoreCacheControl})
624+}
625+
626+// immutableCacheControl grants long-lived private caching to exactly one
627+// verified content version. It is served only for allowlisted static
628+// files whose ?v= digest matches the SHA-256 of the exact bytes served.
629+const immutableCacheControl = "private, max-age=31536000, immutable"
630+
631+// noStoreCacheControl marks responses that must never be cached: the
632+// unversioned shell and static assets, dynamic API content, and every
633+// version-gating error.
634+const noStoreCacheControl = "no-store"
635+
636+// serveVersionedShell serves the internal shell with its known static
637+// references rewritten to content-versioned URLs (?v=<sha256>) from the
638+// captured bundle's manifest. The shell itself is unversioned and stays
639+// noncacheable; version queries on the shell are ignored because the
640+// shell document is never immutable. An invalid manifest yields a clear
641+// noncacheable error instead of unverified versioned content.
642+func (h *dumbSchemeHandler) serveVersionedShell(assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath string) purecef.ResourceHandler {
643+ data, err := readAssetWithEncoding(assetsFS, fullPath, indexHTML)
644+ if err != nil {
645+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("shell not found")
646+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
647+ }
648+ m, err := bundle.Manifest()
649+ if err != nil {
650+ h.logger.Error().Err(err).Msg("asset manifest invalid; refusing to version shell")
651+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
652+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
653+ map[string]string{"Cache-Control": noStoreCacheControl})
654+ }
655+ return newStaticResourceHandler(http.StatusOK, getMimeType(indexHTML), versionShellRefs(data, m),
656+ map[string]string{"Cache-Control": noStoreCacheControl})
657+}
658+
659+// versionShellRefs rewrites the shell's known static references with
660+// content-version query values: ./systemviews.wasm becomes
661+// ./systemviews.wasm?v=<digest>, likewise for the pinned JS and CSS.
662+// Only manifest-pinned files are rewritten and already-versioned
663+// references pass through untouched, so the on-disk shell keeps concrete
664+// usable relative URLs for alternate shells.
665+func versionShellRefs(shell []byte, m assets.Manifest) []byte {
666+ out := shell
667+ for _, name := range assets.ManifestFiles {
668+ plain := "./" + name
669+ if bytes.Contains(out, []byte(plain+"?v=")) {
670+ continue
671+ }
672+ out = bytes.ReplaceAll(out, []byte(plain), []byte(plain+"?v="+m.Files[name].SHA256))
673+ }
674+ return out
675+}
676+
677+// isVersionedAssetFile reports whether relPath is a canonical allowlisted
678+// static file: exactly the manifest-pinned servables. Only these may earn
679+// immutable headers; everything else (including the manifest itself and
680+// the shell) stays on the noncacheable path.
681+func isVersionedAssetFile(relPath string) bool {
682+ for _, name := range assets.ManifestFiles {
683+ if relPath == name {
684+ return true
685+ }
686+ }
687+ return false
688+}
689+
690+// parseAssetVersion extracts the content-version query value with
691+// exactly-one semantics: absent reports present=false (unversioned
692+// request), while duplicate or empty values report present=true with
693+// valid=false (a version-gating error, never an unversioned request).
694+// Malformed escape sequences are fail-closed too: u.Query silently drops
695+// unparseable components, so the raw query is parsed explicitly and any
696+// parse error alongside a v parameter rejects the request.
697+func parseAssetVersion(u *url.URL) (v string, present, valid bool) {
698+ if u == nil {
699+ return "", false, false
700+ }
701+ vals, qerr := url.ParseQuery(u.RawQuery)
702+ if qerr != nil {
703+ // A malformed query carrying a v parameter is a version-gating
704+ // error; without one it is an ordinary unversioned request.
705+ return "", hasVersionParam(u.RawQuery), false
706+ }
707+ vs, ok := vals["v"]
708+ if !ok || len(vs) == 0 {
709+ return "", false, false
710+ }
711+ if len(vs) != 1 || vs[0] == "" {
712+ return "", true, false
713+ }
714+ return vs[0], true, true
715+}
716+
717+// hasVersionParam reports whether the raw query carries a v parameter,
718+// used only to classify malformed queries fail-closed.
719+func hasVersionParam(rawQuery string) bool {
720+ for _, part := range strings.Split(rawQuery, "&") {
721+ name, _, _ := strings.Cut(part, "=")
722+ if name == "v" {
723+ return true
724+ }
725+ }
726+ return false
727+}
728+
729+// serveVersionedStatic serves one allowlisted static file with byte-bound
730+// cache policy: exactly one valid ?v= matching the SHA-256 of the exact
731+// served bytes earns immutable headers. URL-to-manifest equality alone is
732+// insufficient — the bytes are hashed and compared. Duplicate, malformed,
733+// or mismatching versions, missing artifacts, and stale manifests all
734+// return a noncacheable error; unversioned requests serve explicitly
735+// noncacheable without verification.
736+func (h *dumbSchemeHandler) serveVersionedStatic(
737+ assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath, v string, present, valid bool,
738+) purecef.ResourceHandler {
739+ if present && !valid {
740+ return h.versionError(fullPath)
741+ }
742+ // Unversioned requests never consult the manifest: they serve
743+ // explicitly noncacheable without verification, including on
744+ // manifest-less bundles.
745+ if !present {
746+ return h.serveUnversionedStatic(assetsFS, bundle, fullPath, relPath)
747+ }
748+ m, err := bundle.Manifest()
749+ if err != nil {
750+ h.logger.Error().Err(err).Msg("asset manifest invalid; refusing versioned static")
751+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
752+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
753+ map[string]string{"Cache-Control": noStoreCacheControl})
754+ }
755+ pin, ok := m.Files[relPath]
756+ if !ok {
757+ h.logger.Error().Str("path", fullPath).Msg("asset manifest missing pin; refusing versioned static")
758+ return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
759+ errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
760+ map[string]string{"Cache-Control": noStoreCacheControl})
761+ }
762+ if v != pin.SHA256 {
763+ return h.versionError(fullPath)
764+ }
765+ if relPath == "systemviews.wasm" {
766+ return newSystemviewWASMResourceHandler(h.ctx, bundle, true, pin.SHA256)
767+ }
768+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
769+ if err != nil {
770+ return h.versionError(fullPath)
771+ }
772+ if sha256Hex(data) != pin.SHA256 {
773+ h.logger.Debug().Str("path", fullPath).Msg("versioned asset bytes do not match manifest pin")
774+ return h.versionError(fullPath)
775+ }
776+ h.logger.Debug().
777+ Str("path", fullPath).
778+ Str("content_type", getMimeType(relPath)).
779+ Int("size", len(data)).
780+ Msg("serving verified immutable asset")
781+ return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
782+ map[string]string{"Cache-Control": immutableCacheControl})
783+}
784+
785+// serveUnversionedStatic serves an allowlisted file requested without a
786+// version: readable, explicitly noncacheable, unverified.
787+func (h *dumbSchemeHandler) serveUnversionedStatic(
788+ assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath string,
789+) purecef.ResourceHandler {
790+ if relPath == "systemviews.wasm" {
791+ return newSystemviewWASMResourceHandler(h.ctx, bundle, false, "")
792+ }
793+ data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
794+ if err != nil {
795+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
796+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
797+ }
798+ return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
799+ map[string]string{"Cache-Control": noStoreCacheControl})
800+}
801+
802+// versionError returns a noncacheable not-found for version-gating
803+// failures: duplicates, malformed values, mismatches, and missing
804+// versioned bytes all read as not found without inviting caching or
805+// enumeration.
806+func (h *dumbSchemeHandler) versionError(fullPath string) purecef.ResourceHandler {
807+ h.logger.Debug().Str("path", fullPath).Msg("versioned asset rejected")
808+ return newStaticResourceHandler(http.StatusNotFound, "text/html; charset=utf-8", errorPageBody(http.StatusNotFound, "Asset not found"),
809+ map[string]string{"Cache-Control": noStoreCacheControl})
810+}
811+
812+// sha256Hex digests exact served bytes for manifest comparison.
813+func sha256Hex(data []byte) string {
814+ sum := sha256.Sum256(data)
815+ return hex.EncodeToString(sum[:])
595 }816 }
596 817
597 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {818 func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
@@ -617,9 +838,9 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
617 return fullPath, cleanRelPath, true838 return fullPath, cleanRelPath, true
618 }839 }
619 840
620-func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) {841+func readAssetWithEncoding(assetsFS fs.FS, fullPath, relPath string) ([]byte, error) {
621 if strings.HasSuffix(relPath, ".wasm") {842 if strings.HasSuffix(relPath, ".wasm") {
622- if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {843+ if compressed, err := fs.ReadFile(assetsFS, fullPath+".br"); err == nil {
623 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))844 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
624 if err != nil {845 if err != nil {
625 return nil, err846 return nil, err
@@ -630,7 +851,7 @@ func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, erro
630 return data, nil851 return data, nil
631 }852 }
632 }853 }
633- data, err := fs.ReadFile(assets, fullPath)854+ data, err := fs.ReadFile(assetsFS, fullPath)
634 return data, err855 return data, err
635 }856 }
636 857
@@ -807,9 +1028,14 @@ func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentT
807 }1028 }
808 1029
809 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {1030 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
1031+ return h.newRawResourceHandler(status, "text/html; charset=utf-8", errorPageBody(status, msg))
1032+}
1033+
1034+// errorPageBody renders the shared plain error document. Deferred handlers
1035+// reuse it so failures stay byte-identical to the synchronous error path.
1036+func errorPageBody(status int, msg string) []byte {
810 escaped := html.EscapeString(msg)1037 escaped := html.EscapeString(msg)
811- body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)1038+ return []byte(fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped))
812- return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
813 }1039 }
814 1040
815 func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {1041 func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
A internal/infrastructure/cef/scheme_handler_assets_p1_test.go +317 -0
@@ -0,0 +1,317 @@
1+package cef
2+
3+// P1 characterization for Plan 05 (internal systemview startup).
4+//
5+// These tests lock the CURRENT asset behavior before any caching work:
6+// every WASM request fully re-reads and re-decompresses (P1.1), and the
7+// response-class map (P1.2) records exactly where no-store applies.
8+// Header expectations for served 200s follow the P3.3 policy (unversioned
9+// successes explicitly noncacheable, immutable only for verified
10+// versions); error pages keep their headerless shape throughout.
11+
12+import (
13+ "io/fs"
14+ "net/http"
15+ "sync"
16+ "testing"
17+ "testing/fstest"
18+
19+ purecef "github.com/bnema/purego-cef/cef"
20+ "github.com/stretchr/testify/require"
21+
22+ "github.com/bnema/dumber/assets"
23+)
24+
25+// decodeCountingFS counts full asset reads: with no cache, one served WASM
26+// request equals one decompression. The counter lives outside production
27+// code; P2 replaces call-counting with a real decode-once assertion.
28+type decodeCountingFS struct {
29+ fstest.MapFS
30+ mu sync.Mutex
31+ reads map[string]int
32+}
33+
34+func (d *decodeCountingFS) record(name string) {
35+ d.mu.Lock()
36+ defer d.mu.Unlock()
37+ if d.reads == nil {
38+ d.reads = map[string]int{}
39+ }
40+ d.reads[name]++
41+}
42+
43+func (d *decodeCountingFS) count(name string) int {
44+ d.mu.Lock()
45+ defer d.mu.Unlock()
46+ return d.reads[name]
47+}
48+
49+func countingWASMFS(t *testing.T, wasm []byte) *decodeCountingFS {
50+ t.Helper()
51+ return &decodeCountingFS{
52+ MapFS: fstest.MapFS{
53+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
54+ },
55+ }
56+}
57+
58+func TestAssetP1_RepeatedReadsRedundantlyDecode(t *testing.T) {
59+ t.Parallel()
60+
61+ wasm := []byte("\x00asm-redundant-fixture-p1")
62+ fsys := countingWASMFS(t, wasm)
63+
64+ const requests = 3
65+ got := make([][]byte, requests)
66+ for i := range got {
67+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
68+ require.NoError(t, err)
69+ fsys.record("systemviews/systemviews.wasm.br")
70+ got[i] = data
71+ }
72+ for _, data := range got[1:] {
73+ require.Equal(t, got[0], data, "repeated requests must serve identical bytes")
74+ }
75+ require.Equal(t, requests, fsys.count("systemviews/systemviews.wasm.br"),
76+ "P1 baseline: every request re-reads the compressed asset; P2 must decode once")
77+}
78+
79+func TestAssetP1_ConcurrentReadsServeIdenticalBytes(t *testing.T) {
80+ t.Parallel()
81+
82+ wasm := []byte("\x00asm-concurrent-fixture-p1")
83+ fsys := countingWASMFS(t, wasm)
84+
85+ const readers = 8
86+ got := make([][]byte, readers)
87+ errs := make([]error, readers)
88+ var wg sync.WaitGroup
89+ for i := range got {
90+ wg.Add(1)
91+ go func(i int) {
92+ defer wg.Done()
93+ got[i], errs[i] = readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
94+ }(i)
95+ }
96+ wg.Wait()
97+ for i := range got {
98+ require.NoError(t, errs[i])
99+ require.Equal(t, wasm, got[i])
100+ }
101+}
102+
103+func TestAssetP1_CompressedPreferredOverRaw(t *testing.T) {
104+ t.Parallel()
105+
106+ fromCompressed := []byte("\x00asm-from-br")
107+ fromRaw := []byte("\x00asm-from-raw")
108+ fsys := fstest.MapFS{
109+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, fromCompressed)},
110+ "systemviews/systemviews.wasm": {Data: fromRaw},
111+ }
112+
113+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
114+ require.NoError(t, err)
115+ require.Equal(t, fromCompressed, data, "compressed artifact wins when present")
116+}
117+
118+func TestAssetP1_RawOnlyFallback(t *testing.T) {
119+ t.Parallel()
120+
121+ raw := []byte("\x00asm-raw-only")
122+ fsys := fstest.MapFS{
123+ "systemviews/systemviews.wasm": {Data: raw},
124+ }
125+
126+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
127+ require.NoError(t, err)
128+ require.Equal(t, raw, data)
129+}
130+
131+func TestAssetP1_CorruptCompressedHasNoRawFallback(t *testing.T) {
132+ t.Parallel()
133+
134+ // Current semantics: any successfully read .br is authoritative. A
135+ // corrupt compressed artifact is an explicit error even when valid raw
136+ // data exists. P2 keeps this tightened behavior.
137+ fsys := fstest.MapFS{
138+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli-data")},
139+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
140+ }
141+
142+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
143+ require.Error(t, err, "corrupt compressed WASM must fail explicitly, not fall back to raw")
144+}
145+
146+func TestAssetP1_OversizedDecompressedRejected(t *testing.T) {
147+ t.Parallel()
148+
149+ // Zeros compress to almost nothing, so this stays fast while proving
150+ // the decompressed-size bound.
151+ big := make([]byte, maxSystemviewsWASMBytes+1)
152+ fsys := fstest.MapFS{
153+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, big)},
154+ }
155+
156+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
157+ require.Error(t, err, "decompressed output past the 64 MiB bound must fail")
158+}
159+
160+func TestAssetP1_RawReadCurrentlyUnbounded(t *testing.T) {
161+ t.Parallel()
162+
163+ // P1 baseline: the raw path has no size bound at all. P2.1 deliberately
164+ // tightens this alongside the compressed path.
165+ big := make([]byte, maxSystemviewsWASMBytes+1)
166+ fsys := fstest.MapFS{
167+ "systemviews/systemviews.wasm": {Data: big},
168+ }
169+
170+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
171+ require.NoError(t, err)
172+ require.Len(t, data, maxSystemviewsWASMBytes+1, "raw reads are unbounded at baseline")
173+}
174+
175+func TestAssetP1_MissingAssetReportsNotExist(t *testing.T) {
176+ t.Parallel()
177+
178+ fsys := fstest.MapFS{}
179+
180+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
181+ require.Error(t, err)
182+ require.ErrorIs(t, err, fs.ErrNotExist, "missing asset must surface fs.ErrNotExist")
183+}
184+
185+// staticHandlerOf unwraps the identity-stubbed resource handler. Callers
186+// must stub cefNewResourceHandler to the identity function first.
187+func staticHandlerOf(t *testing.T, rh any) *staticResourceHandler {
188+ t.Helper()
189+ h, ok := rh.(*staticResourceHandler)
190+ require.True(t, ok, "expected *staticResourceHandler, got %T", rh)
191+ return h
192+}
193+
194+func stubIdentityResourceHandler(t *testing.T) {
195+ t.Helper()
196+ old := cefNewResourceHandler
197+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
198+ t.Cleanup(func() { cefNewResourceHandler = old })
199+}
200+
201+// P1.2 response classes: exactly where no-store is applied today. Asset
202+// success, error pages, and the crash shell carry NO Cache-Control header
203+// at baseline; P3 grants immutable headers only to version-validated
204+// static bytes.
205+
206+func TestAssetP1_ShellServes200CarriesNoStore(t *testing.T) {
207+ stubIdentityResourceHandler(t)
208+ h := newTestDumbSchemeHandler(t)
209+ h.setAssets(assets.WebUIAssets)
210+
211+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
212+ require.Equal(t, http.StatusOK, rh.statusCode)
213+ require.Equal(t, "text/html; charset=utf-8", rh.contentType)
214+ require.NotEmpty(t, rh.data)
215+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
216+ "P3.3 policy: the unversioned shell is explicitly noncacheable; only version-verified bytes earn immutable")
217+}
218+
219+func TestAssetP1_CSSServes200CarriesNoStore(t *testing.T) {
220+ stubIdentityResourceHandler(t)
221+ h := newTestDumbSchemeHandler(t)
222+ h.setAssets(assets.WebUIAssets)
223+
224+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css")))
225+ require.Equal(t, http.StatusOK, rh.statusCode)
226+ require.Equal(t, "text/css; charset=utf-8", rh.contentType, "Go mime table carries the charset suffix")
227+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
228+ "P3.3 policy: unversioned static assets are explicitly noncacheable")
229+}
230+
231+func TestAssetP1_MissingAssetServes404WithoutCacheHeaders(t *testing.T) {
232+ stubIdentityResourceHandler(t)
233+ h := newTestDumbSchemeHandler(t)
234+ h.setAssets(assets.WebUIAssets)
235+
236+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/does-not-exist.js")))
237+ require.Equal(t, http.StatusNotFound, rh.statusCode)
238+ require.Empty(t, rh.headers, "error pages carry no Cache-Control at baseline")
239+}
240+
241+func TestAssetP1_RedirectCarriesNoStore(t *testing.T) {
242+ stubIdentityResourceHandler(t)
243+ h := newTestDumbSchemeHandler(t)
244+
245+ rh := staticHandlerOf(t, h.newRedirectResourceHandler(http.StatusTemporaryRedirect, "https://dumber.invalid/history"))
246+ require.Equal(t, http.StatusTemporaryRedirect, rh.statusCode)
247+ require.Equal(t, "https://dumber.invalid/history", rh.headers["Location"])
248+ require.Equal(t, "no-store", rh.headers["Cache-Control"])
249+}
250+
251+func TestAssetP1_RawAndErrorHandlersCarryNoCacheHeaders(t *testing.T) {
252+ stubIdentityResourceHandler(t)
253+ h := newTestDumbSchemeHandler(t)
254+
255+ require.Empty(t, staticHandlerOf(t, h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte("<p>x</p>"))).headers)
256+ require.Empty(t, staticHandlerOf(t, h.newErrorResourceHandler(http.StatusInternalServerError, "boom")).headers,
257+ "error pages carry no Cache-Control at baseline")
258+}
259+
260+func TestAssetP1_APIRawAndJSONCarryNoStoreCORS(t *testing.T) {
261+ stubIdentityResourceHandler(t)
262+ h := newTestDumbSchemeHandler(t)
263+
264+ raw := staticHandlerOf(t, h.newAPIRawResourceHandler(http.StatusOK, "application/json", []byte(`{}`)))
265+ require.Equal(t, "no-store", raw.headers["Cache-Control"])
266+ require.Equal(t, "*", raw.headers["Access-Control-Allow-Origin"])
267+
268+ js := staticHandlerOf(t, h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}))
269+ require.Equal(t, http.StatusNotFound, js.statusCode)
270+ require.Equal(t, "application/json", js.contentType)
271+ require.Equal(t, "no-store", js.headers["Cache-Control"])
272+ require.Equal(t, "*", js.headers["Access-Control-Allow-Origin"])
273+ require.JSONEq(t, `{"error":"not found"}`, string(js.data))
274+}
275+
276+func TestAssetP1_PrivateAPICarriesNoStoreWithoutCORS(t *testing.T) {
277+ stubIdentityResourceHandler(t)
278+ h := newTestDumbSchemeHandler(t)
279+
280+ raw := staticHandlerOf(t, h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil))
281+ require.Equal(t, "no-store", raw.headers["Cache-Control"])
282+ require.NotContains(t, raw.headers, "Access-Control-Allow-Origin")
283+
284+ js := staticHandlerOf(t, h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}))
285+ require.Equal(t, "no-store", js.headers["Cache-Control"])
286+ require.NotContains(t, js.headers, "Access-Control-Allow-Origin")
287+}
288+
289+func TestAssetP1_FaviconHandlerCarriesNoStore(t *testing.T) {
290+ stubIdentityResourceHandler(t)
291+
292+ rh := newFaviconResourceHandler(t.Context(), nil, "example.com", 32)
293+ fav, ok := rh.(*faviconResourceHandler)
294+ require.True(t, ok, "expected *faviconResourceHandler, got %T", rh)
295+ require.Equal(t, "no-store", fav.headers["Cache-Control"])
296+}
297+
298+func TestAssetP1_TopLevelAPIPathNeverResolvesAsAsset(t *testing.T) {
299+ for _, raw := range []string{
300+ "https://dumber.invalid/api/config",
301+ "https://dumber.invalid/api/message",
302+ } {
303+ _, _, ok := resolveAssetPath(mustParseURL(t, raw))
304+ require.False(t, ok, "%s must not resolve as an asset", raw)
305+ }
306+}
307+
308+func TestAssetP1_NestedAPIPathFallsThroughTo404(t *testing.T) {
309+ stubIdentityResourceHandler(t)
310+ h := newTestDumbSchemeHandler(t)
311+ h.setAssets(assets.WebUIAssets)
312+
313+ // handleAPI routes real API traffic before handleAsset; a nested api/
314+ // path reaching the asset layer must 404, never serve asset bytes.
315+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history/api/message")))
316+ require.Equal(t, http.StatusNotFound, rh.statusCode)
317+}
@@ -0,0 +1,317 @@
1+package cef
2+
3+// P1 characterization for Plan 05 (internal systemview startup).
4+//
5+// These tests lock the CURRENT asset behavior before any caching work:
6+// every WASM request fully re-reads and re-decompresses (P1.1), and the
7+// response-class map (P1.2) records exactly where no-store applies.
8+// Header expectations for served 200s follow the P3.3 policy (unversioned
9+// successes explicitly noncacheable, immutable only for verified
10+// versions); error pages keep their headerless shape throughout.
11+
12+import (
13+ "io/fs"
14+ "net/http"
15+ "sync"
16+ "testing"
17+ "testing/fstest"
18+
19+ purecef "github.com/bnema/purego-cef/cef"
20+ "github.com/stretchr/testify/require"
21+
22+ "github.com/bnema/dumber/assets"
23+)
24+
25+// decodeCountingFS counts full asset reads: with no cache, one served WASM
26+// request equals one decompression. The counter lives outside production
27+// code; P2 replaces call-counting with a real decode-once assertion.
28+type decodeCountingFS struct {
29+ fstest.MapFS
30+ mu sync.Mutex
31+ reads map[string]int
32+}
33+
34+func (d *decodeCountingFS) record(name string) {
35+ d.mu.Lock()
36+ defer d.mu.Unlock()
37+ if d.reads == nil {
38+ d.reads = map[string]int{}
39+ }
40+ d.reads[name]++
41+}
42+
43+func (d *decodeCountingFS) count(name string) int {
44+ d.mu.Lock()
45+ defer d.mu.Unlock()
46+ return d.reads[name]
47+}
48+
49+func countingWASMFS(t *testing.T, wasm []byte) *decodeCountingFS {
50+ t.Helper()
51+ return &decodeCountingFS{
52+ MapFS: fstest.MapFS{
53+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
54+ },
55+ }
56+}
57+
58+func TestAssetP1_RepeatedReadsRedundantlyDecode(t *testing.T) {
59+ t.Parallel()
60+
61+ wasm := []byte("\x00asm-redundant-fixture-p1")
62+ fsys := countingWASMFS(t, wasm)
63+
64+ const requests = 3
65+ got := make([][]byte, requests)
66+ for i := range got {
67+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
68+ require.NoError(t, err)
69+ fsys.record("systemviews/systemviews.wasm.br")
70+ got[i] = data
71+ }
72+ for _, data := range got[1:] {
73+ require.Equal(t, got[0], data, "repeated requests must serve identical bytes")
74+ }
75+ require.Equal(t, requests, fsys.count("systemviews/systemviews.wasm.br"),
76+ "P1 baseline: every request re-reads the compressed asset; P2 must decode once")
77+}
78+
79+func TestAssetP1_ConcurrentReadsServeIdenticalBytes(t *testing.T) {
80+ t.Parallel()
81+
82+ wasm := []byte("\x00asm-concurrent-fixture-p1")
83+ fsys := countingWASMFS(t, wasm)
84+
85+ const readers = 8
86+ got := make([][]byte, readers)
87+ errs := make([]error, readers)
88+ var wg sync.WaitGroup
89+ for i := range got {
90+ wg.Add(1)
91+ go func(i int) {
92+ defer wg.Done()
93+ got[i], errs[i] = readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
94+ }(i)
95+ }
96+ wg.Wait()
97+ for i := range got {
98+ require.NoError(t, errs[i])
99+ require.Equal(t, wasm, got[i])
100+ }
101+}
102+
103+func TestAssetP1_CompressedPreferredOverRaw(t *testing.T) {
104+ t.Parallel()
105+
106+ fromCompressed := []byte("\x00asm-from-br")
107+ fromRaw := []byte("\x00asm-from-raw")
108+ fsys := fstest.MapFS{
109+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, fromCompressed)},
110+ "systemviews/systemviews.wasm": {Data: fromRaw},
111+ }
112+
113+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
114+ require.NoError(t, err)
115+ require.Equal(t, fromCompressed, data, "compressed artifact wins when present")
116+}
117+
118+func TestAssetP1_RawOnlyFallback(t *testing.T) {
119+ t.Parallel()
120+
121+ raw := []byte("\x00asm-raw-only")
122+ fsys := fstest.MapFS{
123+ "systemviews/systemviews.wasm": {Data: raw},
124+ }
125+
126+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
127+ require.NoError(t, err)
128+ require.Equal(t, raw, data)
129+}
130+
131+func TestAssetP1_CorruptCompressedHasNoRawFallback(t *testing.T) {
132+ t.Parallel()
133+
134+ // Current semantics: any successfully read .br is authoritative. A
135+ // corrupt compressed artifact is an explicit error even when valid raw
136+ // data exists. P2 keeps this tightened behavior.
137+ fsys := fstest.MapFS{
138+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli-data")},
139+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
140+ }
141+
142+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
143+ require.Error(t, err, "corrupt compressed WASM must fail explicitly, not fall back to raw")
144+}
145+
146+func TestAssetP1_OversizedDecompressedRejected(t *testing.T) {
147+ t.Parallel()
148+
149+ // Zeros compress to almost nothing, so this stays fast while proving
150+ // the decompressed-size bound.
151+ big := make([]byte, maxSystemviewsWASMBytes+1)
152+ fsys := fstest.MapFS{
153+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, big)},
154+ }
155+
156+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
157+ require.Error(t, err, "decompressed output past the 64 MiB bound must fail")
158+}
159+
160+func TestAssetP1_RawReadCurrentlyUnbounded(t *testing.T) {
161+ t.Parallel()
162+
163+ // P1 baseline: the raw path has no size bound at all. P2.1 deliberately
164+ // tightens this alongside the compressed path.
165+ big := make([]byte, maxSystemviewsWASMBytes+1)
166+ fsys := fstest.MapFS{
167+ "systemviews/systemviews.wasm": {Data: big},
168+ }
169+
170+ data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
171+ require.NoError(t, err)
172+ require.Len(t, data, maxSystemviewsWASMBytes+1, "raw reads are unbounded at baseline")
173+}
174+
175+func TestAssetP1_MissingAssetReportsNotExist(t *testing.T) {
176+ t.Parallel()
177+
178+ fsys := fstest.MapFS{}
179+
180+ _, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
181+ require.Error(t, err)
182+ require.ErrorIs(t, err, fs.ErrNotExist, "missing asset must surface fs.ErrNotExist")
183+}
184+
185+// staticHandlerOf unwraps the identity-stubbed resource handler. Callers
186+// must stub cefNewResourceHandler to the identity function first.
187+func staticHandlerOf(t *testing.T, rh any) *staticResourceHandler {
188+ t.Helper()
189+ h, ok := rh.(*staticResourceHandler)
190+ require.True(t, ok, "expected *staticResourceHandler, got %T", rh)
191+ return h
192+}
193+
194+func stubIdentityResourceHandler(t *testing.T) {
195+ t.Helper()
196+ old := cefNewResourceHandler
197+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
198+ t.Cleanup(func() { cefNewResourceHandler = old })
199+}
200+
201+// P1.2 response classes: exactly where no-store is applied today. Asset
202+// success, error pages, and the crash shell carry NO Cache-Control header
203+// at baseline; P3 grants immutable headers only to version-validated
204+// static bytes.
205+
206+func TestAssetP1_ShellServes200CarriesNoStore(t *testing.T) {
207+ stubIdentityResourceHandler(t)
208+ h := newTestDumbSchemeHandler(t)
209+ h.setAssets(assets.WebUIAssets)
210+
211+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
212+ require.Equal(t, http.StatusOK, rh.statusCode)
213+ require.Equal(t, "text/html; charset=utf-8", rh.contentType)
214+ require.NotEmpty(t, rh.data)
215+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
216+ "P3.3 policy: the unversioned shell is explicitly noncacheable; only version-verified bytes earn immutable")
217+}
218+
219+func TestAssetP1_CSSServes200CarriesNoStore(t *testing.T) {
220+ stubIdentityResourceHandler(t)
221+ h := newTestDumbSchemeHandler(t)
222+ h.setAssets(assets.WebUIAssets)
223+
224+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css")))
225+ require.Equal(t, http.StatusOK, rh.statusCode)
226+ require.Equal(t, "text/css; charset=utf-8", rh.contentType, "Go mime table carries the charset suffix")
227+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
228+ "P3.3 policy: unversioned static assets are explicitly noncacheable")
229+}
230+
231+func TestAssetP1_MissingAssetServes404WithoutCacheHeaders(t *testing.T) {
232+ stubIdentityResourceHandler(t)
233+ h := newTestDumbSchemeHandler(t)
234+ h.setAssets(assets.WebUIAssets)
235+
236+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/does-not-exist.js")))
237+ require.Equal(t, http.StatusNotFound, rh.statusCode)
238+ require.Empty(t, rh.headers, "error pages carry no Cache-Control at baseline")
239+}
240+
241+func TestAssetP1_RedirectCarriesNoStore(t *testing.T) {
242+ stubIdentityResourceHandler(t)
243+ h := newTestDumbSchemeHandler(t)
244+
245+ rh := staticHandlerOf(t, h.newRedirectResourceHandler(http.StatusTemporaryRedirect, "https://dumber.invalid/history"))
246+ require.Equal(t, http.StatusTemporaryRedirect, rh.statusCode)
247+ require.Equal(t, "https://dumber.invalid/history", rh.headers["Location"])
248+ require.Equal(t, "no-store", rh.headers["Cache-Control"])
249+}
250+
251+func TestAssetP1_RawAndErrorHandlersCarryNoCacheHeaders(t *testing.T) {
252+ stubIdentityResourceHandler(t)
253+ h := newTestDumbSchemeHandler(t)
254+
255+ require.Empty(t, staticHandlerOf(t, h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte("<p>x</p>"))).headers)
256+ require.Empty(t, staticHandlerOf(t, h.newErrorResourceHandler(http.StatusInternalServerError, "boom")).headers,
257+ "error pages carry no Cache-Control at baseline")
258+}
259+
260+func TestAssetP1_APIRawAndJSONCarryNoStoreCORS(t *testing.T) {
261+ stubIdentityResourceHandler(t)
262+ h := newTestDumbSchemeHandler(t)
263+
264+ raw := staticHandlerOf(t, h.newAPIRawResourceHandler(http.StatusOK, "application/json", []byte(`{}`)))
265+ require.Equal(t, "no-store", raw.headers["Cache-Control"])
266+ require.Equal(t, "*", raw.headers["Access-Control-Allow-Origin"])
267+
268+ js := staticHandlerOf(t, h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}))
269+ require.Equal(t, http.StatusNotFound, js.statusCode)
270+ require.Equal(t, "application/json", js.contentType)
271+ require.Equal(t, "no-store", js.headers["Cache-Control"])
272+ require.Equal(t, "*", js.headers["Access-Control-Allow-Origin"])
273+ require.JSONEq(t, `{"error":"not found"}`, string(js.data))
274+}
275+
276+func TestAssetP1_PrivateAPICarriesNoStoreWithoutCORS(t *testing.T) {
277+ stubIdentityResourceHandler(t)
278+ h := newTestDumbSchemeHandler(t)
279+
280+ raw := staticHandlerOf(t, h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil))
281+ require.Equal(t, "no-store", raw.headers["Cache-Control"])
282+ require.NotContains(t, raw.headers, "Access-Control-Allow-Origin")
283+
284+ js := staticHandlerOf(t, h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}))
285+ require.Equal(t, "no-store", js.headers["Cache-Control"])
286+ require.NotContains(t, js.headers, "Access-Control-Allow-Origin")
287+}
288+
289+func TestAssetP1_FaviconHandlerCarriesNoStore(t *testing.T) {
290+ stubIdentityResourceHandler(t)
291+
292+ rh := newFaviconResourceHandler(t.Context(), nil, "example.com", 32)
293+ fav, ok := rh.(*faviconResourceHandler)
294+ require.True(t, ok, "expected *faviconResourceHandler, got %T", rh)
295+ require.Equal(t, "no-store", fav.headers["Cache-Control"])
296+}
297+
298+func TestAssetP1_TopLevelAPIPathNeverResolvesAsAsset(t *testing.T) {
299+ for _, raw := range []string{
300+ "https://dumber.invalid/api/config",
301+ "https://dumber.invalid/api/message",
302+ } {
303+ _, _, ok := resolveAssetPath(mustParseURL(t, raw))
304+ require.False(t, ok, "%s must not resolve as an asset", raw)
305+ }
306+}
307+
308+func TestAssetP1_NestedAPIPathFallsThroughTo404(t *testing.T) {
309+ stubIdentityResourceHandler(t)
310+ h := newTestDumbSchemeHandler(t)
311+ h.setAssets(assets.WebUIAssets)
312+
313+ // handleAPI routes real API traffic before handleAsset; a nested api/
314+ // path reaching the asset layer must 404, never serve asset bytes.
315+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history/api/message")))
316+ require.Equal(t, http.StatusNotFound, rh.statusCode)
317+}
A internal/infrastructure/cef/scheme_handler_assets_p2_test.go +505 -0
@@ -0,0 +1,505 @@
1+package cef
2+
3+// P2.2/P2.3 tests: the handler serves the WASM from its captured bundle
4+// through a deferred handler that never blocks CEF's IO thread.
5+// Replacement installs a fresh isolated state; in-flight requests keep the
6+// old bytes; canceled waiters suppress only their own continuation.
7+
8+import (
9+ "bytes"
10+ "context"
11+ "io/fs"
12+ "net/http"
13+ "strings"
14+ "sync"
15+ "testing"
16+ "testing/fstest"
17+ "time"
18+
19+ "github.com/andybalholm/brotli"
20+ purecef "github.com/bnema/purego-cef/cef"
21+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
22+ "github.com/stretchr/testify/require"
23+
24+ "github.com/bnema/dumber/assets"
25+)
26+
27+// blockingReadFileFS blocks ReadFile of one name until release is closed,
28+// proving non-blocking Create and replacement isolation during a decode.
29+type blockingReadFileFS struct {
30+ inner fstest.MapFS
31+ blockName string
32+ entered chan struct{}
33+ release chan struct{}
34+ once sync.Once
35+}
36+
37+func (b *blockingReadFileFS) gate(name string) {
38+ if name == b.blockName {
39+ b.once.Do(func() { close(b.entered) })
40+ <-b.release
41+ }
42+}
43+
44+func (b *blockingReadFileFS) Open(name string) (fs.File, error) {
45+ b.gate(name)
46+ return b.inner.Open(name)
47+}
48+
49+func (b *blockingReadFileFS) ReadFile(name string) ([]byte, error) {
50+ b.gate(name)
51+ return b.inner.ReadFile(name)
52+}
53+
54+// contStubCallback counts native continuations without mock machinery.
55+type contStubCallback struct {
56+ mu sync.Mutex
57+ conts int
58+}
59+
60+func (c *contStubCallback) Cont() {
61+ c.mu.Lock()
62+ defer c.mu.Unlock()
63+ c.conts++
64+}
65+
66+func (c *contStubCallback) Cancel() {}
67+
68+func (c *contStubCallback) count() int {
69+ c.mu.Lock()
70+ defer c.mu.Unlock()
71+ return c.conts
72+}
73+
74+// testManifestBytes builds a parseable manifest fixture pinning dummy
75+// digests. Serving validates presence and format, not truth: digest truth
76+// is P3.3's job against real artifacts.
77+func testManifestBytes(t *testing.T) []byte {
78+ t.Helper()
79+ data, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
80+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
81+ "systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
82+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
83+ }}.Bytes()
84+ require.NoError(t, err)
85+ return data
86+}
87+
88+func wasmURL(t *testing.T) string {
89+ t.Helper()
90+ return "https://dumber.invalid/systemviews.wasm"
91+}
92+
93+// awaitWASMHandler drives one deferred WASM response to completion on the
94+// test goroutine. Open must return promptly even while the decode is
95+// blocked: that prompt return is the P2.3 IO-thread guarantee.
96+func awaitWASMHandler(t *testing.T, rh purecef.ResourceHandler) *systemviewWASMResourceHandler {
97+ t.Helper()
98+ wh, ok := rh.(*systemviewWASMResourceHandler)
99+ require.True(t, ok, "expected deferred WASM handler, got %T", rh)
100+ opened := make(chan int32, 1)
101+ go func() { opened <- wh.Open(nil, nil, nil) }()
102+ select {
103+ case v := <-opened:
104+ require.Equal(t, int32(1), v)
105+ case <-time.After(10 * time.Second):
106+ t.Fatal("Open blocked: the CEF IO thread would stall")
107+ }
108+ select {
109+ case <-wh.done:
110+ case <-time.After(30 * time.Second):
111+ t.Fatal("WASM load did not complete")
112+ }
113+ return wh
114+}
115+
116+func TestAssetP2_HandlerServesWASMFromBundleOnce(t *testing.T) {
117+ stubIdentityResourceHandler(t)
118+ wasm := []byte("\x00asm-handler-once")
119+ fsys := &countingFS{inner: fstest.MapFS{
120+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
121+ }}
122+ h := newTestDumbSchemeHandler(t)
123+ h.setAssets(fsys)
124+
125+ for i := range 2 {
126+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
127+ require.Equal(t, http.StatusOK, wh.statusCode, "request %d", i)
128+ require.Equal(t, wasm, wh.data)
129+ }
130+ require.Equal(t, 1, fsys.compressedReads(),
131+ "handler must share the bundle decode across requests")
132+}
133+
134+func TestAssetP2_ReplacementDuringBlockedDecode(t *testing.T) {
135+ stubIdentityResourceHandler(t)
136+ wasmA := []byte("\x00asm-bundle-a")
137+ fsA := &blockingReadFileFS{
138+ inner: fstest.MapFS{
139+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmA)},
140+ },
141+ blockName: "systemviews/systemviews.wasm.br",
142+ entered: make(chan struct{}),
143+ release: make(chan struct{}),
144+ }
145+ h := newTestDumbSchemeHandler(t)
146+ h.setAssets(fsA)
147+
148+ u := mustParseURL(t, wasmURL(t))
149+ created := make(chan purecef.ResourceHandler, 1)
150+ go func() { created <- h.handleAsset(u) }()
151+
152+ var oldRH purecef.ResourceHandler
153+ select {
154+ case oldRH = <-created:
155+ case <-time.After(10 * time.Second):
156+ t.Fatal("Create blocked on decode: the CEF IO thread would stall")
157+ }
158+ oldWH, ok := oldRH.(*systemviewWASMResourceHandler)
159+ require.True(t, ok)
160+ opened := make(chan int32, 1)
161+ go func() { opened <- oldWH.Open(nil, nil, nil) }()
162+ select {
163+ case <-opened:
164+ case <-time.After(10 * time.Second):
165+ t.Fatal("Open blocked on decode")
166+ }
167+ <-fsA.entered
168+
169+ // Replace the bundle while A is still decoding: the in-flight request
170+ // must keep A's bytes and never publish into the new cache.
171+ wasmB := []byte("\x00asm-bundle-b")
172+ h.setAssets(fstest.MapFS{
173+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmB)},
174+ })
175+ close(fsA.release)
176+
177+ select {
178+ case <-oldWH.done:
179+ case <-time.After(30 * time.Second):
180+ t.Fatal("old load did not complete after release")
181+ }
182+ require.Equal(t, http.StatusOK, oldWH.statusCode)
183+ require.Equal(t, wasmA, oldWH.data, "in-flight request must retain the replaced bundle's bytes")
184+
185+ fresh := awaitWASMHandler(t, h.handleAsset(u))
186+ require.Equal(t, http.StatusOK, fresh.statusCode)
187+ require.Equal(t, wasmB, fresh.data, "post-replacement requests must serve the new bundle")
188+}
189+
190+func TestAssetP2_UnconfiguredAssetsServe500(t *testing.T) {
191+ stubIdentityResourceHandler(t)
192+ h := newTestDumbSchemeHandler(t)
193+
194+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
195+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
196+
197+ // Installing nil stays a clean 500, never a panic.
198+ h.setAssets(nil)
199+ rh = staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
200+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
201+}
202+
203+func TestAssetP2_NonWASMAssetsKeepLegacyPath(t *testing.T) {
204+ stubIdentityResourceHandler(t)
205+ h := newTestDumbSchemeHandler(t)
206+ h.setAssets(fstest.MapFS{
207+ "systemviews/index.html": {Data: []byte(`<html><script src="./wasm_exec.js"></script></html>`)},
208+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
209+ })
210+
211+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
212+ require.Equal(t, http.StatusOK, rh.statusCode)
213+ require.Contains(t, string(rh.data), "./wasm_exec.js?v="+strings.Repeat("c", 64))
214+}
215+
216+func TestAssetP23_ManifestReadableDuringBlockedDecode(t *testing.T) {
217+ stubIdentityResourceHandler(t)
218+ wasm := []byte("\x00asm-manifest-race")
219+ blocking := &blockingReadFileFS{
220+ inner: fstest.MapFS{
221+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
222+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
223+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
224+ },
225+ blockName: "systemviews/systemviews.wasm.br",
226+ entered: make(chan struct{}),
227+ release: make(chan struct{}),
228+ }
229+ h := newTestDumbSchemeHandler(t)
230+ h.setAssets(blocking)
231+
232+ // Start a wasm decode and leave it blocked off-thread.
233+ firstWH := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
234+ require.Equal(t, int32(1), firstWH.Open(nil, nil, nil))
235+ <-blocking.entered
236+
237+ // The shell consults the manifest synchronously on the request path:
238+ // it must serve promptly instead of deadlocking behind the decode.
239+ served := make(chan purecef.ResourceHandler, 1)
240+ go func() { served <- h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")) }()
241+ select {
242+ case rh := <-served:
243+ shell := staticHandlerOf(t, rh)
244+ require.Equal(t, http.StatusOK, shell.statusCode)
245+ require.Contains(t, string(shell.data), "?v=")
246+ case <-time.After(10 * time.Second):
247+ t.Fatal("shell deadlocked behind in-flight decode")
248+ }
249+
250+ close(blocking.release)
251+ select {
252+ case <-firstWH.done:
253+ case <-time.After(30 * time.Second):
254+ t.Fatal("WASM load did not complete after release")
255+ }
256+ require.Equal(t, wasm, firstWH.data)
257+}
258+
259+func TestAssetP23_BlockedDecodeLeavesUnrelatedWorkUnaffected(t *testing.T) {
260+ stubIdentityResourceHandler(t)
261+ wasm := []byte("\x00asm-unrelated")
262+ blocking := &blockingReadFileFS{
263+ inner: fstest.MapFS{
264+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
265+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
266+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
267+ },
268+ blockName: "systemviews/systemviews.wasm.br",
269+ entered: make(chan struct{}),
270+ release: make(chan struct{}),
271+ }
272+ h := newTestDumbSchemeHandler(t)
273+ h.setAssets(blocking)
274+
275+ // Shell traffic before any WASM request must not decode.
276+ shell := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
277+ require.Equal(t, http.StatusOK, shell.statusCode)
278+
279+ u := mustParseURL(t, wasmURL(t))
280+ created := make(chan purecef.ResourceHandler, 1)
281+ go func() { created <- h.handleAsset(u) }()
282+ var firstRH purecef.ResourceHandler
283+ select {
284+ case firstRH = <-created:
285+ case <-time.After(10 * time.Second):
286+ t.Fatal("Create blocked on decode")
287+ }
288+ firstWH := firstRH.(*systemviewWASMResourceHandler)
289+ opened := make(chan int32, 1)
290+ go func() { opened <- firstWH.Open(nil, nil, nil) }()
291+ select {
292+ case <-opened:
293+ case <-time.After(10 * time.Second):
294+ t.Fatal("Open blocked on decode")
295+ }
296+ <-blocking.entered
297+
298+ // A second waiter joins the same in-flight decode while an unrelated
299+ // API request completes without touching the decoder.
300+ secondWH := h.handleAsset(u).(*systemviewWASMResourceHandler)
301+ require.Equal(t, int32(1), secondWH.Open(nil, nil, nil))
302+ apiRH := staticHandlerOf(t, h.handleAPI(nil, http.MethodOptions, "/api/message", nil))
303+ require.Equal(t, http.StatusNoContent, apiRH.statusCode)
304+
305+ select {
306+ case <-firstWH.done:
307+ t.Fatal("decode completed while still blocked")
308+ case <-time.After(100 * time.Millisecond):
309+ }
310+
311+ close(blocking.release)
312+ for _, wh := range []*systemviewWASMResourceHandler{firstWH, secondWH} {
313+ select {
314+ case <-wh.done:
315+ case <-time.After(30 * time.Second):
316+ t.Fatal("WASM load did not complete after release")
317+ }
318+ require.Equal(t, http.StatusOK, wh.statusCode)
319+ require.Equal(t, wasm, wh.data, "both waiters share the one decode")
320+ }
321+}
322+
323+func TestAssetP23_CancelSuppressesContinuation(t *testing.T) {
324+ stubIdentityResourceHandler(t)
325+ wasm := []byte("\x00asm-cancel")
326+ blocking := &blockingReadFileFS{
327+ inner: fstest.MapFS{
328+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
329+ },
330+ blockName: "systemviews/systemviews.wasm.br",
331+ entered: make(chan struct{}),
332+ release: make(chan struct{}),
333+ }
334+ h := newTestDumbSchemeHandler(t)
335+ h.setAssets(blocking)
336+
337+ cb := &contStubCallback{}
338+ wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
339+ require.Equal(t, int32(1), wh.Open(nil, nil, cb))
340+ <-blocking.entered
341+
342+ // Canceling one waiter suppresses only its own continuation; the shared
343+ // decode still completes for the bundle.
344+ wh.Cancel()
345+ close(blocking.release)
346+ select {
347+ case <-wh.done:
348+ case <-time.After(30 * time.Second):
349+ t.Fatal("canceled load did not finish")
350+ }
351+ require.Equal(t, http.StatusOK, wh.statusCode)
352+ require.Equal(t, wasm, wh.data)
353+ require.Zero(t, cb.count(), "canceled waiter must not resume CEF")
354+
355+ // A later waiter on the same bundle is served from the completed decode
356+ // and resumes normally.
357+ cb2 := &contStubCallback{}
358+ wh2 := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
359+ require.Equal(t, int32(1), wh2.Open(nil, nil, cb2))
360+ select {
361+ case <-wh2.done:
362+ case <-time.After(30 * time.Second):
363+ t.Fatal("post-cancel waiter did not complete")
364+ }
365+ require.Equal(t, wasm, wh2.data)
366+ require.Equal(t, 1, cb2.count(), "later waiter must resume CEF normally")
367+}
368+
369+func TestAssetP23_ShutdownCompletesSafely(t *testing.T) {
370+ stubIdentityResourceHandler(t)
371+ wasm := []byte("\x00asm-shutdown")
372+ blocking := &blockingReadFileFS{
373+ inner: fstest.MapFS{
374+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
375+ },
376+ blockName: "systemviews/systemviews.wasm.br",
377+ entered: make(chan struct{}),
378+ release: make(chan struct{}),
379+ }
380+ ctx, cancel := context.WithCancel(context.Background())
381+ h, err := newDumbSchemeHandler(ctx, nil, noopConfigPayload(), noopConfigPayload())
382+ require.NoError(t, err)
383+ h.setAssets(blocking)
384+
385+ wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
386+ cb := &contStubCallback{}
387+ require.Equal(t, int32(1), wh.Open(nil, nil, cb))
388+ <-blocking.entered
389+
390+ // Engine shutdown mid-decode: the bounded CPU decode still finishes
391+ // without hanging or panicking, but the native continuation is
392+ // suppressed because the request context died with the engine.
393+ cancel()
394+ close(blocking.release)
395+ select {
396+ case <-wh.done:
397+ case <-time.After(30 * time.Second):
398+ t.Fatal("load hung across shutdown")
399+ }
400+ require.Equal(t, http.StatusOK, wh.statusCode)
401+ require.Equal(t, wasm, wh.data)
402+ // done closes before the continuation decision; give the decision a
403+ // scheduling window, then prove Cont never fired.
404+ require.Never(t, func() bool { return cb.count() > 0 }, 500*time.Millisecond, 10*time.Millisecond,
405+ "shutdown must suppress the native continuation")
406+}
407+
408+func TestAssetP23_WireUnversionedCarriesNoStore(t *testing.T) {
409+ stubIdentityResourceHandler(t)
410+ wasm := []byte("\x00asm-wire")
411+ h := newTestDumbSchemeHandler(t)
412+ h.setAssets(fstest.MapFS{
413+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
414+ })
415+
416+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
417+
418+ response := cefmocks.NewMockResponse(t)
419+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
420+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
421+ response.EXPECT().SetMimeType("application/wasm").Once()
422+ // Unversioned successes stay explicitly noncacheable; no immutable
423+ // header may appear without a verified version.
424+ response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
425+ var responseLength int64
426+ wh.GetResponseHeaders(response, &responseLength, 0)
427+ require.Equal(t, int64(len(wasm)), responseLength)
428+}
429+
430+func TestAssetP23_WireFailureMatchesErrorPage(t *testing.T) {
431+ stubIdentityResourceHandler(t)
432+ h := newTestDumbSchemeHandler(t)
433+ h.setAssets(fstest.MapFS{
434+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
435+ })
436+
437+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
438+ require.Equal(t, http.StatusNotFound, wh.statusCode)
439+ require.Equal(t, errorPageBody(http.StatusNotFound, "Asset not found"), wh.data,
440+ "deferred failure must stay byte-identical to the synchronous error page")
441+
442+ response := cefmocks.NewMockResponse(t)
443+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
444+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
445+ response.EXPECT().SetMimeType("text/html").Once()
446+ response.EXPECT().SetCharset("utf-8").Once()
447+ var responseLength int64
448+ wh.GetResponseHeaders(response, &responseLength, 0)
449+ require.Equal(t, int64(len(wh.data)), responseLength)
450+}
451+
452+// wasmBenchFixture builds a 2 MiB pseudo-WASM payload; zeros keep brotli
453+// fast so benchmarks measure plumbing, not compression ratios.
454+func wasmBenchFixture() []byte {
455+ return bytes.Repeat([]byte("\x00asm-bench"), 256*1024)
456+}
457+
458+func brotliCompressForBench(b *testing.B, data []byte) []byte {
459+ b.Helper()
460+ var buf bytes.Buffer
461+ w := brotli.NewWriter(&buf)
462+ if _, err := w.Write(data); err != nil {
463+ b.Fatalf("brotli write: %v", err)
464+ }
465+ if err := w.Close(); err != nil {
466+ b.Fatalf("brotli close: %v", err)
467+ }
468+ return buf.Bytes()
469+}
470+
471+// BenchmarkSystemviewWASMFirstLoad measures one cold bundle decode; the
472+// cached benchmark below measures the shared path every request takes.
473+func BenchmarkSystemviewWASMFirstLoad(b *testing.B) {
474+ wasm := wasmBenchFixture()
475+ compressed := brotliCompressForBench(b, wasm)
476+ b.ResetTimer()
477+ for i := 0; i < b.N; i++ {
478+ b.StopTimer()
479+ bundle := newSystemviewAssetBundle(fstest.MapFS{
480+ "systemviews/systemviews.wasm.br": {Data: compressed},
481+ })
482+ b.StartTimer()
483+ data, err := bundle.WASM()
484+ if err != nil || len(data) != len(wasm) {
485+ b.Fatalf("load: %v", err)
486+ }
487+ }
488+}
489+
490+func BenchmarkSystemviewWASMCached(b *testing.B) {
491+ wasm := wasmBenchFixture()
492+ bundle := newSystemviewAssetBundle(fstest.MapFS{
493+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForBench(b, wasm)},
494+ })
495+ if _, err := bundle.WASM(); err != nil {
496+ b.Fatalf("warmup load: %v", err)
497+ }
498+ b.ResetTimer()
499+ for i := 0; i < b.N; i++ {
500+ data, err := bundle.WASM()
501+ if err != nil || len(data) != len(wasm) {
502+ b.Fatalf("load: %v", err)
503+ }
504+ }
505+}
@@ -0,0 +1,505 @@
1+package cef
2+
3+// P2.2/P2.3 tests: the handler serves the WASM from its captured bundle
4+// through a deferred handler that never blocks CEF's IO thread.
5+// Replacement installs a fresh isolated state; in-flight requests keep the
6+// old bytes; canceled waiters suppress only their own continuation.
7+
8+import (
9+ "bytes"
10+ "context"
11+ "io/fs"
12+ "net/http"
13+ "strings"
14+ "sync"
15+ "testing"
16+ "testing/fstest"
17+ "time"
18+
19+ "github.com/andybalholm/brotli"
20+ purecef "github.com/bnema/purego-cef/cef"
21+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
22+ "github.com/stretchr/testify/require"
23+
24+ "github.com/bnema/dumber/assets"
25+)
26+
27+// blockingReadFileFS blocks ReadFile of one name until release is closed,
28+// proving non-blocking Create and replacement isolation during a decode.
29+type blockingReadFileFS struct {
30+ inner fstest.MapFS
31+ blockName string
32+ entered chan struct{}
33+ release chan struct{}
34+ once sync.Once
35+}
36+
37+func (b *blockingReadFileFS) gate(name string) {
38+ if name == b.blockName {
39+ b.once.Do(func() { close(b.entered) })
40+ <-b.release
41+ }
42+}
43+
44+func (b *blockingReadFileFS) Open(name string) (fs.File, error) {
45+ b.gate(name)
46+ return b.inner.Open(name)
47+}
48+
49+func (b *blockingReadFileFS) ReadFile(name string) ([]byte, error) {
50+ b.gate(name)
51+ return b.inner.ReadFile(name)
52+}
53+
54+// contStubCallback counts native continuations without mock machinery.
55+type contStubCallback struct {
56+ mu sync.Mutex
57+ conts int
58+}
59+
60+func (c *contStubCallback) Cont() {
61+ c.mu.Lock()
62+ defer c.mu.Unlock()
63+ c.conts++
64+}
65+
66+func (c *contStubCallback) Cancel() {}
67+
68+func (c *contStubCallback) count() int {
69+ c.mu.Lock()
70+ defer c.mu.Unlock()
71+ return c.conts
72+}
73+
74+// testManifestBytes builds a parseable manifest fixture pinning dummy
75+// digests. Serving validates presence and format, not truth: digest truth
76+// is P3.3's job against real artifacts.
77+func testManifestBytes(t *testing.T) []byte {
78+ t.Helper()
79+ data, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
80+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
81+ "systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
82+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
83+ }}.Bytes()
84+ require.NoError(t, err)
85+ return data
86+}
87+
88+func wasmURL(t *testing.T) string {
89+ t.Helper()
90+ return "https://dumber.invalid/systemviews.wasm"
91+}
92+
93+// awaitWASMHandler drives one deferred WASM response to completion on the
94+// test goroutine. Open must return promptly even while the decode is
95+// blocked: that prompt return is the P2.3 IO-thread guarantee.
96+func awaitWASMHandler(t *testing.T, rh purecef.ResourceHandler) *systemviewWASMResourceHandler {
97+ t.Helper()
98+ wh, ok := rh.(*systemviewWASMResourceHandler)
99+ require.True(t, ok, "expected deferred WASM handler, got %T", rh)
100+ opened := make(chan int32, 1)
101+ go func() { opened <- wh.Open(nil, nil, nil) }()
102+ select {
103+ case v := <-opened:
104+ require.Equal(t, int32(1), v)
105+ case <-time.After(10 * time.Second):
106+ t.Fatal("Open blocked: the CEF IO thread would stall")
107+ }
108+ select {
109+ case <-wh.done:
110+ case <-time.After(30 * time.Second):
111+ t.Fatal("WASM load did not complete")
112+ }
113+ return wh
114+}
115+
116+func TestAssetP2_HandlerServesWASMFromBundleOnce(t *testing.T) {
117+ stubIdentityResourceHandler(t)
118+ wasm := []byte("\x00asm-handler-once")
119+ fsys := &countingFS{inner: fstest.MapFS{
120+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
121+ }}
122+ h := newTestDumbSchemeHandler(t)
123+ h.setAssets(fsys)
124+
125+ for i := range 2 {
126+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
127+ require.Equal(t, http.StatusOK, wh.statusCode, "request %d", i)
128+ require.Equal(t, wasm, wh.data)
129+ }
130+ require.Equal(t, 1, fsys.compressedReads(),
131+ "handler must share the bundle decode across requests")
132+}
133+
134+func TestAssetP2_ReplacementDuringBlockedDecode(t *testing.T) {
135+ stubIdentityResourceHandler(t)
136+ wasmA := []byte("\x00asm-bundle-a")
137+ fsA := &blockingReadFileFS{
138+ inner: fstest.MapFS{
139+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmA)},
140+ },
141+ blockName: "systemviews/systemviews.wasm.br",
142+ entered: make(chan struct{}),
143+ release: make(chan struct{}),
144+ }
145+ h := newTestDumbSchemeHandler(t)
146+ h.setAssets(fsA)
147+
148+ u := mustParseURL(t, wasmURL(t))
149+ created := make(chan purecef.ResourceHandler, 1)
150+ go func() { created <- h.handleAsset(u) }()
151+
152+ var oldRH purecef.ResourceHandler
153+ select {
154+ case oldRH = <-created:
155+ case <-time.After(10 * time.Second):
156+ t.Fatal("Create blocked on decode: the CEF IO thread would stall")
157+ }
158+ oldWH, ok := oldRH.(*systemviewWASMResourceHandler)
159+ require.True(t, ok)
160+ opened := make(chan int32, 1)
161+ go func() { opened <- oldWH.Open(nil, nil, nil) }()
162+ select {
163+ case <-opened:
164+ case <-time.After(10 * time.Second):
165+ t.Fatal("Open blocked on decode")
166+ }
167+ <-fsA.entered
168+
169+ // Replace the bundle while A is still decoding: the in-flight request
170+ // must keep A's bytes and never publish into the new cache.
171+ wasmB := []byte("\x00asm-bundle-b")
172+ h.setAssets(fstest.MapFS{
173+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasmB)},
174+ })
175+ close(fsA.release)
176+
177+ select {
178+ case <-oldWH.done:
179+ case <-time.After(30 * time.Second):
180+ t.Fatal("old load did not complete after release")
181+ }
182+ require.Equal(t, http.StatusOK, oldWH.statusCode)
183+ require.Equal(t, wasmA, oldWH.data, "in-flight request must retain the replaced bundle's bytes")
184+
185+ fresh := awaitWASMHandler(t, h.handleAsset(u))
186+ require.Equal(t, http.StatusOK, fresh.statusCode)
187+ require.Equal(t, wasmB, fresh.data, "post-replacement requests must serve the new bundle")
188+}
189+
190+func TestAssetP2_UnconfiguredAssetsServe500(t *testing.T) {
191+ stubIdentityResourceHandler(t)
192+ h := newTestDumbSchemeHandler(t)
193+
194+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
195+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
196+
197+ // Installing nil stays a clean 500, never a panic.
198+ h.setAssets(nil)
199+ rh = staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
200+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
201+}
202+
203+func TestAssetP2_NonWASMAssetsKeepLegacyPath(t *testing.T) {
204+ stubIdentityResourceHandler(t)
205+ h := newTestDumbSchemeHandler(t)
206+ h.setAssets(fstest.MapFS{
207+ "systemviews/index.html": {Data: []byte(`<html><script src="./wasm_exec.js"></script></html>`)},
208+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
209+ })
210+
211+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
212+ require.Equal(t, http.StatusOK, rh.statusCode)
213+ require.Contains(t, string(rh.data), "./wasm_exec.js?v="+strings.Repeat("c", 64))
214+}
215+
216+func TestAssetP23_ManifestReadableDuringBlockedDecode(t *testing.T) {
217+ stubIdentityResourceHandler(t)
218+ wasm := []byte("\x00asm-manifest-race")
219+ blocking := &blockingReadFileFS{
220+ inner: fstest.MapFS{
221+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
222+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
223+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
224+ },
225+ blockName: "systemviews/systemviews.wasm.br",
226+ entered: make(chan struct{}),
227+ release: make(chan struct{}),
228+ }
229+ h := newTestDumbSchemeHandler(t)
230+ h.setAssets(blocking)
231+
232+ // Start a wasm decode and leave it blocked off-thread.
233+ firstWH := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
234+ require.Equal(t, int32(1), firstWH.Open(nil, nil, nil))
235+ <-blocking.entered
236+
237+ // The shell consults the manifest synchronously on the request path:
238+ // it must serve promptly instead of deadlocking behind the decode.
239+ served := make(chan purecef.ResourceHandler, 1)
240+ go func() { served <- h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")) }()
241+ select {
242+ case rh := <-served:
243+ shell := staticHandlerOf(t, rh)
244+ require.Equal(t, http.StatusOK, shell.statusCode)
245+ require.Contains(t, string(shell.data), "?v=")
246+ case <-time.After(10 * time.Second):
247+ t.Fatal("shell deadlocked behind in-flight decode")
248+ }
249+
250+ close(blocking.release)
251+ select {
252+ case <-firstWH.done:
253+ case <-time.After(30 * time.Second):
254+ t.Fatal("WASM load did not complete after release")
255+ }
256+ require.Equal(t, wasm, firstWH.data)
257+}
258+
259+func TestAssetP23_BlockedDecodeLeavesUnrelatedWorkUnaffected(t *testing.T) {
260+ stubIdentityResourceHandler(t)
261+ wasm := []byte("\x00asm-unrelated")
262+ blocking := &blockingReadFileFS{
263+ inner: fstest.MapFS{
264+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
265+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
266+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
267+ },
268+ blockName: "systemviews/systemviews.wasm.br",
269+ entered: make(chan struct{}),
270+ release: make(chan struct{}),
271+ }
272+ h := newTestDumbSchemeHandler(t)
273+ h.setAssets(blocking)
274+
275+ // Shell traffic before any WASM request must not decode.
276+ shell := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
277+ require.Equal(t, http.StatusOK, shell.statusCode)
278+
279+ u := mustParseURL(t, wasmURL(t))
280+ created := make(chan purecef.ResourceHandler, 1)
281+ go func() { created <- h.handleAsset(u) }()
282+ var firstRH purecef.ResourceHandler
283+ select {
284+ case firstRH = <-created:
285+ case <-time.After(10 * time.Second):
286+ t.Fatal("Create blocked on decode")
287+ }
288+ firstWH := firstRH.(*systemviewWASMResourceHandler)
289+ opened := make(chan int32, 1)
290+ go func() { opened <- firstWH.Open(nil, nil, nil) }()
291+ select {
292+ case <-opened:
293+ case <-time.After(10 * time.Second):
294+ t.Fatal("Open blocked on decode")
295+ }
296+ <-blocking.entered
297+
298+ // A second waiter joins the same in-flight decode while an unrelated
299+ // API request completes without touching the decoder.
300+ secondWH := h.handleAsset(u).(*systemviewWASMResourceHandler)
301+ require.Equal(t, int32(1), secondWH.Open(nil, nil, nil))
302+ apiRH := staticHandlerOf(t, h.handleAPI(nil, http.MethodOptions, "/api/message", nil))
303+ require.Equal(t, http.StatusNoContent, apiRH.statusCode)
304+
305+ select {
306+ case <-firstWH.done:
307+ t.Fatal("decode completed while still blocked")
308+ case <-time.After(100 * time.Millisecond):
309+ }
310+
311+ close(blocking.release)
312+ for _, wh := range []*systemviewWASMResourceHandler{firstWH, secondWH} {
313+ select {
314+ case <-wh.done:
315+ case <-time.After(30 * time.Second):
316+ t.Fatal("WASM load did not complete after release")
317+ }
318+ require.Equal(t, http.StatusOK, wh.statusCode)
319+ require.Equal(t, wasm, wh.data, "both waiters share the one decode")
320+ }
321+}
322+
323+func TestAssetP23_CancelSuppressesContinuation(t *testing.T) {
324+ stubIdentityResourceHandler(t)
325+ wasm := []byte("\x00asm-cancel")
326+ blocking := &blockingReadFileFS{
327+ inner: fstest.MapFS{
328+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
329+ },
330+ blockName: "systemviews/systemviews.wasm.br",
331+ entered: make(chan struct{}),
332+ release: make(chan struct{}),
333+ }
334+ h := newTestDumbSchemeHandler(t)
335+ h.setAssets(blocking)
336+
337+ cb := &contStubCallback{}
338+ wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
339+ require.Equal(t, int32(1), wh.Open(nil, nil, cb))
340+ <-blocking.entered
341+
342+ // Canceling one waiter suppresses only its own continuation; the shared
343+ // decode still completes for the bundle.
344+ wh.Cancel()
345+ close(blocking.release)
346+ select {
347+ case <-wh.done:
348+ case <-time.After(30 * time.Second):
349+ t.Fatal("canceled load did not finish")
350+ }
351+ require.Equal(t, http.StatusOK, wh.statusCode)
352+ require.Equal(t, wasm, wh.data)
353+ require.Zero(t, cb.count(), "canceled waiter must not resume CEF")
354+
355+ // A later waiter on the same bundle is served from the completed decode
356+ // and resumes normally.
357+ cb2 := &contStubCallback{}
358+ wh2 := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
359+ require.Equal(t, int32(1), wh2.Open(nil, nil, cb2))
360+ select {
361+ case <-wh2.done:
362+ case <-time.After(30 * time.Second):
363+ t.Fatal("post-cancel waiter did not complete")
364+ }
365+ require.Equal(t, wasm, wh2.data)
366+ require.Equal(t, 1, cb2.count(), "later waiter must resume CEF normally")
367+}
368+
369+func TestAssetP23_ShutdownCompletesSafely(t *testing.T) {
370+ stubIdentityResourceHandler(t)
371+ wasm := []byte("\x00asm-shutdown")
372+ blocking := &blockingReadFileFS{
373+ inner: fstest.MapFS{
374+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
375+ },
376+ blockName: "systemviews/systemviews.wasm.br",
377+ entered: make(chan struct{}),
378+ release: make(chan struct{}),
379+ }
380+ ctx, cancel := context.WithCancel(context.Background())
381+ h, err := newDumbSchemeHandler(ctx, nil, noopConfigPayload(), noopConfigPayload())
382+ require.NoError(t, err)
383+ h.setAssets(blocking)
384+
385+ wh := h.handleAsset(mustParseURL(t, wasmURL(t))).(*systemviewWASMResourceHandler)
386+ cb := &contStubCallback{}
387+ require.Equal(t, int32(1), wh.Open(nil, nil, cb))
388+ <-blocking.entered
389+
390+ // Engine shutdown mid-decode: the bounded CPU decode still finishes
391+ // without hanging or panicking, but the native continuation is
392+ // suppressed because the request context died with the engine.
393+ cancel()
394+ close(blocking.release)
395+ select {
396+ case <-wh.done:
397+ case <-time.After(30 * time.Second):
398+ t.Fatal("load hung across shutdown")
399+ }
400+ require.Equal(t, http.StatusOK, wh.statusCode)
401+ require.Equal(t, wasm, wh.data)
402+ // done closes before the continuation decision; give the decision a
403+ // scheduling window, then prove Cont never fired.
404+ require.Never(t, func() bool { return cb.count() > 0 }, 500*time.Millisecond, 10*time.Millisecond,
405+ "shutdown must suppress the native continuation")
406+}
407+
408+func TestAssetP23_WireUnversionedCarriesNoStore(t *testing.T) {
409+ stubIdentityResourceHandler(t)
410+ wasm := []byte("\x00asm-wire")
411+ h := newTestDumbSchemeHandler(t)
412+ h.setAssets(fstest.MapFS{
413+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
414+ })
415+
416+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
417+
418+ response := cefmocks.NewMockResponse(t)
419+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
420+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
421+ response.EXPECT().SetMimeType("application/wasm").Once()
422+ // Unversioned successes stay explicitly noncacheable; no immutable
423+ // header may appear without a verified version.
424+ response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
425+ var responseLength int64
426+ wh.GetResponseHeaders(response, &responseLength, 0)
427+ require.Equal(t, int64(len(wasm)), responseLength)
428+}
429+
430+func TestAssetP23_WireFailureMatchesErrorPage(t *testing.T) {
431+ stubIdentityResourceHandler(t)
432+ h := newTestDumbSchemeHandler(t)
433+ h.setAssets(fstest.MapFS{
434+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
435+ })
436+
437+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t))))
438+ require.Equal(t, http.StatusNotFound, wh.statusCode)
439+ require.Equal(t, errorPageBody(http.StatusNotFound, "Asset not found"), wh.data,
440+ "deferred failure must stay byte-identical to the synchronous error page")
441+
442+ response := cefmocks.NewMockResponse(t)
443+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
444+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
445+ response.EXPECT().SetMimeType("text/html").Once()
446+ response.EXPECT().SetCharset("utf-8").Once()
447+ var responseLength int64
448+ wh.GetResponseHeaders(response, &responseLength, 0)
449+ require.Equal(t, int64(len(wh.data)), responseLength)
450+}
451+
452+// wasmBenchFixture builds a 2 MiB pseudo-WASM payload; zeros keep brotli
453+// fast so benchmarks measure plumbing, not compression ratios.
454+func wasmBenchFixture() []byte {
455+ return bytes.Repeat([]byte("\x00asm-bench"), 256*1024)
456+}
457+
458+func brotliCompressForBench(b *testing.B, data []byte) []byte {
459+ b.Helper()
460+ var buf bytes.Buffer
461+ w := brotli.NewWriter(&buf)
462+ if _, err := w.Write(data); err != nil {
463+ b.Fatalf("brotli write: %v", err)
464+ }
465+ if err := w.Close(); err != nil {
466+ b.Fatalf("brotli close: %v", err)
467+ }
468+ return buf.Bytes()
469+}
470+
471+// BenchmarkSystemviewWASMFirstLoad measures one cold bundle decode; the
472+// cached benchmark below measures the shared path every request takes.
473+func BenchmarkSystemviewWASMFirstLoad(b *testing.B) {
474+ wasm := wasmBenchFixture()
475+ compressed := brotliCompressForBench(b, wasm)
476+ b.ResetTimer()
477+ for i := 0; i < b.N; i++ {
478+ b.StopTimer()
479+ bundle := newSystemviewAssetBundle(fstest.MapFS{
480+ "systemviews/systemviews.wasm.br": {Data: compressed},
481+ })
482+ b.StartTimer()
483+ data, err := bundle.WASM()
484+ if err != nil || len(data) != len(wasm) {
485+ b.Fatalf("load: %v", err)
486+ }
487+ }
488+}
489+
490+func BenchmarkSystemviewWASMCached(b *testing.B) {
491+ wasm := wasmBenchFixture()
492+ bundle := newSystemviewAssetBundle(fstest.MapFS{
493+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForBench(b, wasm)},
494+ })
495+ if _, err := bundle.WASM(); err != nil {
496+ b.Fatalf("warmup load: %v", err)
497+ }
498+ b.ResetTimer()
499+ for i := 0; i < b.N; i++ {
500+ data, err := bundle.WASM()
501+ if err != nil || len(data) != len(wasm) {
502+ b.Fatalf("load: %v", err)
503+ }
504+ }
505+}
A internal/infrastructure/cef/scheme_handler_assets_p3_test.go +425 -0
@@ -0,0 +1,425 @@
1+package cef
2+
3+// P3.2 tests: the served shell carries content-versioned references from
4+// its own captured bundle while the on-disk shell keeps usable relative
5+// URLs. The shell itself stays noncacheable; an invalid manifest fails
6+// closed with a clear noncacheable error.
7+
8+import (
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "net/http"
12+ "strings"
13+ "testing"
14+ "testing/fstest"
15+
16+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
17+ "github.com/stretchr/testify/require"
18+
19+ "github.com/bnema/dumber/assets"
20+)
21+
22+const versionedShellFixture = `<html><head><link rel="stylesheet" href="./systemviews.css"><script src="./wasm_exec.js"></script></head><body><script>instantiateWasm(go, './systemviews.wasm')</script><img src="./other.png"></body></html>`
23+
24+func shellBundleFS(t *testing.T, shell string) fstest.MapFS {
25+ t.Helper()
26+ return fstest.MapFS{
27+ "systemviews/index.html": {Data: []byte(shell)},
28+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
29+ }
30+}
31+
32+func TestAssetP32_ShellRefsVersionedFromBundleManifest(t *testing.T) {
33+ stubIdentityResourceHandler(t)
34+ h := newTestDumbSchemeHandler(t)
35+ h.setAssets(shellBundleFS(t, versionedShellFixture))
36+
37+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
38+ require.Equal(t, http.StatusOK, rh.statusCode)
39+ require.Equal(t, "text/html; charset=utf-8", rh.contentType)
40+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
41+ "P3.3 policy: the shell itself stays noncacheable")
42+ served := string(rh.data)
43+ require.Contains(t, served, "./systemviews.wasm?v="+strings.Repeat("b", 64))
44+ require.Contains(t, served, "./wasm_exec.js?v="+strings.Repeat("c", 64))
45+ require.Contains(t, served, "./systemviews.css?v="+strings.Repeat("a", 64))
46+ require.Contains(t, served, `"./other.png"`, "unpinned references pass through untouched")
47+}
48+
49+func TestAssetP32_ShellVersionedAcrossRoutes(t *testing.T) {
50+ stubIdentityResourceHandler(t)
51+ h := newTestDumbSchemeHandler(t)
52+ h.setAssets(shellBundleFS(t, versionedShellFixture))
53+
54+ for _, raw := range []string{
55+ "https://dumber.invalid/history",
56+ "https://dumber.invalid/history/",
57+ "https://dumber.invalid/favorites",
58+ "https://dumber.invalid/favorites/",
59+ "https://dumber.invalid/config",
60+ "https://dumber.invalid/config/",
61+ "https://dumber.invalid/index.html",
62+ } {
63+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
64+ require.Equal(t, http.StatusOK, rh.statusCode, raw)
65+ require.Contains(t, string(rh.data), "?v=", "%s must serve the versioned shell", raw)
66+ }
67+
68+ // The bare domain root has no page mapping: existing routing rejects
69+ // it before the asset layer, unchanged by versioning.
70+ bare := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/")))
71+ require.Equal(t, http.StatusNotFound, bare.statusCode)
72+}
73+
74+func TestAssetP32_AlreadyVersionedRefsUntouched(t *testing.T) {
75+ out := versionShellRefs(
76+ []byte(`<script src="./wasm_exec.js?v=old"></script><script src="./wasm_exec.js"></script>`),
77+ assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
78+ "systemviews.css": {SHA256: "a", Size: 1},
79+ "systemviews.wasm": {SHA256: "b", Size: 2},
80+ "wasm_exec.js": {SHA256: "c", Size: 3},
81+ }},
82+ )
83+ require.NotContains(t, string(out), "?v=c?v=", "must not double-version")
84+ require.Contains(t, string(out), `"./wasm_exec.js?v=old"`, "pre-versioned reference wins")
85+}
86+
87+func TestAssetP32_InvalidManifestFailsClosedNoncacheable(t *testing.T) {
88+ stubIdentityResourceHandler(t)
89+ h := newTestDumbSchemeHandler(t)
90+ h.setAssets(fstest.MapFS{
91+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
92+ "systemviews/" + assets.ManifestName: {Data: []byte("not json")},
93+ })
94+
95+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
96+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
97+ require.Equal(t, "no-store", rh.headers["Cache-Control"], "manifest failure must be noncacheable")
98+ require.Contains(t, string(rh.data), "Asset manifest invalid")
99+}
100+
101+func TestAssetP32_MissingManifestFailsClosed(t *testing.T) {
102+ stubIdentityResourceHandler(t)
103+ h := newTestDumbSchemeHandler(t)
104+ h.setAssets(fstest.MapFS{
105+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
106+ })
107+
108+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
109+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
110+ require.Equal(t, "no-store", rh.headers["Cache-Control"])
111+}
112+
113+func TestAssetP32_ReplacementSwapsShellVersions(t *testing.T) {
114+ stubIdentityResourceHandler(t)
115+ digestA := strings.Repeat("a", 64)
116+ manifestA, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
117+ "systemviews.css": {SHA256: digestA, Size: 1},
118+ "systemviews.wasm": {SHA256: digestA, Size: 2},
119+ "wasm_exec.js": {SHA256: digestA, Size: 3},
120+ }}.Bytes()
121+ require.NoError(t, err)
122+ h := newTestDumbSchemeHandler(t)
123+ h.setAssets(fstest.MapFS{
124+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
125+ "systemviews/" + assets.ManifestName: {Data: manifestA},
126+ })
127+ u := mustParseURL(t, "https://dumber.invalid/history")
128+ require.Contains(t, string(staticHandlerOf(t, h.handleAsset(u)).data), "?v="+digestA)
129+
130+ digestB := strings.Repeat("b", 64)
131+ manifestB, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
132+ "systemviews.css": {SHA256: digestB, Size: 1},
133+ "systemviews.wasm": {SHA256: digestB, Size: 2},
134+ "wasm_exec.js": {SHA256: digestB, Size: 3},
135+ }}.Bytes()
136+ require.NoError(t, err)
137+ h.setAssets(fstest.MapFS{
138+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
139+ "systemviews/" + assets.ManifestName: {Data: manifestB},
140+ })
141+ served := string(staticHandlerOf(t, h.handleAsset(u)).data)
142+ require.Contains(t, served, "?v="+digestB)
143+ require.NotContains(t, served, "?v="+digestA, "shell versions follow the captured bundle")
144+}
145+
146+func TestAssetP32_OnDiskShellKeepsRelativeURLs(t *testing.T) {
147+ // The committed shell must stay directly usable by alternate shells:
148+ // concrete relative URLs, no template placeholders, no baked versions.
149+ data, err := assets.WebUIAssets.ReadFile("systemviews/index.html")
150+ require.NoError(t, err)
151+ shell := string(data)
152+ for _, ref := range []string{"./systemviews.wasm", "./wasm_exec.js", "./systemviews.css"} {
153+ require.Contains(t, shell, ref)
154+ require.NotContains(t, shell, ref+"?v=", "on-disk shell must not bake versions")
155+ }
156+ require.NotContains(t, shell, "{{", "no unresolved template placeholders")
157+}
158+
159+// ---------------------------------------------------------------------------
160+// P3.3: immutable headers bound to served bytes
161+// ---------------------------------------------------------------------------
162+
163+// trueManifestFS builds a bundle whose manifest pins the true digests of
164+// the given files, plus a compressed WASM agreeing with the raw bytes.
165+func trueManifestFS(t *testing.T, wasm, js, css []byte) fstest.MapFS {
166+ t.Helper()
167+ entries := make(map[string]assets.FileEntry, len(assets.ManifestFiles))
168+ for name, data := range map[string][]byte{
169+ "systemviews.wasm": wasm,
170+ "wasm_exec.js": js,
171+ "systemviews.css": css,
172+ } {
173+ sum := sha256.Sum256(data)
174+ entries[name] = assets.FileEntry{SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
175+ }
176+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: entries}.Bytes()
177+ require.NoError(t, err)
178+ return fstest.MapFS{
179+ "systemviews/systemviews.wasm": {Data: wasm},
180+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
181+ "systemviews/wasm_exec.js": {Data: js},
182+ "systemviews/systemviews.css": {Data: css},
183+ "systemviews/" + assets.ManifestName: {Data: manifest},
184+ }
185+}
186+
187+func digestOf(data []byte) string {
188+ sum := sha256.Sum256(data)
189+ return hex.EncodeToString(sum[:])
190+}
191+
192+func TestAssetP33_VersionedCSSServesImmutable(t *testing.T) {
193+ stubIdentityResourceHandler(t)
194+ css := []byte("/* immutable css */")
195+ h := newTestDumbSchemeHandler(t)
196+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
197+
198+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(css))))
199+ require.Equal(t, http.StatusOK, rh.statusCode)
200+ require.Equal(t, css, rh.data)
201+ require.Equal(t, immutableCacheControl, rh.headers["Cache-Control"])
202+}
203+
204+func TestAssetP33_UnversionedCSSServesNoStore(t *testing.T) {
205+ stubIdentityResourceHandler(t)
206+ css := []byte("/* plain css */")
207+ h := newTestDumbSchemeHandler(t)
208+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
209+
210+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css")))
211+ require.Equal(t, http.StatusOK, rh.statusCode)
212+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
213+ require.NotContains(t, rh.headers["Cache-Control"], "immutable")
214+}
215+
216+func TestAssetP33_WrongVersionRejectedNoncacheable(t *testing.T) {
217+ stubIdentityResourceHandler(t)
218+ css := []byte("/* real css */")
219+ h := newTestDumbSchemeHandler(t)
220+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
221+
222+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("0", 64))))
223+ require.Equal(t, http.StatusNotFound, rh.statusCode)
224+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
225+}
226+
227+func TestAssetP33_DuplicateAndEmptyVersionsRejected(t *testing.T) {
228+ stubIdentityResourceHandler(t)
229+ css := []byte("/* css */")
230+ fsys := trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css)
231+ h := newTestDumbSchemeHandler(t)
232+ h.setAssets(fsys)
233+ digest := digestOf(css)
234+
235+ for _, raw := range []string{
236+ "https://dumber.invalid/systemviews.css?v=" + digest + "&v=" + digest,
237+ "https://dumber.invalid/systemviews.css?v=",
238+ "https://dumber.invalid/systemviews.css?v=a&v=b",
239+ // Malformed escapes fail closed instead of degrading to
240+ // unversioned or single-version handling.
241+ "https://dumber.invalid/systemviews.css?v=" + digest + "&v=%ZZ",
242+ "https://dumber.invalid/systemviews.css?v=%ZZ",
243+ } {
244+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
245+ require.Equal(t, http.StatusNotFound, rh.statusCode, raw)
246+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
247+ }
248+
249+ // A malformed escape on an unrelated parameter is not a version:
250+ // the request serves unversioned and noncacheable.
251+ plain := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?x=%ZZ")))
252+ require.Equal(t, http.StatusOK, plain.statusCode)
253+ require.Equal(t, noStoreCacheControl, plain.headers["Cache-Control"])
254+}
255+
256+func TestAssetP33_StaleManifestRejectedNoncacheable(t *testing.T) {
257+ stubIdentityResourceHandler(t)
258+ // The manifest pins dummy digests while the files carry real bytes:
259+ // v matches the pin, but the served bytes do not. URL-to-manifest
260+ // equality alone must never earn immutable headers.
261+ h := newTestDumbSchemeHandler(t)
262+ h.setAssets(fstest.MapFS{
263+ "systemviews/systemviews.css": {Data: []byte("/* real css */")},
264+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm")},
265+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, []byte("\x00asm"))},
266+ "systemviews/wasm_exec.js": {Data: []byte("/* js */")},
267+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
268+ })
269+
270+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("a", 64))))
271+ require.Equal(t, http.StatusNotFound, rh.statusCode, "stale pin must fail closed")
272+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
273+}
274+
275+func TestAssetP33_MissingVersionedArtifactRejected(t *testing.T) {
276+ stubIdentityResourceHandler(t)
277+ h := newTestDumbSchemeHandler(t)
278+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), []byte("/* css */")))
279+
280+ // wasm_exec.js is pinned but absent from this bundle variant.
281+ bundle := fstest.MapFS{
282+ "systemviews/systemviews.css": {Data: []byte("/* css */")},
283+ }
284+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
285+ "systemviews.css": {SHA256: digestOf([]byte("/* css */")), Size: int64(len("/* css */"))},
286+ "systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
287+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
288+ }}.Bytes()
289+ require.NoError(t, err)
290+ bundle["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
291+ h.setAssets(bundle)
292+
293+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/wasm_exec.js?v="+strings.Repeat("c", 64))))
294+ require.Equal(t, http.StatusNotFound, rh.statusCode)
295+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
296+}
297+
298+func TestAssetP33_VersionedWASMServesImmutableOnWire(t *testing.T) {
299+ stubIdentityResourceHandler(t)
300+ wasm := []byte("\x00asm-wire-immutable")
301+ h := newTestDumbSchemeHandler(t)
302+ h.setAssets(trueManifestFS(t, wasm, []byte("/* js */"), []byte("/* css */")))
303+
304+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(wasm))))
305+ require.Equal(t, http.StatusOK, wh.statusCode)
306+ require.Equal(t, wasm, wh.data)
307+
308+ response := cefmocks.NewMockResponse(t)
309+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
310+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
311+ response.EXPECT().SetMimeType("application/wasm").Once()
312+ response.EXPECT().SetHeaderByName("Cache-Control", immutableCacheControl, int32(1)).Once()
313+ var responseLength int64
314+ wh.GetResponseHeaders(response, &responseLength, 0)
315+ require.Equal(t, int64(len(wasm)), responseLength)
316+}
317+
318+func TestAssetP33_WrongVersionWASMDecodesNothing(t *testing.T) {
319+ stubIdentityResourceHandler(t)
320+ wasm := []byte("\x00asm-no-decode")
321+ fsys := &countingFS{inner: fstest.MapFS{
322+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
323+ }}
324+ _ = fsys
325+ h := newTestDumbSchemeHandler(t)
326+ inner := fstest.MapFS{
327+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
328+ }
329+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
330+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
331+ "systemviews.wasm": {SHA256: digestOf(wasm), Size: int64(len(wasm))},
332+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
333+ }}.Bytes()
334+ require.NoError(t, err)
335+ inner["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
336+ counting := &countingFS{inner: inner}
337+ h.setAssets(counting)
338+
339+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+strings.Repeat("0", 64))))
340+ require.Equal(t, http.StatusNotFound, rh.statusCode)
341+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
342+ require.Zero(t, counting.compressedReads(), "rejected version must not start a decode")
343+}
344+
345+func TestAssetP33_StaleWASMBytesFailClosedOnWire(t *testing.T) {
346+ stubIdentityResourceHandler(t)
347+ wasm := []byte("\x00asm-stale-wire")
348+ other := []byte("\x00asm-other-wire")
349+ otherManifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
350+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
351+ "systemviews.wasm": {SHA256: digestOf(other), Size: int64(len(other))},
352+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
353+ }}.Bytes()
354+ require.NoError(t, err)
355+ h := newTestDumbSchemeHandler(t)
356+ h.setAssets(fstest.MapFS{
357+ "systemviews/systemviews.wasm": {Data: wasm},
358+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
359+ "systemviews/" + assets.ManifestName: {Data: otherManifest},
360+ })
361+
362+ // v matches the stale pin, but the decoded bytes hash differently.
363+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(other))))
364+ require.Equal(t, http.StatusNotFound, wh.statusCode)
365+
366+ response := cefmocks.NewMockResponse(t)
367+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
368+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
369+ response.EXPECT().SetMimeType("text/html").Once()
370+ response.EXPECT().SetCharset("utf-8").Once()
371+ response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
372+ var responseLength int64
373+ wh.GetResponseHeaders(response, &responseLength, 0)
374+ require.Equal(t, int64(len(wh.data)), responseLength)
375+}
376+
377+func TestAssetP33_ReplacementSwapsValidatedVersions(t *testing.T) {
378+ stubIdentityResourceHandler(t)
379+ cssA := []byte("/* css a */")
380+ h := newTestDumbSchemeHandler(t)
381+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssA))
382+ u := "https://dumber.invalid/systemviews.css?v=" + digestOf(cssA)
383+ require.Equal(t, immutableCacheControl, staticHandlerOf(t, h.handleAsset(mustParseURL(t, u))).headers["Cache-Control"])
384+
385+ cssB := []byte("/* css b */")
386+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssB))
387+ // The old version no longer validates against the captured bundle.
388+ old := staticHandlerOf(t, h.handleAsset(mustParseURL(t, u)))
389+ require.Equal(t, http.StatusNotFound, old.statusCode)
390+ require.Equal(t, noStoreCacheControl, old.headers["Cache-Control"])
391+ fresh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(cssB))))
392+ require.Equal(t, http.StatusOK, fresh.statusCode)
393+ require.Equal(t, immutableCacheControl, fresh.headers["Cache-Control"])
394+}
395+
396+func TestAssetP33_ManifestItselfServesNoStore(t *testing.T) {
397+ stubIdentityResourceHandler(t)
398+ css := []byte("/* css */")
399+ h := newTestDumbSchemeHandler(t)
400+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
401+
402+ // The manifest is servable but never allowlisted: it stays fresh and
403+ // noncacheable even with a version query.
404+ for _, raw := range []string{
405+ "https://dumber.invalid/asset-manifest.json",
406+ "https://dumber.invalid/asset-manifest.json?v=anything",
407+ } {
408+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
409+ require.Equal(t, http.StatusOK, rh.statusCode, raw)
410+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
411+ }
412+}
413+
414+func TestAssetP33_ShellVersionQueryIgnored(t *testing.T) {
415+ stubIdentityResourceHandler(t)
416+ h := newTestDumbSchemeHandler(t)
417+ h.setAssets(shellBundleFS(t, versionedShellFixture))
418+
419+ // The shell document is never immutable: a ?v= on the shell serves the
420+ // normal noncacheable shell instead of erroring or pinning.
421+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history?v=whatever")))
422+ require.Equal(t, http.StatusOK, rh.statusCode)
423+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
424+ require.Contains(t, string(rh.data), "?v=")
425+}
@@ -0,0 +1,425 @@
1+package cef
2+
3+// P3.2 tests: the served shell carries content-versioned references from
4+// its own captured bundle while the on-disk shell keeps usable relative
5+// URLs. The shell itself stays noncacheable; an invalid manifest fails
6+// closed with a clear noncacheable error.
7+
8+import (
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "net/http"
12+ "strings"
13+ "testing"
14+ "testing/fstest"
15+
16+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
17+ "github.com/stretchr/testify/require"
18+
19+ "github.com/bnema/dumber/assets"
20+)
21+
22+const versionedShellFixture = `<html><head><link rel="stylesheet" href="./systemviews.css"><script src="./wasm_exec.js"></script></head><body><script>instantiateWasm(go, './systemviews.wasm')</script><img src="./other.png"></body></html>`
23+
24+func shellBundleFS(t *testing.T, shell string) fstest.MapFS {
25+ t.Helper()
26+ return fstest.MapFS{
27+ "systemviews/index.html": {Data: []byte(shell)},
28+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
29+ }
30+}
31+
32+func TestAssetP32_ShellRefsVersionedFromBundleManifest(t *testing.T) {
33+ stubIdentityResourceHandler(t)
34+ h := newTestDumbSchemeHandler(t)
35+ h.setAssets(shellBundleFS(t, versionedShellFixture))
36+
37+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
38+ require.Equal(t, http.StatusOK, rh.statusCode)
39+ require.Equal(t, "text/html; charset=utf-8", rh.contentType)
40+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
41+ "P3.3 policy: the shell itself stays noncacheable")
42+ served := string(rh.data)
43+ require.Contains(t, served, "./systemviews.wasm?v="+strings.Repeat("b", 64))
44+ require.Contains(t, served, "./wasm_exec.js?v="+strings.Repeat("c", 64))
45+ require.Contains(t, served, "./systemviews.css?v="+strings.Repeat("a", 64))
46+ require.Contains(t, served, `"./other.png"`, "unpinned references pass through untouched")
47+}
48+
49+func TestAssetP32_ShellVersionedAcrossRoutes(t *testing.T) {
50+ stubIdentityResourceHandler(t)
51+ h := newTestDumbSchemeHandler(t)
52+ h.setAssets(shellBundleFS(t, versionedShellFixture))
53+
54+ for _, raw := range []string{
55+ "https://dumber.invalid/history",
56+ "https://dumber.invalid/history/",
57+ "https://dumber.invalid/favorites",
58+ "https://dumber.invalid/favorites/",
59+ "https://dumber.invalid/config",
60+ "https://dumber.invalid/config/",
61+ "https://dumber.invalid/index.html",
62+ } {
63+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
64+ require.Equal(t, http.StatusOK, rh.statusCode, raw)
65+ require.Contains(t, string(rh.data), "?v=", "%s must serve the versioned shell", raw)
66+ }
67+
68+ // The bare domain root has no page mapping: existing routing rejects
69+ // it before the asset layer, unchanged by versioning.
70+ bare := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/")))
71+ require.Equal(t, http.StatusNotFound, bare.statusCode)
72+}
73+
74+func TestAssetP32_AlreadyVersionedRefsUntouched(t *testing.T) {
75+ out := versionShellRefs(
76+ []byte(`<script src="./wasm_exec.js?v=old"></script><script src="./wasm_exec.js"></script>`),
77+ assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
78+ "systemviews.css": {SHA256: "a", Size: 1},
79+ "systemviews.wasm": {SHA256: "b", Size: 2},
80+ "wasm_exec.js": {SHA256: "c", Size: 3},
81+ }},
82+ )
83+ require.NotContains(t, string(out), "?v=c?v=", "must not double-version")
84+ require.Contains(t, string(out), `"./wasm_exec.js?v=old"`, "pre-versioned reference wins")
85+}
86+
87+func TestAssetP32_InvalidManifestFailsClosedNoncacheable(t *testing.T) {
88+ stubIdentityResourceHandler(t)
89+ h := newTestDumbSchemeHandler(t)
90+ h.setAssets(fstest.MapFS{
91+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
92+ "systemviews/" + assets.ManifestName: {Data: []byte("not json")},
93+ })
94+
95+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
96+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
97+ require.Equal(t, "no-store", rh.headers["Cache-Control"], "manifest failure must be noncacheable")
98+ require.Contains(t, string(rh.data), "Asset manifest invalid")
99+}
100+
101+func TestAssetP32_MissingManifestFailsClosed(t *testing.T) {
102+ stubIdentityResourceHandler(t)
103+ h := newTestDumbSchemeHandler(t)
104+ h.setAssets(fstest.MapFS{
105+ "systemviews/index.html": {Data: []byte("<html>shell</html>")},
106+ })
107+
108+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
109+ require.Equal(t, http.StatusInternalServerError, rh.statusCode)
110+ require.Equal(t, "no-store", rh.headers["Cache-Control"])
111+}
112+
113+func TestAssetP32_ReplacementSwapsShellVersions(t *testing.T) {
114+ stubIdentityResourceHandler(t)
115+ digestA := strings.Repeat("a", 64)
116+ manifestA, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
117+ "systemviews.css": {SHA256: digestA, Size: 1},
118+ "systemviews.wasm": {SHA256: digestA, Size: 2},
119+ "wasm_exec.js": {SHA256: digestA, Size: 3},
120+ }}.Bytes()
121+ require.NoError(t, err)
122+ h := newTestDumbSchemeHandler(t)
123+ h.setAssets(fstest.MapFS{
124+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
125+ "systemviews/" + assets.ManifestName: {Data: manifestA},
126+ })
127+ u := mustParseURL(t, "https://dumber.invalid/history")
128+ require.Contains(t, string(staticHandlerOf(t, h.handleAsset(u)).data), "?v="+digestA)
129+
130+ digestB := strings.Repeat("b", 64)
131+ manifestB, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
132+ "systemviews.css": {SHA256: digestB, Size: 1},
133+ "systemviews.wasm": {SHA256: digestB, Size: 2},
134+ "wasm_exec.js": {SHA256: digestB, Size: 3},
135+ }}.Bytes()
136+ require.NoError(t, err)
137+ h.setAssets(fstest.MapFS{
138+ "systemviews/index.html": {Data: []byte(versionedShellFixture)},
139+ "systemviews/" + assets.ManifestName: {Data: manifestB},
140+ })
141+ served := string(staticHandlerOf(t, h.handleAsset(u)).data)
142+ require.Contains(t, served, "?v="+digestB)
143+ require.NotContains(t, served, "?v="+digestA, "shell versions follow the captured bundle")
144+}
145+
146+func TestAssetP32_OnDiskShellKeepsRelativeURLs(t *testing.T) {
147+ // The committed shell must stay directly usable by alternate shells:
148+ // concrete relative URLs, no template placeholders, no baked versions.
149+ data, err := assets.WebUIAssets.ReadFile("systemviews/index.html")
150+ require.NoError(t, err)
151+ shell := string(data)
152+ for _, ref := range []string{"./systemviews.wasm", "./wasm_exec.js", "./systemviews.css"} {
153+ require.Contains(t, shell, ref)
154+ require.NotContains(t, shell, ref+"?v=", "on-disk shell must not bake versions")
155+ }
156+ require.NotContains(t, shell, "{{", "no unresolved template placeholders")
157+}
158+
159+// ---------------------------------------------------------------------------
160+// P3.3: immutable headers bound to served bytes
161+// ---------------------------------------------------------------------------
162+
163+// trueManifestFS builds a bundle whose manifest pins the true digests of
164+// the given files, plus a compressed WASM agreeing with the raw bytes.
165+func trueManifestFS(t *testing.T, wasm, js, css []byte) fstest.MapFS {
166+ t.Helper()
167+ entries := make(map[string]assets.FileEntry, len(assets.ManifestFiles))
168+ for name, data := range map[string][]byte{
169+ "systemviews.wasm": wasm,
170+ "wasm_exec.js": js,
171+ "systemviews.css": css,
172+ } {
173+ sum := sha256.Sum256(data)
174+ entries[name] = assets.FileEntry{SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
175+ }
176+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: entries}.Bytes()
177+ require.NoError(t, err)
178+ return fstest.MapFS{
179+ "systemviews/systemviews.wasm": {Data: wasm},
180+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
181+ "systemviews/wasm_exec.js": {Data: js},
182+ "systemviews/systemviews.css": {Data: css},
183+ "systemviews/" + assets.ManifestName: {Data: manifest},
184+ }
185+}
186+
187+func digestOf(data []byte) string {
188+ sum := sha256.Sum256(data)
189+ return hex.EncodeToString(sum[:])
190+}
191+
192+func TestAssetP33_VersionedCSSServesImmutable(t *testing.T) {
193+ stubIdentityResourceHandler(t)
194+ css := []byte("/* immutable css */")
195+ h := newTestDumbSchemeHandler(t)
196+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
197+
198+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(css))))
199+ require.Equal(t, http.StatusOK, rh.statusCode)
200+ require.Equal(t, css, rh.data)
201+ require.Equal(t, immutableCacheControl, rh.headers["Cache-Control"])
202+}
203+
204+func TestAssetP33_UnversionedCSSServesNoStore(t *testing.T) {
205+ stubIdentityResourceHandler(t)
206+ css := []byte("/* plain css */")
207+ h := newTestDumbSchemeHandler(t)
208+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
209+
210+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css")))
211+ require.Equal(t, http.StatusOK, rh.statusCode)
212+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
213+ require.NotContains(t, rh.headers["Cache-Control"], "immutable")
214+}
215+
216+func TestAssetP33_WrongVersionRejectedNoncacheable(t *testing.T) {
217+ stubIdentityResourceHandler(t)
218+ css := []byte("/* real css */")
219+ h := newTestDumbSchemeHandler(t)
220+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
221+
222+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("0", 64))))
223+ require.Equal(t, http.StatusNotFound, rh.statusCode)
224+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
225+}
226+
227+func TestAssetP33_DuplicateAndEmptyVersionsRejected(t *testing.T) {
228+ stubIdentityResourceHandler(t)
229+ css := []byte("/* css */")
230+ fsys := trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css)
231+ h := newTestDumbSchemeHandler(t)
232+ h.setAssets(fsys)
233+ digest := digestOf(css)
234+
235+ for _, raw := range []string{
236+ "https://dumber.invalid/systemviews.css?v=" + digest + "&v=" + digest,
237+ "https://dumber.invalid/systemviews.css?v=",
238+ "https://dumber.invalid/systemviews.css?v=a&v=b",
239+ // Malformed escapes fail closed instead of degrading to
240+ // unversioned or single-version handling.
241+ "https://dumber.invalid/systemviews.css?v=" + digest + "&v=%ZZ",
242+ "https://dumber.invalid/systemviews.css?v=%ZZ",
243+ } {
244+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
245+ require.Equal(t, http.StatusNotFound, rh.statusCode, raw)
246+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
247+ }
248+
249+ // A malformed escape on an unrelated parameter is not a version:
250+ // the request serves unversioned and noncacheable.
251+ plain := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?x=%ZZ")))
252+ require.Equal(t, http.StatusOK, plain.statusCode)
253+ require.Equal(t, noStoreCacheControl, plain.headers["Cache-Control"])
254+}
255+
256+func TestAssetP33_StaleManifestRejectedNoncacheable(t *testing.T) {
257+ stubIdentityResourceHandler(t)
258+ // The manifest pins dummy digests while the files carry real bytes:
259+ // v matches the pin, but the served bytes do not. URL-to-manifest
260+ // equality alone must never earn immutable headers.
261+ h := newTestDumbSchemeHandler(t)
262+ h.setAssets(fstest.MapFS{
263+ "systemviews/systemviews.css": {Data: []byte("/* real css */")},
264+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm")},
265+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, []byte("\x00asm"))},
266+ "systemviews/wasm_exec.js": {Data: []byte("/* js */")},
267+ "systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
268+ })
269+
270+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("a", 64))))
271+ require.Equal(t, http.StatusNotFound, rh.statusCode, "stale pin must fail closed")
272+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
273+}
274+
275+func TestAssetP33_MissingVersionedArtifactRejected(t *testing.T) {
276+ stubIdentityResourceHandler(t)
277+ h := newTestDumbSchemeHandler(t)
278+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), []byte("/* css */")))
279+
280+ // wasm_exec.js is pinned but absent from this bundle variant.
281+ bundle := fstest.MapFS{
282+ "systemviews/systemviews.css": {Data: []byte("/* css */")},
283+ }
284+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
285+ "systemviews.css": {SHA256: digestOf([]byte("/* css */")), Size: int64(len("/* css */"))},
286+ "systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
287+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
288+ }}.Bytes()
289+ require.NoError(t, err)
290+ bundle["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
291+ h.setAssets(bundle)
292+
293+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/wasm_exec.js?v="+strings.Repeat("c", 64))))
294+ require.Equal(t, http.StatusNotFound, rh.statusCode)
295+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
296+}
297+
298+func TestAssetP33_VersionedWASMServesImmutableOnWire(t *testing.T) {
299+ stubIdentityResourceHandler(t)
300+ wasm := []byte("\x00asm-wire-immutable")
301+ h := newTestDumbSchemeHandler(t)
302+ h.setAssets(trueManifestFS(t, wasm, []byte("/* js */"), []byte("/* css */")))
303+
304+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(wasm))))
305+ require.Equal(t, http.StatusOK, wh.statusCode)
306+ require.Equal(t, wasm, wh.data)
307+
308+ response := cefmocks.NewMockResponse(t)
309+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
310+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
311+ response.EXPECT().SetMimeType("application/wasm").Once()
312+ response.EXPECT().SetHeaderByName("Cache-Control", immutableCacheControl, int32(1)).Once()
313+ var responseLength int64
314+ wh.GetResponseHeaders(response, &responseLength, 0)
315+ require.Equal(t, int64(len(wasm)), responseLength)
316+}
317+
318+func TestAssetP33_WrongVersionWASMDecodesNothing(t *testing.T) {
319+ stubIdentityResourceHandler(t)
320+ wasm := []byte("\x00asm-no-decode")
321+ fsys := &countingFS{inner: fstest.MapFS{
322+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
323+ }}
324+ _ = fsys
325+ h := newTestDumbSchemeHandler(t)
326+ inner := fstest.MapFS{
327+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
328+ }
329+ manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
330+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
331+ "systemviews.wasm": {SHA256: digestOf(wasm), Size: int64(len(wasm))},
332+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
333+ }}.Bytes()
334+ require.NoError(t, err)
335+ inner["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
336+ counting := &countingFS{inner: inner}
337+ h.setAssets(counting)
338+
339+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+strings.Repeat("0", 64))))
340+ require.Equal(t, http.StatusNotFound, rh.statusCode)
341+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
342+ require.Zero(t, counting.compressedReads(), "rejected version must not start a decode")
343+}
344+
345+func TestAssetP33_StaleWASMBytesFailClosedOnWire(t *testing.T) {
346+ stubIdentityResourceHandler(t)
347+ wasm := []byte("\x00asm-stale-wire")
348+ other := []byte("\x00asm-other-wire")
349+ otherManifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
350+ "systemviews.css": {SHA256: strings.Repeat("a", 64), Size: 1},
351+ "systemviews.wasm": {SHA256: digestOf(other), Size: int64(len(other))},
352+ "wasm_exec.js": {SHA256: strings.Repeat("c", 64), Size: 3},
353+ }}.Bytes()
354+ require.NoError(t, err)
355+ h := newTestDumbSchemeHandler(t)
356+ h.setAssets(fstest.MapFS{
357+ "systemviews/systemviews.wasm": {Data: wasm},
358+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
359+ "systemviews/" + assets.ManifestName: {Data: otherManifest},
360+ })
361+
362+ // v matches the stale pin, but the decoded bytes hash differently.
363+ wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(other))))
364+ require.Equal(t, http.StatusNotFound, wh.statusCode)
365+
366+ response := cefmocks.NewMockResponse(t)
367+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
368+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
369+ response.EXPECT().SetMimeType("text/html").Once()
370+ response.EXPECT().SetCharset("utf-8").Once()
371+ response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
372+ var responseLength int64
373+ wh.GetResponseHeaders(response, &responseLength, 0)
374+ require.Equal(t, int64(len(wh.data)), responseLength)
375+}
376+
377+func TestAssetP33_ReplacementSwapsValidatedVersions(t *testing.T) {
378+ stubIdentityResourceHandler(t)
379+ cssA := []byte("/* css a */")
380+ h := newTestDumbSchemeHandler(t)
381+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssA))
382+ u := "https://dumber.invalid/systemviews.css?v=" + digestOf(cssA)
383+ require.Equal(t, immutableCacheControl, staticHandlerOf(t, h.handleAsset(mustParseURL(t, u))).headers["Cache-Control"])
384+
385+ cssB := []byte("/* css b */")
386+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssB))
387+ // The old version no longer validates against the captured bundle.
388+ old := staticHandlerOf(t, h.handleAsset(mustParseURL(t, u)))
389+ require.Equal(t, http.StatusNotFound, old.statusCode)
390+ require.Equal(t, noStoreCacheControl, old.headers["Cache-Control"])
391+ fresh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(cssB))))
392+ require.Equal(t, http.StatusOK, fresh.statusCode)
393+ require.Equal(t, immutableCacheControl, fresh.headers["Cache-Control"])
394+}
395+
396+func TestAssetP33_ManifestItselfServesNoStore(t *testing.T) {
397+ stubIdentityResourceHandler(t)
398+ css := []byte("/* css */")
399+ h := newTestDumbSchemeHandler(t)
400+ h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
401+
402+ // The manifest is servable but never allowlisted: it stays fresh and
403+ // noncacheable even with a version query.
404+ for _, raw := range []string{
405+ "https://dumber.invalid/asset-manifest.json",
406+ "https://dumber.invalid/asset-manifest.json?v=anything",
407+ } {
408+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
409+ require.Equal(t, http.StatusOK, rh.statusCode, raw)
410+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
411+ }
412+}
413+
414+func TestAssetP33_ShellVersionQueryIgnored(t *testing.T) {
415+ stubIdentityResourceHandler(t)
416+ h := newTestDumbSchemeHandler(t)
417+ h.setAssets(shellBundleFS(t, versionedShellFixture))
418+
419+ // The shell document is never immutable: a ?v= on the shell serves the
420+ // normal noncacheable shell instead of erroring or pinning.
421+ rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history?v=whatever")))
422+ require.Equal(t, http.StatusOK, rh.statusCode)
423+ require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
424+ require.Contains(t, string(rh.data), "?v=")
425+}
A internal/infrastructure/cef/systemview_asset_cache.go +363 -0
@@ -0,0 +1,363 @@
1+package cef
2+
3+// Bundle-scoped immutable systemviews WASM cache (Plan 05 P2.1).
4+//
5+// Only the successfully decoded immutable systemviews WASM is cached, one
6+// entry per asset bundle. This is deliberately NOT a general web response
7+// cache: shell HTML, JS/CSS, API responses, and error pages are never
8+// cached here.
9+//
10+// Hardened load semantics (tighter than the legacy readAssetWithEncoding):
11+// - A present compressed artifact is authoritative. Corrupt or oversized
12+// output is an explicit error with no raw fallback.
13+// - The raw fallback applies ONLY when the compressed file is absent
14+// (errors.Is(err, fs.ErrNotExist)). Permission/I/O failures reading
15+// the compressed file are explicit errors even if valid raw data
16+// exists.
17+// - Raw reads are size-bounded before allocation, like the decompressed
18+// path.
19+// - The first outcome (success or failure) is cached for the bundle
20+// lifetime, so a corrupt bundle fails fast instead of storming
21+// retries. Production embeds are immutable, so a cached failure cannot
22+// go stale; bundle replacement installs a fresh cache (P2.2).
23+//
24+// Returned bytes are immutable: callers must not mutate them.
25+
26+import (
27+ "bytes"
28+ "context"
29+ "errors"
30+ "fmt"
31+ "io"
32+ "io/fs"
33+ "net/http"
34+ "sync"
35+ "unsafe"
36+
37+ "github.com/andybalholm/brotli"
38+ purecef "github.com/bnema/purego-cef/cef"
39+
40+ "github.com/bnema/dumber/assets"
41+)
42+
43+// systemviewsWASMPath is the fixed bundle-relative WASM served to internal
44+// pages. Only this file is cached; nothing else enters this cache.
45+const systemviewsWASMPath = "systemviews/systemviews.wasm"
46+
47+// systemviewAssetBundle is one immutable asset state: an asset filesystem
48+// plus its once-loaded WASM outcome. In-flight requests retain their
49+// captured bundle; installing a different bundle replaces the whole state.
50+// WASM bytes and manifest load under independent mutexes: a stalled decode
51+// must never stall manifest reads on the request path, since handleAsset
52+// consults the manifest synchronously while decodes run off-thread.
53+type systemviewAssetBundle struct {
54+ assets fs.FS
55+ wasmMu sync.Mutex
56+ loaded bool
57+ data []byte
58+ // digest is the hex SHA-256 of data, computed once at load: bundle
59+ // bytes are immutable, so versioned serving compares this cached
60+ // value instead of re-hashing megabytes per request.
61+ digest string
62+ err error
63+ // manifestLoaded caches the parsed content manifest alongside the
64+ // WASM: shell versioning and immutable headers always validate
65+ // against the same captured bundle state, never a newer install.
66+ manifestMu sync.Mutex
67+ manifestLoaded bool
68+ manifest assets.Manifest
69+ manifestErr error
70+}
71+
72+// newSystemviewAssetBundle captures one immutable asset filesystem. A nil
73+// FS is rejected at load time with a plain error, never a panic.
74+func newSystemviewAssetBundle(assetsFS fs.FS) *systemviewAssetBundle {
75+ return &systemviewAssetBundle{assets: assetsFS}
76+}
77+
78+// WASM returns the bundle's decoded WASM, loading it exactly once. Success
79+// and failure are both cached: repeated callers share the first outcome
80+// without re-reading or re-decoding.
81+func (b *systemviewAssetBundle) WASM() ([]byte, error) {
82+ b.wasmMu.Lock()
83+ defer b.wasmMu.Unlock()
84+ if !b.loaded {
85+ b.data, b.err = loadSystemviewWASM(b.assets)
86+ if b.err == nil {
87+ b.digest = sha256Hex(b.data)
88+ }
89+ b.loaded = true
90+ }
91+ return b.data, b.err
92+}
93+
94+// WASMDigest returns the hex SHA-256 of the once-loaded WASM bytes.
95+// It shares the load cache with WASM, so digest comparison in the
96+// versioned serving path costs a mutex, not a megabytes hash.
97+func (b *systemviewAssetBundle) WASMDigest() (string, error) {
98+ if _, err := b.WASM(); err != nil {
99+ return "", err
100+ }
101+ b.wasmMu.Lock()
102+ defer b.wasmMu.Unlock()
103+ return b.digest, nil
104+}
105+
106+// Manifest returns the bundle's parsed content manifest, loading it
107+// exactly once. Success and failure are both cached like the WASM bytes:
108+// an invalid manifest fails closed for the bundle lifetime instead of
109+// flapping between error and unverified content.
110+func (b *systemviewAssetBundle) Manifest() (assets.Manifest, error) {
111+ b.manifestMu.Lock()
112+ defer b.manifestMu.Unlock()
113+ if !b.manifestLoaded {
114+ b.manifest, b.manifestErr = loadSystemviewManifest(b.assets)
115+ b.manifestLoaded = true
116+ }
117+ return b.manifest, b.manifestErr
118+}
119+
120+// loadSystemviewManifest reads and strictly validates the bundle manifest.
121+// A missing, corrupt, or stale manifest is a plain error: callers serve a
122+// clear noncacheable error, never unverified immutable content.
123+func loadSystemviewManifest(assetsFS fs.FS) (assets.Manifest, error) {
124+ if assetsFS == nil {
125+ return assets.Manifest{}, errors.New("systemview assets not configured")
126+ }
127+ data, err := fs.ReadFile(assetsFS, "systemviews/"+assets.ManifestName)
128+ if err != nil {
129+ return assets.Manifest{}, fmt.Errorf("read asset manifest: %w", err)
130+ }
131+ return assets.ParseManifest(data)
132+}
133+
134+// loadSystemviewWASM reads and decodes the bundle WASM with the hardened
135+// semantics documented above. It performs no caching itself.
136+func loadSystemviewWASM(assetsFS fs.FS) ([]byte, error) {
137+ if assetsFS == nil {
138+ return nil, errors.New("systemview assets not configured")
139+ }
140+ // Both the compressed input and the raw fallback are size-bounded
141+ // before allocation: legitimate bundles ship a few megabytes of
142+ // Brotli for up to 64 MiB of WASM, so anything larger is rejected
143+ // before it can exhaust memory.
144+ compressed, err := readBoundedAsset(assetsFS, systemviewsWASMPath+".br", maxSystemviewsWASMBytes)
145+ if err == nil {
146+ data, decodeErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
147+ if decodeErr != nil {
148+ return nil, fmt.Errorf("decompress systemview WASM: %w", decodeErr)
149+ }
150+ if len(data) > maxSystemviewsWASMBytes {
151+ return nil, fmt.Errorf("decompressed systemview WASM exceeds %d bytes", maxSystemviewsWASMBytes)
152+ }
153+ return data, nil
154+ }
155+ if !errors.Is(err, fs.ErrNotExist) {
156+ return nil, fmt.Errorf("read compressed systemview WASM: %w", err)
157+ }
158+ return readBoundedAsset(assetsFS, systemviewsWASMPath, maxSystemviewsWASMBytes)
159+}
160+
161+// readBoundedAsset reads one raw asset with maxBytes enforced before
162+// unbounded allocation. Missing files surface unwrapped so callers can
163+// match fs.ErrNotExist for fallback decisions.
164+func readBoundedAsset(assetsFS fs.FS, name string, maxBytes int) ([]byte, error) {
165+ f, err := assetsFS.Open(name)
166+ if err != nil {
167+ return nil, err
168+ }
169+ defer func() { _ = f.Close() }()
170+ data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
171+ if err != nil {
172+ return nil, fmt.Errorf("read systemview asset %s: %w", name, err)
173+ }
174+ if len(data) > maxBytes {
175+ return nil, fmt.Errorf("systemview asset %s exceeds %d bytes", name, maxBytes)
176+ }
177+ return data, nil
178+}
179+
180+// ---------------------------------------------------------------------------
181+// Deferred WASM resource handler (Plan 05 P2.3)
182+// ---------------------------------------------------------------------------
183+
184+// systemviewWASMResourceHandler serves one WASM request from a captured
185+// bundle without blocking CEF's IO thread, following the existing async
186+// favicon handler pattern: Open/ProcessRequest return immediately, the
187+// bundle-owned once-loading runs on a worker goroutine, and CEF is resumed
188+// via the retained callback. The decode itself is bounded CPU work (at most
189+// maxSystemviewsWASMBytes of brotli output), so no handler-wide lock is
190+// ever held across it and shutdown never waits on it beyond milliseconds.
191+// Canceling one request suppresses only its own continuation; other bundle
192+// waiters share the same decoded bytes unaffected. A versioned request pins
193+// exact bytes: only output hashing to wantDigest earns immutable headers.
194+type systemviewWASMResourceHandler struct {
195+ cancel context.CancelFunc
196+ cancelOnce sync.Once
197+ // ctx scopes the request to the handler lifetime: engine shutdown
198+ // cancels it, letting completion suppress the native continuation.
199+ // It is released after the continuation decision, never by load
200+ // itself, so shutdown stays observable at that point.
201+ ctx context.Context
202+ bundle *systemviewAssetBundle
203+ versioned bool
204+ wantDigest string
205+ once sync.Once
206+ // settleMu arbitrates the continuation race: exactly one of
207+ // request completion or cancellation wins, so a canceled waiter
208+ // never resumes CEF while a completed load always does.
209+ settleMu sync.Mutex
210+ settled bool
211+ done chan struct{}
212+ data []byte
213+ contentType string
214+ statusCode int
215+ offset int
216+}
217+
218+func newSystemviewWASMResourceHandler(
219+ ctx context.Context, bundle *systemviewAssetBundle, versioned bool, wantDigest string,
220+) purecef.ResourceHandler {
221+ if ctx == nil {
222+ ctx = context.Background()
223+ }
224+ reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // released after the continuation decision or by Cancel
225+ return cefNewResourceHandler(&systemviewWASMResourceHandler{
226+ cancel: cancel,
227+ ctx: reqCtx,
228+ bundle: bundle,
229+ versioned: versioned,
230+ wantDigest: wantDigest,
231+ done: make(chan struct{}),
232+ })
233+}
234+
235+func (rh *systemviewWASMResourceHandler) load() {
236+ defer close(rh.done)
237+ if rh.bundle == nil {
238+ rh.fail()
239+ return
240+ }
241+ data, err := rh.bundle.WASM()
242+ if err != nil {
243+ rh.fail()
244+ return
245+ }
246+ // A versioned request pins exact bytes: the bundle digest is computed
247+ // once at load over the immutable decoded cache, so this comparison
248+ // costs a mutex rather than a megabytes hash per request.
249+ if rh.versioned {
250+ digest, derr := rh.bundle.WASMDigest()
251+ if derr != nil || digest != rh.wantDigest {
252+ rh.fail()
253+ return
254+ }
255+ }
256+ rh.statusCode = http.StatusOK
257+ // MIME matches the synchronous asset path exactly: the bundle serves
258+ // the same validated systemviews.wasm bytes getMimeType classifies.
259+ rh.contentType = getMimeType(systemviewsWASMPath)
260+ rh.data = data
261+}
262+
263+// fail serves the same 404 error document as the synchronous error path,
264+// including its HTML content type.
265+func (rh *systemviewWASMResourceHandler) fail() {
266+ rh.statusCode = http.StatusNotFound
267+ rh.contentType = "text/html; charset=utf-8"
268+ rh.data = errorPageBody(http.StatusNotFound, "Asset not found")
269+}
270+
271+func (rh *systemviewWASMResourceHandler) start(callback purecef.Callback) {
272+ rh.once.Do(func() {
273+ go func() {
274+ rh.load()
275+ rh.settleMu.Lock()
276+ superseded := rh.settled || rh.ctx.Err() != nil
277+ rh.settled = true
278+ rh.settleMu.Unlock()
279+ rh.cancelRequest()
280+ if !superseded && callback != nil {
281+ callback.Cont()
282+ }
283+ }()
284+ })
285+}
286+
287+func (rh *systemviewWASMResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
288+ if handleRequest != nil {
289+ *handleRequest = 0
290+ }
291+ rh.start(callback)
292+ return 1
293+}
294+
295+func (rh *systemviewWASMResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
296+ rh.start(callback)
297+ return 1
298+}
299+
300+func (rh *systemviewWASMResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
301+ <-rh.done
302+ response.SetStatus(int32(rh.statusCode))
303+ if text := http.StatusText(rh.statusCode); text != "" {
304+ response.SetStatusText(text)
305+ }
306+ mimeType, charset := splitMimeCharset(rh.contentType)
307+ response.SetMimeType(mimeType)
308+ if charset != "" {
309+ response.SetCharset(charset)
310+ }
311+ // Only version-verified bytes earn immutable headers. Versioned
312+ // failures are noncacheable errors; unversioned successes stay
313+ // explicitly noncacheable; unversioned failures keep the legacy
314+ // headerless error shape.
315+ switch {
316+ case rh.statusCode == http.StatusOK && rh.versioned:
317+ response.SetHeaderByName("Cache-Control", immutableCacheControl, 1)
318+ case rh.versioned || rh.statusCode == http.StatusOK:
319+ response.SetHeaderByName("Cache-Control", noStoreCacheControl, 1)
320+ }
321+ if responseLength != nil {
322+ *responseLength = int64(len(rh.data))
323+ }
324+}
325+
326+func (rh *systemviewWASMResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
327+ return 0
328+}
329+
330+func (rh *systemviewWASMResourceHandler) Read(
331+ dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback,
332+) int32 {
333+ <-rh.done
334+ if rh.offset >= len(rh.data) {
335+ return 0
336+ }
337+ remaining := len(rh.data) - rh.offset
338+ toRead := min(int(bytesToRead), remaining)
339+ dst := unsafe.Slice((*byte)(dataOut), toRead)
340+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
341+ rh.offset += toRead
342+ if bytesRead != nil {
343+ *bytesRead = int32(toRead)
344+ }
345+ return 1
346+}
347+
348+func (rh *systemviewWASMResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
349+ return 0
350+}
351+
352+func (rh *systemviewWASMResourceHandler) Cancel() {
353+ rh.settleMu.Lock()
354+ rh.settled = true
355+ rh.settleMu.Unlock()
356+ rh.cancelRequest()
357+}
358+
359+func (rh *systemviewWASMResourceHandler) cancelRequest() {
360+ if rh.cancel != nil {
361+ rh.cancelOnce.Do(rh.cancel)
362+ }
363+}
@@ -0,0 +1,363 @@
1+package cef
2+
3+// Bundle-scoped immutable systemviews WASM cache (Plan 05 P2.1).
4+//
5+// Only the successfully decoded immutable systemviews WASM is cached, one
6+// entry per asset bundle. This is deliberately NOT a general web response
7+// cache: shell HTML, JS/CSS, API responses, and error pages are never
8+// cached here.
9+//
10+// Hardened load semantics (tighter than the legacy readAssetWithEncoding):
11+// - A present compressed artifact is authoritative. Corrupt or oversized
12+// output is an explicit error with no raw fallback.
13+// - The raw fallback applies ONLY when the compressed file is absent
14+// (errors.Is(err, fs.ErrNotExist)). Permission/I/O failures reading
15+// the compressed file are explicit errors even if valid raw data
16+// exists.
17+// - Raw reads are size-bounded before allocation, like the decompressed
18+// path.
19+// - The first outcome (success or failure) is cached for the bundle
20+// lifetime, so a corrupt bundle fails fast instead of storming
21+// retries. Production embeds are immutable, so a cached failure cannot
22+// go stale; bundle replacement installs a fresh cache (P2.2).
23+//
24+// Returned bytes are immutable: callers must not mutate them.
25+
26+import (
27+ "bytes"
28+ "context"
29+ "errors"
30+ "fmt"
31+ "io"
32+ "io/fs"
33+ "net/http"
34+ "sync"
35+ "unsafe"
36+
37+ "github.com/andybalholm/brotli"
38+ purecef "github.com/bnema/purego-cef/cef"
39+
40+ "github.com/bnema/dumber/assets"
41+)
42+
43+// systemviewsWASMPath is the fixed bundle-relative WASM served to internal
44+// pages. Only this file is cached; nothing else enters this cache.
45+const systemviewsWASMPath = "systemviews/systemviews.wasm"
46+
47+// systemviewAssetBundle is one immutable asset state: an asset filesystem
48+// plus its once-loaded WASM outcome. In-flight requests retain their
49+// captured bundle; installing a different bundle replaces the whole state.
50+// WASM bytes and manifest load under independent mutexes: a stalled decode
51+// must never stall manifest reads on the request path, since handleAsset
52+// consults the manifest synchronously while decodes run off-thread.
53+type systemviewAssetBundle struct {
54+ assets fs.FS
55+ wasmMu sync.Mutex
56+ loaded bool
57+ data []byte
58+ // digest is the hex SHA-256 of data, computed once at load: bundle
59+ // bytes are immutable, so versioned serving compares this cached
60+ // value instead of re-hashing megabytes per request.
61+ digest string
62+ err error
63+ // manifestLoaded caches the parsed content manifest alongside the
64+ // WASM: shell versioning and immutable headers always validate
65+ // against the same captured bundle state, never a newer install.
66+ manifestMu sync.Mutex
67+ manifestLoaded bool
68+ manifest assets.Manifest
69+ manifestErr error
70+}
71+
72+// newSystemviewAssetBundle captures one immutable asset filesystem. A nil
73+// FS is rejected at load time with a plain error, never a panic.
74+func newSystemviewAssetBundle(assetsFS fs.FS) *systemviewAssetBundle {
75+ return &systemviewAssetBundle{assets: assetsFS}
76+}
77+
78+// WASM returns the bundle's decoded WASM, loading it exactly once. Success
79+// and failure are both cached: repeated callers share the first outcome
80+// without re-reading or re-decoding.
81+func (b *systemviewAssetBundle) WASM() ([]byte, error) {
82+ b.wasmMu.Lock()
83+ defer b.wasmMu.Unlock()
84+ if !b.loaded {
85+ b.data, b.err = loadSystemviewWASM(b.assets)
86+ if b.err == nil {
87+ b.digest = sha256Hex(b.data)
88+ }
89+ b.loaded = true
90+ }
91+ return b.data, b.err
92+}
93+
94+// WASMDigest returns the hex SHA-256 of the once-loaded WASM bytes.
95+// It shares the load cache with WASM, so digest comparison in the
96+// versioned serving path costs a mutex, not a megabytes hash.
97+func (b *systemviewAssetBundle) WASMDigest() (string, error) {
98+ if _, err := b.WASM(); err != nil {
99+ return "", err
100+ }
101+ b.wasmMu.Lock()
102+ defer b.wasmMu.Unlock()
103+ return b.digest, nil
104+}
105+
106+// Manifest returns the bundle's parsed content manifest, loading it
107+// exactly once. Success and failure are both cached like the WASM bytes:
108+// an invalid manifest fails closed for the bundle lifetime instead of
109+// flapping between error and unverified content.
110+func (b *systemviewAssetBundle) Manifest() (assets.Manifest, error) {
111+ b.manifestMu.Lock()
112+ defer b.manifestMu.Unlock()
113+ if !b.manifestLoaded {
114+ b.manifest, b.manifestErr = loadSystemviewManifest(b.assets)
115+ b.manifestLoaded = true
116+ }
117+ return b.manifest, b.manifestErr
118+}
119+
120+// loadSystemviewManifest reads and strictly validates the bundle manifest.
121+// A missing, corrupt, or stale manifest is a plain error: callers serve a
122+// clear noncacheable error, never unverified immutable content.
123+func loadSystemviewManifest(assetsFS fs.FS) (assets.Manifest, error) {
124+ if assetsFS == nil {
125+ return assets.Manifest{}, errors.New("systemview assets not configured")
126+ }
127+ data, err := fs.ReadFile(assetsFS, "systemviews/"+assets.ManifestName)
128+ if err != nil {
129+ return assets.Manifest{}, fmt.Errorf("read asset manifest: %w", err)
130+ }
131+ return assets.ParseManifest(data)
132+}
133+
134+// loadSystemviewWASM reads and decodes the bundle WASM with the hardened
135+// semantics documented above. It performs no caching itself.
136+func loadSystemviewWASM(assetsFS fs.FS) ([]byte, error) {
137+ if assetsFS == nil {
138+ return nil, errors.New("systemview assets not configured")
139+ }
140+ // Both the compressed input and the raw fallback are size-bounded
141+ // before allocation: legitimate bundles ship a few megabytes of
142+ // Brotli for up to 64 MiB of WASM, so anything larger is rejected
143+ // before it can exhaust memory.
144+ compressed, err := readBoundedAsset(assetsFS, systemviewsWASMPath+".br", maxSystemviewsWASMBytes)
145+ if err == nil {
146+ data, decodeErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
147+ if decodeErr != nil {
148+ return nil, fmt.Errorf("decompress systemview WASM: %w", decodeErr)
149+ }
150+ if len(data) > maxSystemviewsWASMBytes {
151+ return nil, fmt.Errorf("decompressed systemview WASM exceeds %d bytes", maxSystemviewsWASMBytes)
152+ }
153+ return data, nil
154+ }
155+ if !errors.Is(err, fs.ErrNotExist) {
156+ return nil, fmt.Errorf("read compressed systemview WASM: %w", err)
157+ }
158+ return readBoundedAsset(assetsFS, systemviewsWASMPath, maxSystemviewsWASMBytes)
159+}
160+
161+// readBoundedAsset reads one raw asset with maxBytes enforced before
162+// unbounded allocation. Missing files surface unwrapped so callers can
163+// match fs.ErrNotExist for fallback decisions.
164+func readBoundedAsset(assetsFS fs.FS, name string, maxBytes int) ([]byte, error) {
165+ f, err := assetsFS.Open(name)
166+ if err != nil {
167+ return nil, err
168+ }
169+ defer func() { _ = f.Close() }()
170+ data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
171+ if err != nil {
172+ return nil, fmt.Errorf("read systemview asset %s: %w", name, err)
173+ }
174+ if len(data) > maxBytes {
175+ return nil, fmt.Errorf("systemview asset %s exceeds %d bytes", name, maxBytes)
176+ }
177+ return data, nil
178+}
179+
180+// ---------------------------------------------------------------------------
181+// Deferred WASM resource handler (Plan 05 P2.3)
182+// ---------------------------------------------------------------------------
183+
184+// systemviewWASMResourceHandler serves one WASM request from a captured
185+// bundle without blocking CEF's IO thread, following the existing async
186+// favicon handler pattern: Open/ProcessRequest return immediately, the
187+// bundle-owned once-loading runs on a worker goroutine, and CEF is resumed
188+// via the retained callback. The decode itself is bounded CPU work (at most
189+// maxSystemviewsWASMBytes of brotli output), so no handler-wide lock is
190+// ever held across it and shutdown never waits on it beyond milliseconds.
191+// Canceling one request suppresses only its own continuation; other bundle
192+// waiters share the same decoded bytes unaffected. A versioned request pins
193+// exact bytes: only output hashing to wantDigest earns immutable headers.
194+type systemviewWASMResourceHandler struct {
195+ cancel context.CancelFunc
196+ cancelOnce sync.Once
197+ // ctx scopes the request to the handler lifetime: engine shutdown
198+ // cancels it, letting completion suppress the native continuation.
199+ // It is released after the continuation decision, never by load
200+ // itself, so shutdown stays observable at that point.
201+ ctx context.Context
202+ bundle *systemviewAssetBundle
203+ versioned bool
204+ wantDigest string
205+ once sync.Once
206+ // settleMu arbitrates the continuation race: exactly one of
207+ // request completion or cancellation wins, so a canceled waiter
208+ // never resumes CEF while a completed load always does.
209+ settleMu sync.Mutex
210+ settled bool
211+ done chan struct{}
212+ data []byte
213+ contentType string
214+ statusCode int
215+ offset int
216+}
217+
218+func newSystemviewWASMResourceHandler(
219+ ctx context.Context, bundle *systemviewAssetBundle, versioned bool, wantDigest string,
220+) purecef.ResourceHandler {
221+ if ctx == nil {
222+ ctx = context.Background()
223+ }
224+ reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // released after the continuation decision or by Cancel
225+ return cefNewResourceHandler(&systemviewWASMResourceHandler{
226+ cancel: cancel,
227+ ctx: reqCtx,
228+ bundle: bundle,
229+ versioned: versioned,
230+ wantDigest: wantDigest,
231+ done: make(chan struct{}),
232+ })
233+}
234+
235+func (rh *systemviewWASMResourceHandler) load() {
236+ defer close(rh.done)
237+ if rh.bundle == nil {
238+ rh.fail()
239+ return
240+ }
241+ data, err := rh.bundle.WASM()
242+ if err != nil {
243+ rh.fail()
244+ return
245+ }
246+ // A versioned request pins exact bytes: the bundle digest is computed
247+ // once at load over the immutable decoded cache, so this comparison
248+ // costs a mutex rather than a megabytes hash per request.
249+ if rh.versioned {
250+ digest, derr := rh.bundle.WASMDigest()
251+ if derr != nil || digest != rh.wantDigest {
252+ rh.fail()
253+ return
254+ }
255+ }
256+ rh.statusCode = http.StatusOK
257+ // MIME matches the synchronous asset path exactly: the bundle serves
258+ // the same validated systemviews.wasm bytes getMimeType classifies.
259+ rh.contentType = getMimeType(systemviewsWASMPath)
260+ rh.data = data
261+}
262+
263+// fail serves the same 404 error document as the synchronous error path,
264+// including its HTML content type.
265+func (rh *systemviewWASMResourceHandler) fail() {
266+ rh.statusCode = http.StatusNotFound
267+ rh.contentType = "text/html; charset=utf-8"
268+ rh.data = errorPageBody(http.StatusNotFound, "Asset not found")
269+}
270+
271+func (rh *systemviewWASMResourceHandler) start(callback purecef.Callback) {
272+ rh.once.Do(func() {
273+ go func() {
274+ rh.load()
275+ rh.settleMu.Lock()
276+ superseded := rh.settled || rh.ctx.Err() != nil
277+ rh.settled = true
278+ rh.settleMu.Unlock()
279+ rh.cancelRequest()
280+ if !superseded && callback != nil {
281+ callback.Cont()
282+ }
283+ }()
284+ })
285+}
286+
287+func (rh *systemviewWASMResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
288+ if handleRequest != nil {
289+ *handleRequest = 0
290+ }
291+ rh.start(callback)
292+ return 1
293+}
294+
295+func (rh *systemviewWASMResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
296+ rh.start(callback)
297+ return 1
298+}
299+
300+func (rh *systemviewWASMResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
301+ <-rh.done
302+ response.SetStatus(int32(rh.statusCode))
303+ if text := http.StatusText(rh.statusCode); text != "" {
304+ response.SetStatusText(text)
305+ }
306+ mimeType, charset := splitMimeCharset(rh.contentType)
307+ response.SetMimeType(mimeType)
308+ if charset != "" {
309+ response.SetCharset(charset)
310+ }
311+ // Only version-verified bytes earn immutable headers. Versioned
312+ // failures are noncacheable errors; unversioned successes stay
313+ // explicitly noncacheable; unversioned failures keep the legacy
314+ // headerless error shape.
315+ switch {
316+ case rh.statusCode == http.StatusOK && rh.versioned:
317+ response.SetHeaderByName("Cache-Control", immutableCacheControl, 1)
318+ case rh.versioned || rh.statusCode == http.StatusOK:
319+ response.SetHeaderByName("Cache-Control", noStoreCacheControl, 1)
320+ }
321+ if responseLength != nil {
322+ *responseLength = int64(len(rh.data))
323+ }
324+}
325+
326+func (rh *systemviewWASMResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
327+ return 0
328+}
329+
330+func (rh *systemviewWASMResourceHandler) Read(
331+ dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback,
332+) int32 {
333+ <-rh.done
334+ if rh.offset >= len(rh.data) {
335+ return 0
336+ }
337+ remaining := len(rh.data) - rh.offset
338+ toRead := min(int(bytesToRead), remaining)
339+ dst := unsafe.Slice((*byte)(dataOut), toRead)
340+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
341+ rh.offset += toRead
342+ if bytesRead != nil {
343+ *bytesRead = int32(toRead)
344+ }
345+ return 1
346+}
347+
348+func (rh *systemviewWASMResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
349+ return 0
350+}
351+
352+func (rh *systemviewWASMResourceHandler) Cancel() {
353+ rh.settleMu.Lock()
354+ rh.settled = true
355+ rh.settleMu.Unlock()
356+ rh.cancelRequest()
357+}
358+
359+func (rh *systemviewWASMResourceHandler) cancelRequest() {
360+ if rh.cancel != nil {
361+ rh.cancelOnce.Do(rh.cancel)
362+ }
363+}
A internal/infrastructure/cef/systemview_asset_cache_test.go +224 -0
@@ -0,0 +1,224 @@
1+package cef
2+
3+// P2.1 tests: the bundle loads its WASM exactly once (success or failure),
4+// with the hardened semantics — compressed authoritative, raw fallback only
5+// on absence, raw reads bounded, failures cached.
6+
7+import (
8+ "errors"
9+ "io"
10+ "io/fs"
11+ "sync"
12+ "testing"
13+ "testing/fstest"
14+
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// countingFS counts Open and ReadFile calls per name. Note MapFS
19+// implements ReadFileFS, so fs.ReadFile bypasses Open: both entry points
20+// must be counted explicitly.
21+type countingFS struct {
22+ inner fstest.MapFS
23+ mu sync.Mutex
24+ opens map[string]int
25+}
26+
27+func (c *countingFS) record(name string) {
28+ c.mu.Lock()
29+ defer c.mu.Unlock()
30+ if c.opens == nil {
31+ c.opens = map[string]int{}
32+ }
33+ c.opens[name]++
34+}
35+
36+func (c *countingFS) Open(name string) (fs.File, error) {
37+ c.record("open:" + name)
38+ return c.inner.Open(name)
39+}
40+
41+func (c *countingFS) ReadFile(name string) ([]byte, error) {
42+ c.record("readfile:" + name)
43+ return c.inner.ReadFile(name)
44+}
45+
46+// compressedReads counts compressed WASM reads through either entry
47+// point: the loader may Open the file directly (bounded reads) or use
48+// the ReadFileFS shortcut, depending on the hardened path.
49+func (c *countingFS) compressedReads() int {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ name := systemviewsWASMPath + ".br"
53+ return c.opens["readfile:"+name] + c.opens["open:"+name]
54+}
55+
56+// denyFS fails every Open with a fixed error (e.g. permission denied).
57+type denyFS struct{ err error }
58+
59+func (d denyFS) Open(name string) (fs.File, error) {
60+ return nil, &fs.PathError{Op: "open", Path: name, Err: d.err}
61+}
62+
63+func TestSystemviewAssetCache_DecodesOnceUnderConcurrency(t *testing.T) {
64+ t.Parallel()
65+
66+ wasm := []byte("\x00asm-bundle-once")
67+ fsys := &countingFS{inner: fstest.MapFS{
68+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
69+ }}
70+ b := newSystemviewAssetBundle(fsys)
71+
72+ const readers = 8
73+ got := make([][]byte, readers)
74+ errs := make([]error, readers)
75+ var wg sync.WaitGroup
76+ for i := range got {
77+ wg.Add(1)
78+ go func(i int) {
79+ defer wg.Done()
80+ got[i], errs[i] = b.WASM()
81+ }(i)
82+ }
83+ wg.Wait()
84+ for i := range got {
85+ require.NoError(t, errs[i])
86+ require.Equal(t, wasm, got[i])
87+ require.Same(t, &got[0][0], &got[i][0], "waiters must share one immutable buffer")
88+ }
89+ require.Equal(t, 1, fsys.compressedReads(), "bundle must decode exactly once")
90+}
91+
92+func TestSystemviewAssetCache_CachesFailureWithoutRetryStorm(t *testing.T) {
93+ t.Parallel()
94+
95+ fsys := &countingFS{inner: fstest.MapFS{
96+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
97+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
98+ }}
99+ b := newSystemviewAssetBundle(fsys)
100+
101+ _, err := b.WASM()
102+ require.Error(t, err, "corrupt compressed WASM must fail even with valid raw present")
103+ firstReads := fsys.compressedReads()
104+ _, err = b.WASM()
105+ require.Error(t, err)
106+ require.Equal(t, firstReads, fsys.compressedReads(), "cached failure must not re-read")
107+}
108+
109+func TestSystemviewAssetCache_CompressedReadErrorIsExplicit(t *testing.T) {
110+ t.Parallel()
111+
112+ // Permission (or any non-absence) failures reading the compressed file
113+ // must not silently fall back to raw.
114+ b := newSystemviewAssetBundle(denyFS{err: fs.ErrPermission})
115+
116+ _, err := b.WASM()
117+ require.Error(t, err)
118+ require.ErrorIs(t, err, fs.ErrPermission, "must surface the read failure")
119+ require.NotErrorIs(t, err, fs.ErrNotExist)
120+}
121+
122+func TestSystemviewAssetCache_RawFallbackOnlyOnAbsence(t *testing.T) {
123+ t.Parallel()
124+
125+ raw := []byte("\x00asm-raw-fallback")
126+ b := newSystemviewAssetBundle(fstest.MapFS{
127+ "systemviews/systemviews.wasm": {Data: raw},
128+ })
129+
130+ data, err := b.WASM()
131+ require.NoError(t, err)
132+ require.Equal(t, raw, data)
133+}
134+
135+func TestSystemviewAssetCache_RawOversizedRejected(t *testing.T) {
136+ t.Parallel()
137+
138+ big := make([]byte, maxSystemviewsWASMBytes+1)
139+ b := newSystemviewAssetBundle(fstest.MapFS{
140+ "systemviews/systemviews.wasm": {Data: big},
141+ })
142+
143+ _, err := b.WASM()
144+ require.Error(t, err, "raw WASM past the bound must fail like the compressed path")
145+}
146+
147+func TestSystemviewAssetCache_MissingBothReportsNotExist(t *testing.T) {
148+ t.Parallel()
149+
150+ b := newSystemviewAssetBundle(fstest.MapFS{})
151+
152+ _, err := b.WASM()
153+ require.Error(t, err)
154+ require.ErrorIs(t, err, fs.ErrNotExist)
155+}
156+
157+func TestSystemviewAssetCache_NilFSFailsCleanly(t *testing.T) {
158+ t.Parallel()
159+
160+ b := newSystemviewAssetBundle(nil)
161+ _, err := b.WASM()
162+ require.Error(t, err)
163+}
164+
165+// zeroFile yields size bytes of zeros without allocating them, proving
166+// input bounds without a 64 MiB fixture in memory.
167+type zeroFile struct {
168+ remaining int64
169+}
170+
171+func (f *zeroFile) Read(p []byte) (int, error) {
172+ if f.remaining <= 0 {
173+ return 0, io.EOF
174+ }
175+ n := min(int64(len(p)), f.remaining)
176+ clear(p[:n])
177+ f.remaining -= n
178+ return int(n), nil
179+}
180+
181+func (f *zeroFile) Close() error { return nil }
182+
183+func (f *zeroFile) Stat() (fs.FileInfo, error) { return nil, errors.New("zeroFile has no stat") }
184+
185+// oversizedInputFS serves an unbounded-looking compressed file: the
186+// bundle must reject it on size before allocation.
187+type oversizedInputFS struct {
188+ fstest.MapFS
189+}
190+
191+func (o oversizedInputFS) Open(name string) (fs.File, error) {
192+ if name == systemviewsWASMPath+".br" {
193+ return &zeroFile{remaining: int64(maxSystemviewsWASMBytes) + 1}, nil
194+ }
195+ return o.MapFS.Open(name)
196+}
197+
198+func TestSystemviewAssetCache_OversizedCompressedInputRejected(t *testing.T) {
199+ t.Parallel()
200+
201+ b := newSystemviewAssetBundle(oversizedInputFS{fstest.MapFS{
202+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
203+ }})
204+ _, err := b.WASM()
205+ require.Error(t, err, "oversized compressed input must fail before allocation")
206+ require.Contains(t, err.Error(), "exceeds")
207+}
208+
209+func TestSystemviewAssetCache_DigestCachedAtLoad(t *testing.T) {
210+ t.Parallel()
211+
212+ wasm := []byte("\x00asm-digest")
213+ b := newSystemviewAssetBundle(fstest.MapFS{
214+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
215+ })
216+ digest, err := b.WASMDigest()
217+ require.NoError(t, err)
218+ require.Equal(t, sha256Hex(wasm), digest, "cached digest must match the decoded bytes")
219+
220+ // A failing bundle reports the load error instead of an empty digest.
221+ bad := newSystemviewAssetBundle(denyFS{err: fs.ErrPermission})
222+ _, err = bad.WASMDigest()
223+ require.Error(t, err)
224+}
@@ -0,0 +1,224 @@
1+package cef
2+
3+// P2.1 tests: the bundle loads its WASM exactly once (success or failure),
4+// with the hardened semantics — compressed authoritative, raw fallback only
5+// on absence, raw reads bounded, failures cached.
6+
7+import (
8+ "errors"
9+ "io"
10+ "io/fs"
11+ "sync"
12+ "testing"
13+ "testing/fstest"
14+
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// countingFS counts Open and ReadFile calls per name. Note MapFS
19+// implements ReadFileFS, so fs.ReadFile bypasses Open: both entry points
20+// must be counted explicitly.
21+type countingFS struct {
22+ inner fstest.MapFS
23+ mu sync.Mutex
24+ opens map[string]int
25+}
26+
27+func (c *countingFS) record(name string) {
28+ c.mu.Lock()
29+ defer c.mu.Unlock()
30+ if c.opens == nil {
31+ c.opens = map[string]int{}
32+ }
33+ c.opens[name]++
34+}
35+
36+func (c *countingFS) Open(name string) (fs.File, error) {
37+ c.record("open:" + name)
38+ return c.inner.Open(name)
39+}
40+
41+func (c *countingFS) ReadFile(name string) ([]byte, error) {
42+ c.record("readfile:" + name)
43+ return c.inner.ReadFile(name)
44+}
45+
46+// compressedReads counts compressed WASM reads through either entry
47+// point: the loader may Open the file directly (bounded reads) or use
48+// the ReadFileFS shortcut, depending on the hardened path.
49+func (c *countingFS) compressedReads() int {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ name := systemviewsWASMPath + ".br"
53+ return c.opens["readfile:"+name] + c.opens["open:"+name]
54+}
55+
56+// denyFS fails every Open with a fixed error (e.g. permission denied).
57+type denyFS struct{ err error }
58+
59+func (d denyFS) Open(name string) (fs.File, error) {
60+ return nil, &fs.PathError{Op: "open", Path: name, Err: d.err}
61+}
62+
63+func TestSystemviewAssetCache_DecodesOnceUnderConcurrency(t *testing.T) {
64+ t.Parallel()
65+
66+ wasm := []byte("\x00asm-bundle-once")
67+ fsys := &countingFS{inner: fstest.MapFS{
68+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
69+ }}
70+ b := newSystemviewAssetBundle(fsys)
71+
72+ const readers = 8
73+ got := make([][]byte, readers)
74+ errs := make([]error, readers)
75+ var wg sync.WaitGroup
76+ for i := range got {
77+ wg.Add(1)
78+ go func(i int) {
79+ defer wg.Done()
80+ got[i], errs[i] = b.WASM()
81+ }(i)
82+ }
83+ wg.Wait()
84+ for i := range got {
85+ require.NoError(t, errs[i])
86+ require.Equal(t, wasm, got[i])
87+ require.Same(t, &got[0][0], &got[i][0], "waiters must share one immutable buffer")
88+ }
89+ require.Equal(t, 1, fsys.compressedReads(), "bundle must decode exactly once")
90+}
91+
92+func TestSystemviewAssetCache_CachesFailureWithoutRetryStorm(t *testing.T) {
93+ t.Parallel()
94+
95+ fsys := &countingFS{inner: fstest.MapFS{
96+ "systemviews/systemviews.wasm.br": {Data: []byte("not-brotli")},
97+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
98+ }}
99+ b := newSystemviewAssetBundle(fsys)
100+
101+ _, err := b.WASM()
102+ require.Error(t, err, "corrupt compressed WASM must fail even with valid raw present")
103+ firstReads := fsys.compressedReads()
104+ _, err = b.WASM()
105+ require.Error(t, err)
106+ require.Equal(t, firstReads, fsys.compressedReads(), "cached failure must not re-read")
107+}
108+
109+func TestSystemviewAssetCache_CompressedReadErrorIsExplicit(t *testing.T) {
110+ t.Parallel()
111+
112+ // Permission (or any non-absence) failures reading the compressed file
113+ // must not silently fall back to raw.
114+ b := newSystemviewAssetBundle(denyFS{err: fs.ErrPermission})
115+
116+ _, err := b.WASM()
117+ require.Error(t, err)
118+ require.ErrorIs(t, err, fs.ErrPermission, "must surface the read failure")
119+ require.NotErrorIs(t, err, fs.ErrNotExist)
120+}
121+
122+func TestSystemviewAssetCache_RawFallbackOnlyOnAbsence(t *testing.T) {
123+ t.Parallel()
124+
125+ raw := []byte("\x00asm-raw-fallback")
126+ b := newSystemviewAssetBundle(fstest.MapFS{
127+ "systemviews/systemviews.wasm": {Data: raw},
128+ })
129+
130+ data, err := b.WASM()
131+ require.NoError(t, err)
132+ require.Equal(t, raw, data)
133+}
134+
135+func TestSystemviewAssetCache_RawOversizedRejected(t *testing.T) {
136+ t.Parallel()
137+
138+ big := make([]byte, maxSystemviewsWASMBytes+1)
139+ b := newSystemviewAssetBundle(fstest.MapFS{
140+ "systemviews/systemviews.wasm": {Data: big},
141+ })
142+
143+ _, err := b.WASM()
144+ require.Error(t, err, "raw WASM past the bound must fail like the compressed path")
145+}
146+
147+func TestSystemviewAssetCache_MissingBothReportsNotExist(t *testing.T) {
148+ t.Parallel()
149+
150+ b := newSystemviewAssetBundle(fstest.MapFS{})
151+
152+ _, err := b.WASM()
153+ require.Error(t, err)
154+ require.ErrorIs(t, err, fs.ErrNotExist)
155+}
156+
157+func TestSystemviewAssetCache_NilFSFailsCleanly(t *testing.T) {
158+ t.Parallel()
159+
160+ b := newSystemviewAssetBundle(nil)
161+ _, err := b.WASM()
162+ require.Error(t, err)
163+}
164+
165+// zeroFile yields size bytes of zeros without allocating them, proving
166+// input bounds without a 64 MiB fixture in memory.
167+type zeroFile struct {
168+ remaining int64
169+}
170+
171+func (f *zeroFile) Read(p []byte) (int, error) {
172+ if f.remaining <= 0 {
173+ return 0, io.EOF
174+ }
175+ n := min(int64(len(p)), f.remaining)
176+ clear(p[:n])
177+ f.remaining -= n
178+ return int(n), nil
179+}
180+
181+func (f *zeroFile) Close() error { return nil }
182+
183+func (f *zeroFile) Stat() (fs.FileInfo, error) { return nil, errors.New("zeroFile has no stat") }
184+
185+// oversizedInputFS serves an unbounded-looking compressed file: the
186+// bundle must reject it on size before allocation.
187+type oversizedInputFS struct {
188+ fstest.MapFS
189+}
190+
191+func (o oversizedInputFS) Open(name string) (fs.File, error) {
192+ if name == systemviewsWASMPath+".br" {
193+ return &zeroFile{remaining: int64(maxSystemviewsWASMBytes) + 1}, nil
194+ }
195+ return o.MapFS.Open(name)
196+}
197+
198+func TestSystemviewAssetCache_OversizedCompressedInputRejected(t *testing.T) {
199+ t.Parallel()
200+
201+ b := newSystemviewAssetBundle(oversizedInputFS{fstest.MapFS{
202+ "systemviews/systemviews.wasm": {Data: []byte("\x00asm-valid-raw")},
203+ }})
204+ _, err := b.WASM()
205+ require.Error(t, err, "oversized compressed input must fail before allocation")
206+ require.Contains(t, err.Error(), "exceeds")
207+}
208+
209+func TestSystemviewAssetCache_DigestCachedAtLoad(t *testing.T) {
210+ t.Parallel()
211+
212+ wasm := []byte("\x00asm-digest")
213+ b := newSystemviewAssetBundle(fstest.MapFS{
214+ "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
215+ })
216+ digest, err := b.WASMDigest()
217+ require.NoError(t, err)
218+ require.Equal(t, sha256Hex(wasm), digest, "cached digest must match the decoded bytes")
219+
220+ // A failing bundle reports the load error instead of an empty digest.
221+ bad := newSystemviewAssetBundle(denyFS{err: fs.ErrPermission})
222+ _, err = bad.WASMDigest()
223+ require.Error(t, err)
224+}
M internal/ui/systemviews/app.go +30 -0
@@ -114,19 +114,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
114114 }
115115
116116 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117+ a.signalShellError(err)
117118 return err
118119 }
119120 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120121 if err := a.bindDOMActions(ctx, binder); err != nil {
122+ a.signalShellError(err)
121123 return err
122124 }
123125 }
126+ // Readiness fires after the initial mount and action binding only:
127+ // async history-data completion refreshes content separately and
128+ // never gates it.
129+ if err := a.signalShellReady(); err != nil {
130+ return err
131+ }
124132 if asyncInitial {
125133 a.refreshCurrentRouteAsync(ctx)
126134 }
127135 return nil
128136 }
129137
138+// signalShellReady clears the shell loading state when the adapter
139+// supports it. Adapters without a signaler keep legacy behavior.
140+func (a *App) signalShellReady() error {
141+ signaler, ok := a.deps.DOM.(DOMReadinessSignaler)
142+ if !ok {
143+ return nil
144+ }
145+ return signaler.SignalReady()
146+}
147+
148+// signalShellError reports a fatal mount/bind failure to the shell when
149+// the adapter supports it. The original error is always returned;
150+// signaling is best-effort.
151+func (a *App) signalShellError(err error) {
152+ if err == nil {
153+ return
154+ }
155+ if signaler, ok := a.deps.DOM.(DOMReadinessSignaler); ok {
156+ _ = signaler.SignalError(err.Error())
157+ }
158+}
159+
130160 func (a *App) shouldLoadInitialRouteAsync() bool {
131161 return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132162 }
@@ -114,19 +114,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
114 }114 }
115 115
116 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {116 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117+ a.signalShellError(err)
117 return err118 return err
118 }119 }
119 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {120 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120 if err := a.bindDOMActions(ctx, binder); err != nil {121 if err := a.bindDOMActions(ctx, binder); err != nil {
122+ a.signalShellError(err)
121 return err123 return err
122 }124 }
123 }125 }
126+ // Readiness fires after the initial mount and action binding only:
127+ // async history-data completion refreshes content separately and
128+ // never gates it.
129+ if err := a.signalShellReady(); err != nil {
130+ return err
131+ }
124 if asyncInitial {132 if asyncInitial {
125 a.refreshCurrentRouteAsync(ctx)133 a.refreshCurrentRouteAsync(ctx)
126 }134 }
127 return nil135 return nil
128 }136 }
129 137
138+// signalShellReady clears the shell loading state when the adapter
139+// supports it. Adapters without a signaler keep legacy behavior.
140+func (a *App) signalShellReady() error {
141+ signaler, ok := a.deps.DOM.(DOMReadinessSignaler)
142+ if !ok {
143+ return nil
144+ }
145+ return signaler.SignalReady()
146+}
147+
148+// signalShellError reports a fatal mount/bind failure to the shell when
149+// the adapter supports it. The original error is always returned;
150+// signaling is best-effort.
151+func (a *App) signalShellError(err error) {
152+ if err == nil {
153+ return
154+ }
155+ if signaler, ok := a.deps.DOM.(DOMReadinessSignaler); ok {
156+ _ = signaler.SignalError(err.Error())
157+ }
158+}
159+
130 func (a *App) shouldLoadInitialRouteAsync() bool {160 func (a *App) shouldLoadInitialRouteAsync() bool {
131 return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil161 return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132 }162 }
M internal/ui/systemviews/dom.go +13 -0
@@ -24,6 +24,19 @@ type DOMActionBinder interface {
2424 BindActions(handler DOMActionHandler) error
2525 }
2626
27+// DOMReadinessSignaler is implemented by DOM adapters that can mark the
28+// loading shell ready or terminally failed. The App calls it after the
29+// initial mount and action binding complete; history-data completion is a
30+// separate concern and never gates readiness. Adapters without it keep
31+// legacy behavior, leaving shell state to the page script.
32+type DOMReadinessSignaler interface {
33+ // SignalReady clears the shell loading state after mount and binding.
34+ SignalReady() error
35+ // SignalError clears the shell loading state with a fatal message
36+ // when mount or binding fails.
37+ SignalError(message string) error
38+}
39+
2740 // DOMHistoryTimelineAppender is implemented by DOM adapters that can append
2841 // older history windows without reparsing and remounting the whole page.
2942 type DOMHistoryTimelineAppender interface {
@@ -24,6 +24,19 @@ type DOMActionBinder interface {
24 BindActions(handler DOMActionHandler) error24 BindActions(handler DOMActionHandler) error
25 }25 }
26 26
27+// DOMReadinessSignaler is implemented by DOM adapters that can mark the
28+// loading shell ready or terminally failed. The App calls it after the
29+// initial mount and action binding complete; history-data completion is a
30+// separate concern and never gates readiness. Adapters without it keep
31+// legacy behavior, leaving shell state to the page script.
32+type DOMReadinessSignaler interface {
33+ // SignalReady clears the shell loading state after mount and binding.
34+ SignalReady() error
35+ // SignalError clears the shell loading state with a fatal message
36+ // when mount or binding fails.
37+ SignalError(message string) error
38+}
39+
27 // DOMHistoryTimelineAppender is implemented by DOM adapters that can append40 // DOMHistoryTimelineAppender is implemented by DOM adapters that can append
28 // older history windows without reparsing and remounting the whole page.41 // older history windows without reparsing and remounting the whole page.
29 type DOMHistoryTimelineAppender interface {42 type DOMHistoryTimelineAppender interface {
M internal/ui/systemviews/dom_js.go +38 -0
@@ -48,6 +48,44 @@ func (d *browserDOM) Mount(markup string) error {
4848 return nil
4949 }
5050
51+// SignalReady marks the shell ready after mount and action binding: the
52+// loading busy flag is dropped and a data-ready marker is set for
53+// fixtures. Mount replaces #app content, so readiness must be signaled
54+// after every mount that completes binding.
55+func (d *browserDOM) SignalReady() error {
56+ if d == nil || !d.target.Truthy() {
57+ return fmt.Errorf("DOM readiness target #app not found")
58+ }
59+ d.target.Call("removeAttribute", "aria-busy")
60+ d.target.Call("setAttribute", "data-ready", "true")
61+ return nil
62+}
63+
64+// SignalError clears the loading state with a fatal message when mount or
65+// binding fails. textContent assignment keeps the message inert.
66+func (d *browserDOM) SignalError(message string) error {
67+ if d == nil || !d.target.Truthy() {
68+ return fmt.Errorf("DOM readiness target #app not found")
69+ }
70+ if message == "" {
71+ message = "systemviews failed to start"
72+ }
73+ d.target.Call("removeAttribute", "aria-busy")
74+ d.target.Call("setAttribute", "data-failed", "true")
75+ d.target.Set("innerHTML", "")
76+ doc := d.document
77+ if !doc.Truthy() {
78+ doc = js.Global().Get("document")
79+ }
80+ if doc.Truthy() {
81+ failure := doc.Call("createElement", "div")
82+ failure.Set("className", "sv-error")
83+ failure.Set("textContent", message)
84+ d.target.Call("appendChild", failure)
85+ }
86+ return nil
87+}
88+
5189 func (d *browserDOM) AppendHistoryTimeline(markup string) error {
5290 if d == nil || !d.target.Truthy() {
5391 return fmt.Errorf("DOM mount target #app not found")
@@ -48,6 +48,44 @@ func (d *browserDOM) Mount(markup string) error {
48 return nil48 return nil
49 }49 }
50 50
51+// SignalReady marks the shell ready after mount and action binding: the
52+// loading busy flag is dropped and a data-ready marker is set for
53+// fixtures. Mount replaces #app content, so readiness must be signaled
54+// after every mount that completes binding.
55+func (d *browserDOM) SignalReady() error {
56+ if d == nil || !d.target.Truthy() {
57+ return fmt.Errorf("DOM readiness target #app not found")
58+ }
59+ d.target.Call("removeAttribute", "aria-busy")
60+ d.target.Call("setAttribute", "data-ready", "true")
61+ return nil
62+}
63+
64+// SignalError clears the loading state with a fatal message when mount or
65+// binding fails. textContent assignment keeps the message inert.
66+func (d *browserDOM) SignalError(message string) error {
67+ if d == nil || !d.target.Truthy() {
68+ return fmt.Errorf("DOM readiness target #app not found")
69+ }
70+ if message == "" {
71+ message = "systemviews failed to start"
72+ }
73+ d.target.Call("removeAttribute", "aria-busy")
74+ d.target.Call("setAttribute", "data-failed", "true")
75+ d.target.Set("innerHTML", "")
76+ doc := d.document
77+ if !doc.Truthy() {
78+ doc = js.Global().Get("document")
79+ }
80+ if doc.Truthy() {
81+ failure := doc.Call("createElement", "div")
82+ failure.Set("className", "sv-error")
83+ failure.Set("textContent", message)
84+ d.target.Call("appendChild", failure)
85+ }
86+ return nil
87+}
88+
51 func (d *browserDOM) AppendHistoryTimeline(markup string) error {89 func (d *browserDOM) AppendHistoryTimeline(markup string) error {
52 if d == nil || !d.target.Truthy() {90 if d == nil || !d.target.Truthy() {
53 return fmt.Errorf("DOM mount target #app not found")91 return fmt.Errorf("DOM mount target #app not found")
A internal/ui/systemviews/readiness_test.go +166 -0
@@ -0,0 +1,166 @@
1+package systemviews
2+
3+// P4.2 readiness tests: the shell loading state clears after the initial
4+// mount and action binding, never gated on async history-data completion.
5+// Fatal mount/bind failures report to the shell while preserving the
6+// original error.
7+
8+import (
9+ "context"
10+ "errors"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// signalingDOM records readiness signals on top of the recording mount.
19+type signalingDOM struct {
20+ recordingDOM
21+ ready bool
22+ failed string
23+}
24+
25+func (d *signalingDOM) SignalReady() error {
26+ d.mu.Lock()
27+ defer d.mu.Unlock()
28+ d.ready = true
29+ return nil
30+}
31+
32+func (d *signalingDOM) SignalError(message string) error {
33+ d.mu.Lock()
34+ defer d.mu.Unlock()
35+ d.failed = message
36+ return nil
37+}
38+
39+func (d *signalingDOM) isReady() bool {
40+ d.mu.Lock()
41+ defer d.mu.Unlock()
42+ return d.ready
43+}
44+
45+func (d *signalingDOM) failure() string {
46+ d.mu.Lock()
47+ defer d.mu.Unlock()
48+ return d.failed
49+}
50+
51+// signalingActionDOM binds actions and signals readiness.
52+type signalingActionDOM struct {
53+ signalingDOM
54+ handler DOMActionHandler
55+}
56+
57+func (d *signalingActionDOM) BindActions(handler DOMActionHandler) error {
58+ d.handler = handler
59+ return nil
60+}
61+
62+func TestReadinessSignaledAfterMountAndBind(t *testing.T) {
63+ t.Parallel()
64+
65+ dom := &signalingActionDOM{}
66+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
67+ require.NoError(t, app.Run())
68+ require.True(t, dom.isReady(), "readiness must fire after mount and binding")
69+ require.Empty(t, dom.failure())
70+}
71+
72+func TestReadinessMountFailureSignalsError(t *testing.T) {
73+ t.Parallel()
74+
75+ dom := &failingMountSignalingDOM{err: errors.New("mount blew up")}
76+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
77+ err := app.Run()
78+ require.ErrorIs(t, err, dom.err, "original mount error must propagate")
79+ require.Contains(t, dom.failure(), "mount blew up")
80+ require.False(t, dom.isReady())
81+}
82+
83+// failingMountSignalingDOM fails Mount while recording signals.
84+type failingMountSignalingDOM struct {
85+ signalingDOM
86+ err error
87+}
88+
89+func (d *failingMountSignalingDOM) Mount(string) error {
90+ return d.err
91+}
92+
93+func TestReadinessBindFailureSignalsError(t *testing.T) {
94+ t.Parallel()
95+
96+ dom := &failingBindSignalingDOM{err: errors.New("bind blew up")}
97+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
98+ err := app.Run()
99+ require.ErrorIs(t, err, dom.err, "original bind error must propagate")
100+ require.Contains(t, dom.failure(), "bind blew up")
101+ require.False(t, dom.isReady())
102+}
103+
104+// failingBindSignalingDOM fails BindActions while recording signals.
105+type failingBindSignalingDOM struct {
106+ signalingDOM
107+ err error
108+}
109+
110+func (d *failingBindSignalingDOM) BindActions(DOMActionHandler) error {
111+ return d.err
112+}
113+
114+func TestReadinessWithoutSignalerKeepsLegacyBehavior(t *testing.T) {
115+ t.Parallel()
116+
117+ dom := &recordingDOM{}
118+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
119+ require.NoError(t, app.Run())
120+ require.True(t, dom.Mounted())
121+}
122+
123+func TestReadinessSignalFailurePropagates(t *testing.T) {
124+ t.Parallel()
125+
126+ dom := &failingReadySignalingDOM{err: errors.New("signal blew up")}
127+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
128+ require.ErrorIs(t, app.Run(), dom.err)
129+}
130+
131+// failingReadySignalingDOM binds fine but fails the readiness signal.
132+type failingReadySignalingDOM struct {
133+ signalingDOM
134+ err error
135+}
136+
137+func (d *failingReadySignalingDOM) BindActions(DOMActionHandler) error {
138+ return nil
139+}
140+
141+func (d *failingReadySignalingDOM) SignalReady() error {
142+ return d.err
143+}
144+
145+func TestReadinessNotGatedOnAsyncHistoryData(t *testing.T) {
146+ t.Parallel()
147+
148+ dom := &signalingActionDOM{signalingDOM: signalingDOM{recordingDOM: recordingDOM{mounts: make(chan string, 4)}}}
149+ history := &recordingHistoryService{
150+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
151+ timelineStarted: make(chan struct{}),
152+ releaseTimeline: make(chan struct{}),
153+ }
154+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
155+
156+ require.NoError(t, app.RunWithContext(context.Background()))
157+ select {
158+ case <-history.timelineStarted:
159+ case <-time.After(5 * time.Second):
160+ t.Fatal("history timeline was not started asynchronously")
161+ }
162+ // The async hydration is still blocked, yet readiness already fired:
163+ // history-data completion is separate from shell readiness.
164+ require.True(t, dom.isReady(), "readiness must not wait for history data")
165+ close(history.releaseTimeline)
166+}
@@ -0,0 +1,166 @@
1+package systemviews
2+
3+// P4.2 readiness tests: the shell loading state clears after the initial
4+// mount and action binding, never gated on async history-data completion.
5+// Fatal mount/bind failures report to the shell while preserving the
6+// original error.
7+
8+import (
9+ "context"
10+ "errors"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// signalingDOM records readiness signals on top of the recording mount.
19+type signalingDOM struct {
20+ recordingDOM
21+ ready bool
22+ failed string
23+}
24+
25+func (d *signalingDOM) SignalReady() error {
26+ d.mu.Lock()
27+ defer d.mu.Unlock()
28+ d.ready = true
29+ return nil
30+}
31+
32+func (d *signalingDOM) SignalError(message string) error {
33+ d.mu.Lock()
34+ defer d.mu.Unlock()
35+ d.failed = message
36+ return nil
37+}
38+
39+func (d *signalingDOM) isReady() bool {
40+ d.mu.Lock()
41+ defer d.mu.Unlock()
42+ return d.ready
43+}
44+
45+func (d *signalingDOM) failure() string {
46+ d.mu.Lock()
47+ defer d.mu.Unlock()
48+ return d.failed
49+}
50+
51+// signalingActionDOM binds actions and signals readiness.
52+type signalingActionDOM struct {
53+ signalingDOM
54+ handler DOMActionHandler
55+}
56+
57+func (d *signalingActionDOM) BindActions(handler DOMActionHandler) error {
58+ d.handler = handler
59+ return nil
60+}
61+
62+func TestReadinessSignaledAfterMountAndBind(t *testing.T) {
63+ t.Parallel()
64+
65+ dom := &signalingActionDOM{}
66+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
67+ require.NoError(t, app.Run())
68+ require.True(t, dom.isReady(), "readiness must fire after mount and binding")
69+ require.Empty(t, dom.failure())
70+}
71+
72+func TestReadinessMountFailureSignalsError(t *testing.T) {
73+ t.Parallel()
74+
75+ dom := &failingMountSignalingDOM{err: errors.New("mount blew up")}
76+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
77+ err := app.Run()
78+ require.ErrorIs(t, err, dom.err, "original mount error must propagate")
79+ require.Contains(t, dom.failure(), "mount blew up")
80+ require.False(t, dom.isReady())
81+}
82+
83+// failingMountSignalingDOM fails Mount while recording signals.
84+type failingMountSignalingDOM struct {
85+ signalingDOM
86+ err error
87+}
88+
89+func (d *failingMountSignalingDOM) Mount(string) error {
90+ return d.err
91+}
92+
93+func TestReadinessBindFailureSignalsError(t *testing.T) {
94+ t.Parallel()
95+
96+ dom := &failingBindSignalingDOM{err: errors.New("bind blew up")}
97+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
98+ err := app.Run()
99+ require.ErrorIs(t, err, dom.err, "original bind error must propagate")
100+ require.Contains(t, dom.failure(), "bind blew up")
101+ require.False(t, dom.isReady())
102+}
103+
104+// failingBindSignalingDOM fails BindActions while recording signals.
105+type failingBindSignalingDOM struct {
106+ signalingDOM
107+ err error
108+}
109+
110+func (d *failingBindSignalingDOM) BindActions(DOMActionHandler) error {
111+ return d.err
112+}
113+
114+func TestReadinessWithoutSignalerKeepsLegacyBehavior(t *testing.T) {
115+ t.Parallel()
116+
117+ dom := &recordingDOM{}
118+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
119+ require.NoError(t, app.Run())
120+ require.True(t, dom.Mounted())
121+}
122+
123+func TestReadinessSignalFailurePropagates(t *testing.T) {
124+ t.Parallel()
125+
126+ dom := &failingReadySignalingDOM{err: errors.New("signal blew up")}
127+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
128+ require.ErrorIs(t, app.Run(), dom.err)
129+}
130+
131+// failingReadySignalingDOM binds fine but fails the readiness signal.
132+type failingReadySignalingDOM struct {
133+ signalingDOM
134+ err error
135+}
136+
137+func (d *failingReadySignalingDOM) BindActions(DOMActionHandler) error {
138+ return nil
139+}
140+
141+func (d *failingReadySignalingDOM) SignalReady() error {
142+ return d.err
143+}
144+
145+func TestReadinessNotGatedOnAsyncHistoryData(t *testing.T) {
146+ t.Parallel()
147+
148+ dom := &signalingActionDOM{signalingDOM: signalingDOM{recordingDOM: recordingDOM{mounts: make(chan string, 4)}}}
149+ history := &recordingHistoryService{
150+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
151+ timelineStarted: make(chan struct{}),
152+ releaseTimeline: make(chan struct{}),
153+ }
154+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
155+
156+ require.NoError(t, app.RunWithContext(context.Background()))
157+ select {
158+ case <-history.timelineStarted:
159+ case <-time.After(5 * time.Second):
160+ t.Fatal("history timeline was not started asynchronously")
161+ }
162+ // The async hydration is still blocked, yet readiness already fired:
163+ // history-data completion is separate from shell readiness.
164+ require.True(t, dom.isReady(), "readiness must not wait for history data")
165+ close(history.releaseTimeline)
166+}
A scripts/test_systemview_startup.py +285 -0
@@ -0,0 +1,285 @@
1+#!/usr/bin/env python3
2+"""Systemview startup fixture (Plan 05 P4.2).
3+
4+Static gates always run headless: the shell must fetch the WASM exactly
5+once (clone-based fallback), carry the readiness contract markers, and
6+the committed manifest must match disk artifacts.
7+
8+The live launch smoke starts the candidate binary with an isolated
9+profile, watches for panics/fatal crashes, then terminates it gracefully.
10+Without a display the live part skips cleanly; in-page branches
11+(streaming fallback, HTTP/MIME/compile failures, mount/bind failure, Go
12+panic exit, disabled JS) follow the printed display protocol, whose
13+markers are asserted statically here.
14+
15+Exit codes: 0 static gates and launch smoke green (in-page branches stay
16+MANUAL), 1 a gate failed, 2 static gates green with live parts skipped.
17+"""
18+
19+import argparse
20+import json
21+import os
22+import re
23+import shutil
24+import subprocess
25+import sys
26+import tempfile
27+from pathlib import Path
28+
29+REPO_ROOT = Path(__file__).resolve().parent.parent
30+SHELL_PATH = REPO_ROOT / "assets" / "systemviews" / "index.html"
31+MANIFEST_PATH = REPO_ROOT / "assets" / "systemviews" / "asset-manifest.json"
32+DOM_ADAPTER_PATH = REPO_ROOT / "internal" / "ui" / "systemviews" / "dom_js.go"
33+GENERATOR_PKG = "./cmd/systemviews-assets"
34+
35+EXIT_OK = 0
36+EXIT_FAIL = 1
37+EXIT_SKIP_LIVE = 2
38+
39+
40+def fail(message):
41+ print(f"systemview-startup: FAIL: {message}", flush=True)
42+ return EXIT_FAIL
43+
44+
45+def check_shell_single_fetch():
46+ """P4.1: one fetch per branch, clone-based fallback, no refetch."""
47+ try:
48+ shell = SHELL_PATH.read_text(encoding="utf-8")
49+ except OSError as exc:
50+ return fail(f"cannot read shell: {exc}")
51+ problems = []
52+ if "response.clone()" not in shell:
53+ problems.append("missing response.clone() fallback")
54+ if "fallback.arrayBuffer()" not in shell:
55+ problems.append("fallback does not consume the retained clone")
56+ if re.search(r"return\s+instantiateFromBytes\(\)", shell):
57+ problems.append("zero-arg instantiateFromBytes() refetches (old double-fetch)")
58+ if shell.count("fetch(wasmURL") != 2:
59+ problems.append("expected exactly two fetch(wasmURL...) branch sites")
60+ if "response.ok" not in shell:
61+ problems.append("missing response.ok fast-fail")
62+ if problems:
63+ return fail("shell fetch structure: " + "; ".join(problems))
64+ print("systemview-startup: shell fetches WASM once per branch, fallback reuses the clone")
65+ return EXIT_OK
66+
67+
68+def check_shell_readiness_contract():
69+ """P4.2: aria-busy entry, data markers, terminal guard, single go.run."""
70+ try:
71+ shell = SHELL_PATH.read_text(encoding="utf-8")
72+ except OSError as exc:
73+ return fail(f"cannot read shell: {exc}")
74+ required = [
75+ 'aria-busy="true"',
76+ "removeAttribute(",
77+ "aria-busy",
78+ 'data-failed',
79+ "terminalErrorReported",
80+ ]
81+ missing = [marker for marker in required if marker not in shell]
82+ if missing:
83+ return fail("shell readiness contract missing: " + ", ".join(missing))
84+ if shell.count("go.run(") != 1:
85+ return fail("runtime must execute exactly once (single go.run call site)")
86+ for marker in ("go.exit =", "goExitCode", "exit status"):
87+ if marker not in shell:
88+ return fail(f"shell exit hook missing: {marker}")
89+ try:
90+ adapter = DOM_ADAPTER_PATH.read_text(encoding="utf-8")
91+ except OSError as exc:
92+ return fail(f"cannot read DOM adapter: {exc}")
93+ if '"data-ready"' not in adapter:
94+ return fail("DOM adapter never marks data-ready")
95+ print("systemview-startup: shell carries the readiness contract markers")
96+ return EXIT_OK
97+
98+
99+def check_manifest_on_disk():
100+ """P3.1: committed manifest parses and matches generator schema."""
101+ try:
102+ raw = MANIFEST_PATH.read_bytes()
103+ except OSError as exc:
104+ return fail(f"cannot read manifest (run make build-systemviews): {exc}")
105+ try:
106+ manifest = json.loads(raw)
107+ except json.JSONDecodeError as exc:
108+ return fail(f"manifest is not JSON: {exc}")
109+ if manifest.get("version") != 1:
110+ return fail("manifest version != 1")
111+ files = manifest.get("files", {})
112+ if sorted(files) != ["systemviews.css", "systemviews.wasm", "wasm_exec.js"]:
113+ return fail(f"manifest pins {sorted(files)}, want the three servables")
114+ for name, entry in files.items():
115+ digest = entry.get("sha256", "")
116+ if not re.fullmatch(r"[0-9a-f]{64}", digest):
117+ return fail(f"manifest pin for {name} is not a sha256 hex digest")
118+ if not isinstance(entry.get("size"), int) or entry["size"] < 0:
119+ return fail(f"manifest pin for {name} has a bad size")
120+ print(f"systemview-startup: manifest pins {len(files)} assets with valid digests")
121+ return EXIT_OK
122+
123+
124+def check_manifest_generator():
125+ """P3.1: generator --check against disk artifacts (needs go toolchain)."""
126+ if shutil.which("go") is None:
127+ print("systemview-startup: SKIP generator check (no go toolchain)")
128+ return EXIT_SKIP_LIVE
129+ proc = subprocess.run(
130+ ["go", "run", GENERATOR_PKG, "-check", "-dir", "assets/systemviews"],
131+ cwd=REPO_ROOT,
132+ capture_output=True,
133+ text=True,
134+ timeout=300,
135+ )
136+ if proc.returncode != 0:
137+ sys.stderr.write(proc.stderr)
138+ return fail("generator --check rejected the working tree")
139+ print(f"systemview-startup: {proc.stdout.strip()}")
140+ return EXIT_OK
141+
142+
143+CRASH_PATTERNS = ["panic:", "fatal error:", "SIGTRAP", "runtime error:", "Go panic"]
144+
145+
146+def display_available():
147+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
148+
149+
150+def sanitized_launch_env(profile):
151+ """Build the child's environment: owned XDG/HOME/CEF dirs, no dev-mode.
152+
153+ The fixture must not read the operator's configuration (XDG_CONFIG_HOME)
154+ nor follow ENV=dev, which would redirect all profile directories under
155+ the working directory instead of the isolated profile.
156+ """
157+ env = dict(os.environ)
158+ env.pop("ENV", None)
159+ env["HOME"] = str(profile / "home")
160+ env["XDG_DATA_HOME"] = str(profile / "data")
161+ env["XDG_STATE_HOME"] = str(profile / "state")
162+ env["XDG_CACHE_HOME"] = str(profile / "cache")
163+ env["XDG_CONFIG_HOME"] = str(profile / "config")
164+ return env
165+
166+
167+def run_launch_smoke(binary, cef_dir, timeout):
168+ """Start the candidate with an isolated profile, watch, terminate.
169+
170+ This is a startup smoke test only: it proves the binary launches and
171+ survives the window without crashes. It never navigates to a
172+ systemview, so fetch counts, readiness inspection, streaming fallback
173+ and failure injection stay MANUAL via the printed display protocol.
174+ """
175+ if not display_available():
176+ print("systemview-startup: SKIP live launch (no DISPLAY or WAYLAND_DISPLAY)")
177+ return EXIT_SKIP_LIVE
178+ profile = Path(tempfile.mkdtemp(prefix="sv-startup-profile-"))
179+ env = sanitized_launch_env(profile)
180+ env["DUMBER_CEF_ROOT_CACHE_PATH"] = str(profile / "cef-cache")
181+ env["DUMBER_CEF_DIR"] = str(cef_dir)
182+ print(f"systemview-startup: launching {binary} with isolated profile {profile}")
183+ try:
184+ proc = subprocess.Popen(
185+ [str(binary)],
186+ env=env,
187+ cwd=profile,
188+ stdout=subprocess.PIPE,
189+ stderr=subprocess.STDOUT,
190+ text=True,
191+ )
192+ except OSError as exc:
193+ return fail(f"cannot execute binary: {exc}")
194+ try:
195+ try:
196+ output, _ = proc.communicate(timeout=timeout)
197+ except subprocess.TimeoutExpired:
198+ output = None
199+ if output is not None:
200+ # Any early exit is unexpected for the GUI binary: a crash
201+ # without markers is still a failure, never a pass.
202+ print(f"systemview-startup: binary exited during smoke with code {proc.returncode}")
203+ print((output or "")[-4000:])
204+ for pattern in CRASH_PATTERNS:
205+ if pattern in (output or ""):
206+ return fail(f"crash marker in startup output: {pattern}")
207+ return fail(f"binary exited during the smoke window (code {proc.returncode}); expected it to stay alive")
208+ # Still alive past the window: the startup path survived; terminate
209+ # gracefully, then force-kill only our own child on refusal.
210+ proc.terminate()
211+ try:
212+ output, _ = proc.communicate(timeout=15)
213+ except subprocess.TimeoutExpired:
214+ proc.kill()
215+ output, _ = proc.communicate(timeout=15)
216+ print("systemview-startup: WARN binary ignored SIGTERM and was killed")
217+ for pattern in CRASH_PATTERNS:
218+ if pattern in (output or ""):
219+ print((output or "")[-4000:])
220+ return fail(f"crash marker in startup output: {pattern}")
221+ print("systemview-startup: launch smoke survived the window with no crash markers")
222+ return EXIT_OK
223+ finally:
224+ shutil.rmtree(profile, ignore_errors=True)
225+
226+
227+DISPLAY_PROTOCOL = """\
228+systemview-startup display protocol -- MANUAL STEPS (not executed by this script):
229+ 1. Open dumb://history -> #app gains data-ready="true", aria-busy gone.
230+ Shell HTML references .wasm/.js/.css with ?v=<64-hex> (view-source).
231+ 2. DevTools: exactly one systemviews.wasm request per load; reload ->
232+ .wasm served 200 with Cache-Control immutable; shell answers no-store.
233+ 3. Streaming fallback: serve .wasm as octet-stream via a local proxy ->
234+ console shows the streaming-fallback warning, page still mounts ready.
235+ 4. HTTP failure: point the shell at a missing .wasm -> terminal
236+ "Failed to load systemviews WebAssembly: Error: HTTP 404" rendered
237+ once in .sv-error, data-failed="true", aria-busy gone.
238+ 5. Compile failure: corrupt the .wasm bytes -> same terminal error path.
239+ 6. Disabled JS: noscript message visible, no console activity.
240+ 7. Go panic path: break bridge init -> nonzero process exit, console
241+ carries the error, error renders exactly once.
242+"""
243+
244+
245+def main():
246+ parser = argparse.ArgumentParser(description="systemview startup fixture")
247+ parser.add_argument("--binary", required=True, help="candidate dumber binary")
248+ parser.add_argument("--cef-dir", required=True, help="compatible CEF runtime dir")
249+ parser.add_argument("--launch-timeout", type=int, default=20, help="smoke window in seconds")
250+ args = parser.parse_args()
251+
252+ binary = Path(args.binary)
253+ if not binary.is_file() or not os.access(binary, os.X_OK):
254+ return fail(f"binary is not executable: {binary}")
255+ cef_dir = Path(args.cef_dir)
256+ if not cef_dir.is_dir():
257+ return fail(f"CEF runtime dir missing: {cef_dir}")
258+ # The child runs with cwd set to the isolated profile: resolve both
259+ # paths now, while they still mean "relative to the invoker".
260+ binary = binary.resolve()
261+ cef_dir = cef_dir.resolve()
262+
263+ results = [
264+ check_shell_single_fetch(),
265+ check_shell_readiness_contract(),
266+ check_manifest_on_disk(),
267+ check_manifest_generator(),
268+ ]
269+ if any(result == EXIT_FAIL for result in results):
270+ return EXIT_FAIL
271+ live = run_launch_smoke(binary, cef_dir, args.launch_timeout)
272+ print(DISPLAY_PROTOCOL)
273+ if live == EXIT_FAIL:
274+ return EXIT_FAIL
275+ if live == EXIT_SKIP_LIVE or any(result == EXIT_SKIP_LIVE for result in results):
276+ print("systemview-startup: static gates green, live parts skipped")
277+ print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
278+ return EXIT_SKIP_LIVE
279+ print("systemview-startup: static gates and launch smoke green")
280+ print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
281+ return EXIT_OK
282+
283+
284+if __name__ == "__main__":
285+ sys.exit(main())
@@ -0,0 +1,285 @@
1+#!/usr/bin/env python3
2+"""Systemview startup fixture (Plan 05 P4.2).
3+
4+Static gates always run headless: the shell must fetch the WASM exactly
5+once (clone-based fallback), carry the readiness contract markers, and
6+the committed manifest must match disk artifacts.
7+
8+The live launch smoke starts the candidate binary with an isolated
9+profile, watches for panics/fatal crashes, then terminates it gracefully.
10+Without a display the live part skips cleanly; in-page branches
11+(streaming fallback, HTTP/MIME/compile failures, mount/bind failure, Go
12+panic exit, disabled JS) follow the printed display protocol, whose
13+markers are asserted statically here.
14+
15+Exit codes: 0 static gates and launch smoke green (in-page branches stay
16+MANUAL), 1 a gate failed, 2 static gates green with live parts skipped.
17+"""
18+
19+import argparse
20+import json
21+import os
22+import re
23+import shutil
24+import subprocess
25+import sys
26+import tempfile
27+from pathlib import Path
28+
29+REPO_ROOT = Path(__file__).resolve().parent.parent
30+SHELL_PATH = REPO_ROOT / "assets" / "systemviews" / "index.html"
31+MANIFEST_PATH = REPO_ROOT / "assets" / "systemviews" / "asset-manifest.json"
32+DOM_ADAPTER_PATH = REPO_ROOT / "internal" / "ui" / "systemviews" / "dom_js.go"
33+GENERATOR_PKG = "./cmd/systemviews-assets"
34+
35+EXIT_OK = 0
36+EXIT_FAIL = 1
37+EXIT_SKIP_LIVE = 2
38+
39+
40+def fail(message):
41+ print(f"systemview-startup: FAIL: {message}", flush=True)
42+ return EXIT_FAIL
43+
44+
45+def check_shell_single_fetch():
46+ """P4.1: one fetch per branch, clone-based fallback, no refetch."""
47+ try:
48+ shell = SHELL_PATH.read_text(encoding="utf-8")
49+ except OSError as exc:
50+ return fail(f"cannot read shell: {exc}")
51+ problems = []
52+ if "response.clone()" not in shell:
53+ problems.append("missing response.clone() fallback")
54+ if "fallback.arrayBuffer()" not in shell:
55+ problems.append("fallback does not consume the retained clone")
56+ if re.search(r"return\s+instantiateFromBytes\(\)", shell):
57+ problems.append("zero-arg instantiateFromBytes() refetches (old double-fetch)")
58+ if shell.count("fetch(wasmURL") != 2:
59+ problems.append("expected exactly two fetch(wasmURL...) branch sites")
60+ if "response.ok" not in shell:
61+ problems.append("missing response.ok fast-fail")
62+ if problems:
63+ return fail("shell fetch structure: " + "; ".join(problems))
64+ print("systemview-startup: shell fetches WASM once per branch, fallback reuses the clone")
65+ return EXIT_OK
66+
67+
68+def check_shell_readiness_contract():
69+ """P4.2: aria-busy entry, data markers, terminal guard, single go.run."""
70+ try:
71+ shell = SHELL_PATH.read_text(encoding="utf-8")
72+ except OSError as exc:
73+ return fail(f"cannot read shell: {exc}")
74+ required = [
75+ 'aria-busy="true"',
76+ "removeAttribute(",
77+ "aria-busy",
78+ 'data-failed',
79+ "terminalErrorReported",
80+ ]
81+ missing = [marker for marker in required if marker not in shell]
82+ if missing:
83+ return fail("shell readiness contract missing: " + ", ".join(missing))
84+ if shell.count("go.run(") != 1:
85+ return fail("runtime must execute exactly once (single go.run call site)")
86+ for marker in ("go.exit =", "goExitCode", "exit status"):
87+ if marker not in shell:
88+ return fail(f"shell exit hook missing: {marker}")
89+ try:
90+ adapter = DOM_ADAPTER_PATH.read_text(encoding="utf-8")
91+ except OSError as exc:
92+ return fail(f"cannot read DOM adapter: {exc}")
93+ if '"data-ready"' not in adapter:
94+ return fail("DOM adapter never marks data-ready")
95+ print("systemview-startup: shell carries the readiness contract markers")
96+ return EXIT_OK
97+
98+
99+def check_manifest_on_disk():
100+ """P3.1: committed manifest parses and matches generator schema."""
101+ try:
102+ raw = MANIFEST_PATH.read_bytes()
103+ except OSError as exc:
104+ return fail(f"cannot read manifest (run make build-systemviews): {exc}")
105+ try:
106+ manifest = json.loads(raw)
107+ except json.JSONDecodeError as exc:
108+ return fail(f"manifest is not JSON: {exc}")
109+ if manifest.get("version") != 1:
110+ return fail("manifest version != 1")
111+ files = manifest.get("files", {})
112+ if sorted(files) != ["systemviews.css", "systemviews.wasm", "wasm_exec.js"]:
113+ return fail(f"manifest pins {sorted(files)}, want the three servables")
114+ for name, entry in files.items():
115+ digest = entry.get("sha256", "")
116+ if not re.fullmatch(r"[0-9a-f]{64}", digest):
117+ return fail(f"manifest pin for {name} is not a sha256 hex digest")
118+ if not isinstance(entry.get("size"), int) or entry["size"] < 0:
119+ return fail(f"manifest pin for {name} has a bad size")
120+ print(f"systemview-startup: manifest pins {len(files)} assets with valid digests")
121+ return EXIT_OK
122+
123+
124+def check_manifest_generator():
125+ """P3.1: generator --check against disk artifacts (needs go toolchain)."""
126+ if shutil.which("go") is None:
127+ print("systemview-startup: SKIP generator check (no go toolchain)")
128+ return EXIT_SKIP_LIVE
129+ proc = subprocess.run(
130+ ["go", "run", GENERATOR_PKG, "-check", "-dir", "assets/systemviews"],
131+ cwd=REPO_ROOT,
132+ capture_output=True,
133+ text=True,
134+ timeout=300,
135+ )
136+ if proc.returncode != 0:
137+ sys.stderr.write(proc.stderr)
138+ return fail("generator --check rejected the working tree")
139+ print(f"systemview-startup: {proc.stdout.strip()}")
140+ return EXIT_OK
141+
142+
143+CRASH_PATTERNS = ["panic:", "fatal error:", "SIGTRAP", "runtime error:", "Go panic"]
144+
145+
146+def display_available():
147+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
148+
149+
150+def sanitized_launch_env(profile):
151+ """Build the child's environment: owned XDG/HOME/CEF dirs, no dev-mode.
152+
153+ The fixture must not read the operator's configuration (XDG_CONFIG_HOME)
154+ nor follow ENV=dev, which would redirect all profile directories under
155+ the working directory instead of the isolated profile.
156+ """
157+ env = dict(os.environ)
158+ env.pop("ENV", None)
159+ env["HOME"] = str(profile / "home")
160+ env["XDG_DATA_HOME"] = str(profile / "data")
161+ env["XDG_STATE_HOME"] = str(profile / "state")
162+ env["XDG_CACHE_HOME"] = str(profile / "cache")
163+ env["XDG_CONFIG_HOME"] = str(profile / "config")
164+ return env
165+
166+
167+def run_launch_smoke(binary, cef_dir, timeout):
168+ """Start the candidate with an isolated profile, watch, terminate.
169+
170+ This is a startup smoke test only: it proves the binary launches and
171+ survives the window without crashes. It never navigates to a
172+ systemview, so fetch counts, readiness inspection, streaming fallback
173+ and failure injection stay MANUAL via the printed display protocol.
174+ """
175+ if not display_available():
176+ print("systemview-startup: SKIP live launch (no DISPLAY or WAYLAND_DISPLAY)")
177+ return EXIT_SKIP_LIVE
178+ profile = Path(tempfile.mkdtemp(prefix="sv-startup-profile-"))
179+ env = sanitized_launch_env(profile)
180+ env["DUMBER_CEF_ROOT_CACHE_PATH"] = str(profile / "cef-cache")
181+ env["DUMBER_CEF_DIR"] = str(cef_dir)
182+ print(f"systemview-startup: launching {binary} with isolated profile {profile}")
183+ try:
184+ proc = subprocess.Popen(
185+ [str(binary)],
186+ env=env,
187+ cwd=profile,
188+ stdout=subprocess.PIPE,
189+ stderr=subprocess.STDOUT,
190+ text=True,
191+ )
192+ except OSError as exc:
193+ return fail(f"cannot execute binary: {exc}")
194+ try:
195+ try:
196+ output, _ = proc.communicate(timeout=timeout)
197+ except subprocess.TimeoutExpired:
198+ output = None
199+ if output is not None:
200+ # Any early exit is unexpected for the GUI binary: a crash
201+ # without markers is still a failure, never a pass.
202+ print(f"systemview-startup: binary exited during smoke with code {proc.returncode}")
203+ print((output or "")[-4000:])
204+ for pattern in CRASH_PATTERNS:
205+ if pattern in (output or ""):
206+ return fail(f"crash marker in startup output: {pattern}")
207+ return fail(f"binary exited during the smoke window (code {proc.returncode}); expected it to stay alive")
208+ # Still alive past the window: the startup path survived; terminate
209+ # gracefully, then force-kill only our own child on refusal.
210+ proc.terminate()
211+ try:
212+ output, _ = proc.communicate(timeout=15)
213+ except subprocess.TimeoutExpired:
214+ proc.kill()
215+ output, _ = proc.communicate(timeout=15)
216+ print("systemview-startup: WARN binary ignored SIGTERM and was killed")
217+ for pattern in CRASH_PATTERNS:
218+ if pattern in (output or ""):
219+ print((output or "")[-4000:])
220+ return fail(f"crash marker in startup output: {pattern}")
221+ print("systemview-startup: launch smoke survived the window with no crash markers")
222+ return EXIT_OK
223+ finally:
224+ shutil.rmtree(profile, ignore_errors=True)
225+
226+
227+DISPLAY_PROTOCOL = """\
228+systemview-startup display protocol -- MANUAL STEPS (not executed by this script):
229+ 1. Open dumb://history -> #app gains data-ready="true", aria-busy gone.
230+ Shell HTML references .wasm/.js/.css with ?v=<64-hex> (view-source).
231+ 2. DevTools: exactly one systemviews.wasm request per load; reload ->
232+ .wasm served 200 with Cache-Control immutable; shell answers no-store.
233+ 3. Streaming fallback: serve .wasm as octet-stream via a local proxy ->
234+ console shows the streaming-fallback warning, page still mounts ready.
235+ 4. HTTP failure: point the shell at a missing .wasm -> terminal
236+ "Failed to load systemviews WebAssembly: Error: HTTP 404" rendered
237+ once in .sv-error, data-failed="true", aria-busy gone.
238+ 5. Compile failure: corrupt the .wasm bytes -> same terminal error path.
239+ 6. Disabled JS: noscript message visible, no console activity.
240+ 7. Go panic path: break bridge init -> nonzero process exit, console
241+ carries the error, error renders exactly once.
242+"""
243+
244+
245+def main():
246+ parser = argparse.ArgumentParser(description="systemview startup fixture")
247+ parser.add_argument("--binary", required=True, help="candidate dumber binary")
248+ parser.add_argument("--cef-dir", required=True, help="compatible CEF runtime dir")
249+ parser.add_argument("--launch-timeout", type=int, default=20, help="smoke window in seconds")
250+ args = parser.parse_args()
251+
252+ binary = Path(args.binary)
253+ if not binary.is_file() or not os.access(binary, os.X_OK):
254+ return fail(f"binary is not executable: {binary}")
255+ cef_dir = Path(args.cef_dir)
256+ if not cef_dir.is_dir():
257+ return fail(f"CEF runtime dir missing: {cef_dir}")
258+ # The child runs with cwd set to the isolated profile: resolve both
259+ # paths now, while they still mean "relative to the invoker".
260+ binary = binary.resolve()
261+ cef_dir = cef_dir.resolve()
262+
263+ results = [
264+ check_shell_single_fetch(),
265+ check_shell_readiness_contract(),
266+ check_manifest_on_disk(),
267+ check_manifest_generator(),
268+ ]
269+ if any(result == EXIT_FAIL for result in results):
270+ return EXIT_FAIL
271+ live = run_launch_smoke(binary, cef_dir, args.launch_timeout)
272+ print(DISPLAY_PROTOCOL)
273+ if live == EXIT_FAIL:
274+ return EXIT_FAIL
275+ if live == EXIT_SKIP_LIVE or any(result == EXIT_SKIP_LIVE for result in results):
276+ print("systemview-startup: static gates green, live parts skipped")
277+ print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
278+ return EXIT_SKIP_LIVE
279+ print("systemview-startup: static gates and launch smoke green")
280+ print("systemview-startup: in-page branches were NOT executed here; they are MANUAL via the protocol above")
281+ return EXIT_OK
282+
283+
284+if __name__ == "__main__":
285+ sys.exit(main())

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close