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 Unverified
B5690EEEBB952194The key is unknown
20 files · +3365 -37
M Makefile +15 -4
| @@ -34,6 +34,14 @@ WASM_GOFLAGS?=$(GOFLAGS) | |||
| 34 | 34 | # Linker flags | |
| 35 | 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 | 45 | # Disable optimizations and inlining for ALL packages. purego callbacks | |
| 38 | 46 | # create mixed Go/C stack frames; the Go runtime (stack growth, GC, | |
| 39 | 47 | # 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 | 71 | generate-systemviews: ## Generate Go code from systemviews templ components | |
| 64 | 72 | @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 | |
| 66 | 74 | @echo "Systemviews templ generation complete" | |
| 67 | 75 | ||
| 68 | 76 | build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |
| 69 | 77 | @echo "Building systemviews wasm assets..." | |
| 70 | 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 | 79 | @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 | |
| 74 | 82 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | |
| 83 | + | go run ./cmd/systemviews-assets -dir assets/systemviews | |
| 75 | 84 | @echo "Systemviews build complete" | |
| 76 | 85 | ||
| 77 | 86 | build-quick: ## Build quickly for backend development | |
| 78 | 87 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 79 | 88 | @mkdir -p $(DIST_DIR) | |
| 89 | + | go run ./cmd/systemviews-assets -check-if-present -dir assets/systemviews | |
| 80 | 90 | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 81 | 91 | @rm -f $(DIST_DIR)/cef-helper | |
| 82 | 92 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| @@ -149,7 +159,8 @@ verify-generated: ## Verify generated systemviews artifacts are committed | |||
| 149 | 159 | ||
| 150 | 160 | verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset | |
| 151 | 161 | @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 | |
| 153 | 164 | ||
| 154 | 165 | # Linting | |
| 155 | 166 | lint: ## Run golangci-lint | |
| @@ -34,6 +34,14 @@ WASM_GOFLAGS?=$(GOFLAGS) | |||||
| 34 | # Linker flags | 34 | # 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 callbacks | 45 | # 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 these | 47 | # 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 components | 71 | 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=false | 73 | + | 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 runtime | 76 | 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/systemviews | 79 | @mkdir -p assets/systemviews | ||
| 72 | - | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | 80 | + | @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/systemviews | 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 |
| 74 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | 82 | 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 development | 86 | 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-helper | 91 | @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 asset | 160 | 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 ./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 | |||
| 153 | 164 | ||||
| 154 | # Linting | 165 | # Linting | ||
| 155 | lint: ## Run golangci-lint | 166 | 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 @@ | |||
| 8 | 8 | <link rel="stylesheet" href="./systemviews.css"> | |
| 9 | 9 | <script src="./wasm_exec.js"></script> | |
| 10 | 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 | 14 | function renderWasmError(prefixText, err) { | |
| 15 | + | console.error(prefixText, err); | |
| 16 | + | if (terminalErrorReported) return; | |
| 17 | + | terminalErrorReported = true; | |
| 12 | 18 | const render = () => { | |
| 13 | 19 | const app = document.getElementById('app'); | |
| 14 | 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 | 25 | app.innerHTML = ''; | |
| 16 | 26 | const message = document.createElement('div'); | |
| 17 | 27 | message.className = 'sv-error'; | |
| @@ -27,26 +37,37 @@ | |||
| 27 | 37 | } else { | |
| 28 | 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 | 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 | 60 | .then((response) => { | |
| 36 | 61 | if (!response.ok) { | |
| 37 | 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 | 67 | .catch((err) => { | |
| 68 | + | if (!fallback) throw err; | |
| 48 | 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 | 77 | renderWasmError('Go WebAssembly runtime failed to load: '); | |
| 57 | 78 | } else { | |
| 58 | 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 | 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 | 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 | 105 | </script> | |
| 64 | 106 | </head> | |
| 65 | 107 | <body> | |
| 66 | - | <main id="app">Loading systemviews…</main> | |
| 108 | + | <main id="app" aria-busy="true">Loading systemviews…</main> | |
| 67 | 109 | <noscript> | |
| 68 | 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 | 111 | </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 @@ | |||
| 3 | 3 | package main | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "os" | |
| 6 | 7 | "syscall/js" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge" | |
| @@ -21,9 +22,13 @@ func main() { | |||
| 21 | 22 | } | |
| 22 | 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 | 29 | if err := app.Run(); err != nil { | |
| 25 | 30 | js.Global().Get("console").Call("error", err.Error()) | |
| 26 | - | panic(err) | |
| 31 | + | os.Exit(1) | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 29 | 34 | select {} | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package main | 3 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | go 1.26 | |
| 4 | 4 | ||
| 5 | + | toolchain go1.26.7 | |
| 6 | + | ||
| 5 | 7 | require ( | |
| 6 | 8 | github.com/a-h/templ v0.3.1020 | |
| 7 | 9 | github.com/andybalholm/brotli v1.2.3 | |
| @@ -2,6 +2,8 @@ module github.com/bnema/dumber | |||||
| 2 | 2 | ||||
| 3 | go 1.26 | 3 | go 1.26 | ||
| 4 | 4 | ||||
| 5 | + | toolchain go1.26.7 | |||
| 6 | + | ||||
| 5 | require ( | 7 | require ( | ||
| 6 | github.com/a-h/templ v0.3.1020 | 8 | github.com/a-h/templ v0.3.1020 | ||
| 7 | github.com/andybalholm/brotli v1.2.3 | 9 | github.com/andybalholm/brotli v1.2.3 | ||
M internal/infrastructure/cef/scheme_handler.go +246 -20
| @@ -3,8 +3,9 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | - | "embed" | |
| 6 | + | "crypto/sha256" | |
| 7 | 7 | "encoding/base64" | |
| 8 | + | "encoding/hex" | |
| 8 | 9 | "encoding/json" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "html" | |
| @@ -26,6 +27,8 @@ import ( | |||
| 26 | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 27 | 28 | "github.com/bnema/dumber/internal/logging" | |
| 28 | 29 | "github.com/rs/zerolog" | |
| 30 | + | ||
| 31 | + | "github.com/bnema/dumber/assets" | |
| 29 | 32 | ) | |
| 30 | 33 | ||
| 31 | 34 | // Scheme path constants matching WebKit's naming. | |
| @@ -53,11 +56,18 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 53 | 56 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| 54 | 57 | // internal https://dumber.invalid origin used by CEF. | |
| 55 | 58 | 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 | |
| 61 | 71 | assetDir string | |
| 62 | 72 | logger zerolog.Logger | |
| 63 | 73 | currentConfigPayload func() ([]byte, error) | |
| @@ -111,12 +121,14 @@ func newDumbSchemeHandler( | |||
| 111 | 121 | }, nil | |
| 112 | 122 | } | |
| 113 | 123 | ||
| 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) { | |
| 116 | 128 | h.mu.Lock() | |
| 117 | 129 | defer h.mu.Unlock() | |
| 118 | - | h.assets = assets | |
| 119 | - | h.assetsSet = true | |
| 130 | + | h.assets = assetsFS | |
| 131 | + | h.bundle = newSystemviewAssetBundle(assetsFS) | |
| 120 | 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 | 571 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |
| 560 | 572 | h.mu.RLock() | |
| 561 | - | hasAssets := h.assetsSet | |
| 573 | + | assetsFS, bundle := h.assets, h.bundle | |
| 562 | 574 | h.mu.RUnlock() | |
| 563 | 575 | ||
| 564 | - | if !hasAssets { | |
| 576 | + | if assetsFS == nil || bundle == nil { | |
| 565 | 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 | 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 | 608 | if err != nil { | |
| 583 | 609 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| 584 | 610 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |
| @@ -591,7 +617,202 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 591 | 617 | Int("size", len(data)). | |
| 592 | 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 | 818 | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |
| @@ -617,9 +838,9 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||
| 617 | 838 | 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 | 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 | 844 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |
| 624 | 845 | if err != nil { | |
| 625 | 846 | return nil, err | |
| @@ -630,7 +851,7 @@ func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, erro | |||
| 630 | 851 | return data, nil | |
| 631 | 852 | } | |
| 632 | 853 | } | |
| 633 | - | data, err := fs.ReadFile(assets, fullPath) | |
| 854 | + | data, err := fs.ReadFile(assetsFS, fullPath) | |
| 634 | 855 | return data, err | |
| 635 | 856 | } | |
| 636 | 857 | ||
| @@ -807,9 +1028,14 @@ func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentT | |||
| 807 | 1028 | } | |
| 808 | 1029 | ||
| 809 | 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 | 1037 | 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)) | |
| 813 | 1039 | } | |
| 814 | 1040 | ||
| 815 | 1041 | 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 actual | 56 | // 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.Context | 59 | + | ctx context.Context |
| 57 | - | messageRouter *MessageRouter | 60 | + | messageRouter *MessageRouter |
| 58 | - | faviconResolver port.FaviconSystemviewResolver | 61 | + | faviconResolver port.FaviconSystemviewResolver |
| 59 | - | assets embed.FS | 62 | + | // assets is the captured immutable asset filesystem. Together with |
| 60 | - | assetsSet bool | 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 | |||
| 61 | assetDir string | 71 | assetDir string | ||
| 62 | logger zerolog.Logger | 72 | logger zerolog.Logger | ||
| 63 | currentConfigPayload func() ([]byte, error) | 73 | currentConfigPayload func() ([]byte, error) | ||
| @@ -111,12 +121,14 @@ func newDumbSchemeHandler( | |||||
| 111 | }, nil | 121 | }, 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 = assets | 130 | + | h.assets = assetsFS |
| 119 | - | h.assetsSet = true | 131 | + | 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.assetsSet | 573 | + | 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, true | 838 | 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, err | 846 | return nil, err | ||
| @@ -630,7 +851,7 @@ func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, erro | |||||
| 630 | return data, nil | 851 | return data, nil | ||
| 631 | } | 852 | } | ||
| 632 | } | 853 | } | ||
| 633 | - | data, err := fs.ReadFile(assets, fullPath) | 854 | + | data, err := fs.ReadFile(assetsFS, fullPath) |
| 634 | return data, err | 855 | 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 { | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | |
| 117 | + | a.signalShellError(err) | |
| 117 | 118 | return err | |
| 118 | 119 | } | |
| 119 | 120 | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | |
| 120 | 121 | if err := a.bindDOMActions(ctx, binder); err != nil { | |
| 122 | + | a.signalShellError(err) | |
| 121 | 123 | 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 | 132 | if asyncInitial { | |
| 125 | 133 | a.refreshCurrentRouteAsync(ctx) | |
| 126 | 134 | } | |
| 127 | 135 | 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 | 160 | func (a *App) shouldLoadInitialRouteAsync() bool { | |
| 131 | 161 | return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil | |
| 132 | 162 | } | |
| @@ -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 err | 118 | 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 err | 123 | 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 nil | 135 | 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 != nil | 161 | 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 { | |||
| 24 | 24 | 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 | 40 | // DOMHistoryTimelineAppender is implemented by DOM adapters that can append | |
| 28 | 41 | // older history windows without reparsing and remounting the whole page. | |
| 29 | 42 | type DOMHistoryTimelineAppender interface { | |
| @@ -24,6 +24,19 @@ type DOMActionBinder interface { | |||||
| 24 | BindActions(handler DOMActionHandler) error | 24 | 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 append | 40 | // 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 { | |||
| 48 | 48 | 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 | 89 | func (d *browserDOM) AppendHistoryTimeline(markup string) error { | |
| 52 | 90 | if d == nil || !d.target.Truthy() { | |
| 53 | 91 | return fmt.Errorf("DOM mount target #app not found") | |
| @@ -48,6 +48,44 @@ func (d *browserDOM) Mount(markup string) error { | |||||
| 48 | return nil | 48 | 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()) | |||