1package 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
26import (
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.
45const 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.
53type 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.
74func 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.
81func (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.
97func (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.
110func (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.
123func 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.
136func 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.
164func 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.
194type 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
218func 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
235func (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.
265func (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
271func (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
287func (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
295func (rh *systemviewWASMResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
296 rh.start(callback)
297 return 1
298}
299
300func (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
326func (rh *systemviewWASMResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
327 return 0
328}
329
330func (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
348func (rh *systemviewWASMResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
349 return 0
350}
351
352func (rh *systemviewWASMResourceHandler) Cancel() {
353 rh.settleMu.Lock()
354 rh.settled = true
355 rh.settleMu.Unlock()
356 rh.cancelRequest()
357}
358
359func (rh *systemviewWASMResourceHandler) cancelRequest() {
360 if rh.cancel != nil {
361 rh.cancelOnce.Do(rh.cancel)
362 }
363}