From 58efe6ddbdec5d97816418501a9c5c2f3bfc39b9 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 14 Aug 2026 00:25:11 +0900 Subject: [PATCH] fix: make Service Worker cache and removal outcomes truthful SW-URL-01: canonicalize each generated root-relative manifest URL against the registration scope once, re-check same-origin, and share that absolute identity across install cache keys, fetch classification and cache lookup or delete. Previously every verified asset fell through to the network. SW-01: serve verified static requests only from the current release cache. A CacheStorage-wide match could return a previous release's response for the same URL while the delete targeted a cache that was never read. The worker scope facade no longer exposes a wide match at all. SW-02: cache reset deletes only names that parse as owned, so a foreign cache sharing the ca-static-v1- prefix survives. SW-03: unregister() resolving to false is a FAILED unregister, not UNREGISTERED. SW-04: staged removal reports what happened - ABSENT, UNREGISTERED and PURGED map to DISABLED, OWNERSHIP_MISMATCH to INCOMPATIBLE and FAILED to FAILED - so a later release cannot delete the worker while a registration or owned cache is still present. SW-05: add the runtime-neutral service-worker-static-manifest codec that owns exact row keys, the extension and content-type allowlist, the root-relative URL rule and the length-prefixed canonical bytes. The generator and the build gate hash those same bytes, and the build gate now decodes and recomputes the set digest instead of type-casting the manifest. Co-Authored-By: Claude Opus 5 (1M context) --- docs/operations/adapter-remediation-ledger.md | 12 +- scripts/generate-service-worker-assets.ts | 25 +- scripts/lib/service-worker-build-input.ts | 38 ++- .../service-worker/service-worker-entry.ts | 3 +- .../service-worker-lifecycle.ts | 77 +++++- .../service-worker-page-controller.ts | 31 ++- .../service-worker/service-worker-removal.ts | 12 +- .../service-worker-static-manifest.ts | 220 ++++++++++++++++ tests/unit/service-worker-build-input.test.ts | 112 +++++++- tests/unit/service-worker-runtime.test.ts | 247 ++++++++++++++++++ 10 files changed, 722 insertions(+), 55 deletions(-) create mode 100644 src/contracts/service-worker-static-manifest.ts diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index cecbb81..c483c5e 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -138,12 +138,12 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at | ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence | | --- | --- | --- | --- | --- | --- | --- | -| SW-URL-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | static asset cache miss rate | — | -| SW-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | stale response served | — | -| SW-02 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | foreign cache deletion | — | -| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | false removal success | — | -| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | removal outcome misreport | — | -| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | — | `NOT_STARTED` | build admission rejection | — | +| SW-URL-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | static asset cache miss rate | Red generator-shaped root-relative asset vs absolute Request URL → green; manifest URLs canonicalized once against the registration scope | +| SW-01 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | stale response served | Red previous-cache hit → green network fallback; only the current release cache is opened, matched and deleted from | +| SW-02 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | foreign cache deletion | Red prefix deletion of `ca-static-v1-not-owned` and longer suffixes → green exact `isOwnedStaticCacheName` only | +| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | false removal success | Red `unregister() === false` reported as UNREGISTERED → green FAILED | +| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | removal outcome misreport | Red removal modes always DISABLED → green outcome matrix (ABSENT/UNREGISTERED/PURGED→DISABLED, OWNERSHIP_MISMATCH→INCOMPATIBLE, FAILED→FAILED) | +| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | build admission rejection | Red tamper table (stale digest, byte length, cross-origin URL, dot segment, extension mismatch, unknown field, duplicate URL) → green; build gate decodes through the shared codec and recomputes the canonical digest | | SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation handshake failure | — | | SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation blocked with zero clients | — | | SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — | diff --git a/scripts/generate-service-worker-assets.ts b/scripts/generate-service-worker-assets.ts index 543a7b0..d89e9ac 100644 --- a/scripts/generate-service-worker-assets.ts +++ b/scripts/generate-service-worker-assets.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; + +import { canonicalStaticManifestBytes } from "../src/contracts/service-worker-static-manifest.ts"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -74,32 +76,21 @@ export async function collectStaticAssets( throw new Error("Static asset set exceeds its byte bound."); } - // The set digest is a length-prefixed hash over the sorted asset identities, - // so a reordered directory listing cannot change it. - const hash = createHash("sha256"); - hash.update("CA_STATIC_ASSET_SET_V1\0"); - for (const asset of assets) { - hash.update(lengthPrefixed(asset.url)); - hash.update(lengthPrefixed(asset.sha256)); - hash.update(lengthPrefixed(String(asset.bytes))); - hash.update(lengthPrefixed(asset.contentType)); - } + // SW-05. The canonical byte serialization lives in the shared runtime-neutral + // codec so the worker can recompute the identical digest with WebCrypto. + const setDigest: `sha256:${string}` = `sha256:${createHash("sha256") + .update(canonicalStaticManifestBytes(assets)) + .digest("hex")}`; return { schemaVersion: 1, buildId, releaseId, - setDigest: `sha256:${hash.digest("hex")}`, + setDigest, assets, }; } -function lengthPrefixed(value: string): Buffer { - const bytes = Buffer.from(value, "utf8"); - const prefix = Buffer.alloc(4); - prefix.writeUInt32BE(bytes.byteLength, 0); - return Buffer.concat([prefix, bytes]); -} async function walk(root: string, current: string): Promise { const entries = await readdir(current, { withFileTypes: true }); diff --git a/scripts/lib/service-worker-build-input.ts b/scripts/lib/service-worker-build-input.ts index 62e2cc9..2315f31 100644 --- a/scripts/lib/service-worker-build-input.ts +++ b/scripts/lib/service-worker-build-input.ts @@ -1,3 +1,10 @@ +import { createHash } from "node:crypto"; + +import { + canonicalStaticManifestBytes, + decodeStaticAssetManifest, +} from "../../src/contracts/service-worker-static-manifest.ts"; + import type { InstalledServiceWorkerSelection, ServiceWorkerHandlerId, @@ -79,19 +86,28 @@ export function resolveServiceWorkerBuildInput(input: Readonly<{ }); } +/** + * SW-05. The build gate no longer type-casts the manifest. It decodes every row + * through the shared runtime-neutral codec and recomputes the set digest from + * the same canonical bytes the generator hashed, so a tampered row, a reordered + * set or a stale digest fails admission instead of shipping. + */ function parseAssets(value: unknown): StaticAssetManifestV1 { - const candidate = record(value); - if ( - candidate?.schemaVersion !== 1 || - typeof candidate.buildId !== "string" || - typeof candidate.releaseId !== "string" || - typeof candidate.setDigest !== "string" || - !DIGEST.test(candidate.setDigest) || - !Array.isArray(candidate.assets) - ) { - throw new TypeError("Generated Service Worker asset manifest is invalid."); + const decoded = decodeStaticAssetManifest(value); + if (!decoded.ok) { + throw new TypeError( + `Generated Service Worker asset manifest is invalid: ${decoded.error.reason}`, + ); } - return candidate as unknown as StaticAssetManifestV1; + const expected = `sha256:${createHash("sha256") + .update(canonicalStaticManifestBytes(decoded.manifest.assets)) + .digest("hex")}`; + if (expected !== decoded.manifest.setDigest) { + throw new TypeError( + "Generated Service Worker asset manifest set digest does not match its assets.", + ); + } + return decoded.manifest as unknown as StaticAssetManifestV1; } function record(value: unknown): Record | null { diff --git a/src/adapters/service-worker/service-worker-entry.ts b/src/adapters/service-worker/service-worker-entry.ts index 2db48b8..5d269a9 100644 --- a/src/adapters/service-worker/service-worker-entry.ts +++ b/src/adapters/service-worker/service-worker-entry.ts @@ -40,7 +40,8 @@ const scope: WorkerScopeLike = { open: (name) => caches.open(name), keys: () => caches.keys(), delete: (name) => caches.delete(name), - match: (request) => caches.match(request), + // SW-01. No CacheStorage-wide match: only the current release cache may + // answer a verified static request. }, clients: { matchAll: (options) => diff --git a/src/adapters/service-worker/service-worker-lifecycle.ts b/src/adapters/service-worker/service-worker-lifecycle.ts index e93239c..28f0c85 100644 --- a/src/adapters/service-worker/service-worker-lifecycle.ts +++ b/src/adapters/service-worker/service-worker-lifecycle.ts @@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{ open(cacheName: string): Promise; keys(): Promise; delete(cacheName: string): Promise; - match(request: string): Promise; }>; clients: Readonly<{ matchAll( @@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{ releaseManifestUrl: string; }>; +/** + * SW-URL-01. Root-relative generated URLs become absolute same-origin URLs + * exactly once. Anything that escapes the scope origin is dropped rather than + * silently classified. + */ +function canonicalManifestUrls( + assets: readonly Readonly<{ url: string }>[], + scopeHref: string, +): readonly string[] { + let base: URL; + try { + base = new URL(scopeHref); + } catch { + return []; + } + const canonical: string[] = []; + for (const asset of assets) { + try { + const absolute = new URL(asset.url, base); + if (absolute.origin !== base.origin) continue; + canonical.push(absolute.href); + } catch { + // A manifest URL that cannot be canonicalized is never classified. + } + } + return canonical; +} + const ACTIVATION_MARKER_URL = "https://clean-architecture.invalid/__service-worker-activation-v1__"; const ACTIVATION_MARKER_MAX_BYTES = 256; @@ -73,8 +100,22 @@ export function createServiceWorkerRuntime( config: WorkerRuntimeConfig, ) { const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS"); - const manifestUrls = new Set( - staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [], + /** + * SW-URL-01. The generated manifest stores root-relative URLs while `Request` + * exposes absolute ones, so comparing the two directly classified every + * verified asset as a network fallback. Canonicalize once against the + * registration scope, re-check same-origin, and share that identity across + * install cache keys, fetch classification and cache lookup or delete. + */ + const manifestUrls: ReadonlySet = Object.freeze( + new Set( + staticEnabled + ? canonicalManifestUrls( + config.manifest?.assets ?? [], + scope.registrationScope, + ) + : [], + ), ); const consumedNonces = new Set(); type PendingActivation = Readonly<{ @@ -176,17 +217,25 @@ export function createServiceWorkerRuntime( }); if (classification !== "VERIFIED_CACHE_FIRST") return null; - const cached = await scope.caches.match(request.url); + // SW-01. Only the current release cache may answer. A CacheStorage-wide + // match could return a previous release's response for the same URL, and + // the subsequent delete would then target a cache that was never read. + const currentCacheName = config.manifest + ? staticCacheName(config.manifest.setDigest) + : null; + if (!currentCacheName) return null; + let currentCache: Cache; + try { + currentCache = await scope.caches.open(currentCacheName); + } catch { + return null; + } + const cached = await currentCache.match(request.url); if (!cached) return null; if (cached.status !== 200 || cached.type === "opaque") { - // §18.6. An invalid hit is deleted and treated as a release mismatch. - const current = config.manifest - ? staticCacheName(config.manifest.setDigest) - : null; - if (current) { - const cache = await scope.caches.open(current); - await cache.delete(request.url).catch(() => false); - } + // §18.6. An invalid hit is deleted from the cache it was read from and + // treated as a release mismatch. + await currentCache.delete(request.url).catch(() => false); return null; } return cached; @@ -347,7 +396,9 @@ export function createServiceWorkerRuntime( let cachesDeleted = 0; const names = await scope.caches.keys(); for (const name of names) { - if (!name.startsWith("ca-static-v1-")) continue; + // SW-02. Exact ownership only: a prefix match would also delete + // `ca-static-v1-not-owned` and any longer-suffixed foreign cache. + if (!isOwnedStaticCacheName(name)) continue; try { if (await scope.caches.delete(name)) cachesDeleted += 1; } catch { diff --git a/src/adapters/service-worker/service-worker-page-controller.ts b/src/adapters/service-worker/service-worker-page-controller.ts index 346ce93..82be3ea 100644 --- a/src/adapters/service-worker/service-worker-page-controller.ts +++ b/src/adapters/service-worker/service-worker-page-controller.ts @@ -2,6 +2,7 @@ import { SERVICE_WORKER_BOUNDS, type InstalledServiceWorkerSelection, type ServiceWorkerActivationOutcome, + type ServiceWorkerRemovalOutcome, type ServiceWorkerResetOutcome, type ServiceWorkerRuntimeHost, type ServiceWorkerStartOutcome, @@ -75,6 +76,29 @@ export function createServiceWorkerPageController( return false; } + /** + * SW-04. Staged removal reports what actually happened. + * + * Returning DISABLED for every outcome let a later release delete the worker + * source and handlers while a registration or an owned cache was still + * present, or while the registration belonged to someone else. + */ + function removalStartOutcome( + outcome: ServiceWorkerRemovalOutcome, + failureReason: string, + ): ServiceWorkerStartOutcome { + switch (outcome.kind) { + case "ABSENT": + case "UNREGISTERED": + case "PURGED": + return Object.freeze({ kind: "DISABLED" as const }); + case "OWNERSHIP_MISMATCH": + return Object.freeze({ kind: "INCOMPATIBLE" as const }); + case "FAILED": + return failed(failureReason); + } + } + async function start(): Promise { if (stopped) return failed("STOPPED"); const container = dependencies.container; @@ -90,8 +114,7 @@ export function createServiceWorkerPageController( origin: dependencies.origin, }); observe("disable_cleanup", outcome.kind); - if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED"); - return Object.freeze({ kind: "DISABLED" as const }); + return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED"); } const selection = dependencies.selection; @@ -108,7 +131,7 @@ export function createServiceWorkerPageController( origin: dependencies.origin, }); observe("remove_registration", outcome.kind); - return Object.freeze({ kind: "DISABLED" as const }); + return removalStartOutcome(outcome, "REMOVE_FAILED"); } if (selection.mode === "PURGE_OWNED_RESOURCES") { const outcome = await purgeOwnedResources({ @@ -118,7 +141,7 @@ export function createServiceWorkerPageController( origin: dependencies.origin, }); observe("purge_owned_resources", outcome.kind); - return Object.freeze({ kind: "DISABLED" as const }); + return removalStartOutcome(outcome, "PURGE_FAILED"); } // §17.5. StrictMode's repeated effect returns the same in-flight promise diff --git a/src/adapters/service-worker/service-worker-removal.ts b/src/adapters/service-worker/service-worker-removal.ts index e7ca8ee..f3f1146 100644 --- a/src/adapters/service-worker/service-worker-removal.ts +++ b/src/adapters/service-worker/service-worker-removal.ts @@ -109,14 +109,24 @@ export async function removeOwnedRegistration( ) { return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const }); } + let unregistered: boolean; try { - await registration.unregister(); + unregistered = await registration.unregister(); } catch { return Object.freeze({ kind: "FAILED" as const, operation: "UNREGISTER" as const, }); } + // SW-03. `unregister()` resolving is not success: `false` means the + // registration is still installed, so reporting UNREGISTERED would let a + // later release delete the worker source while it is still controlling. + if (!unregistered) { + return Object.freeze({ + kind: "FAILED" as const, + operation: "UNREGISTER" as const, + }); + } return Object.freeze({ kind: "UNREGISTERED" as const }); } diff --git a/src/contracts/service-worker-static-manifest.ts b/src/contracts/service-worker-static-manifest.ts new file mode 100644 index 0000000..96f4234 --- /dev/null +++ b/src/contracts/service-worker-static-manifest.ts @@ -0,0 +1,220 @@ +import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts"; + +/** + * SW-05. Runtime-neutral static manifest codec. + * + * The generator, the Node build gate and the Service Worker all need the same + * answer to "is this manifest exactly the one that was generated?". This module + * owns the exact row keys, the content-type and extension allowlist, the + * root-relative URL rule and the length-prefixed canonical byte serialization. + * + * It deliberately contains no digest implementation: the generator and build + * gate hash these bytes with Node SHA-256 while the worker hashes the very same + * bytes with injected WebCrypto, so `node:crypto` never reaches worker code and + * the algorithm is never written twice. + */ + +export type StaticAssetRow = Readonly<{ + url: string; + sha256: string; + bytes: number; + contentType: string; +}>; + +export type StaticAssetManifest = Readonly<{ + schemaVersion: 1; + buildId: string; + releaseId: string; + setDigest: string; + assets: readonly StaticAssetRow[]; +}>; + +export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1"; + +/** Exact allowed extension → content type pairs for a cacheable asset. */ +export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly< + Record +> = Object.freeze({ + ".css": "text/css", + ".js": "text/javascript", + ".json": "application/json", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff2": "font/woff2", +}); + +const MANIFEST_KEYS = Object.freeze([ + "assets", + "buildId", + "releaseId", + "schemaVersion", + "setDigest", +] as const); +const ASSET_ROW_KEYS = Object.freeze([ + "bytes", + "contentType", + "sha256", + "url", +] as const); +const DIGEST = /^sha256:[0-9a-f]{64}$/u; +const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +/** Root-relative, hashed, no dot segments, no query and no fragment. */ +const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u; + +export type StaticManifestDecodeFailure = Readonly<{ + reason: string; +}>; + +export type StaticManifestDecodeResult = + | Readonly<{ ok: true; manifest: StaticAssetManifest }> + | Readonly<{ ok: false; error: StaticManifestDecodeFailure }>; + +function exactKeys( + value: unknown, + allowed: readonly string[], +): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (Object.getOwnPropertySymbols(record).length > 0) return null; + const keys = Object.keys(record).sort(); + return keys.length === allowed.length && + keys.every((key, index) => key === allowed[index]) + ? record + : null; +} + +function extensionOf(url: string): string { + const lastSlash = url.lastIndexOf("/"); + const base = url.slice(lastSlash + 1); + const dot = base.lastIndexOf("."); + return dot < 0 ? "" : base.slice(dot).toLowerCase(); +} + +/** + * Decodes a generated manifest with every row rule applied. It does not verify + * `setDigest`; callers pair it with their own digest implementation over + * `canonicalStaticManifestBytes`. + */ +export function decodeStaticAssetManifest( + value: unknown, +): StaticManifestDecodeResult { + const record = exactKeys(value, MANIFEST_KEYS); + if (!record) return failure("manifest keys are not exact"); + if (record.schemaVersion !== 1) return failure("schemaVersion must be 1"); + if ( + typeof record.buildId !== "string" || + !IDENTITY.test(record.buildId) || + typeof record.releaseId !== "string" || + !IDENTITY.test(record.releaseId) + ) { + return failure("buildId or releaseId is invalid"); + } + if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) { + return failure("setDigest is not a lower-hex sha256"); + } + if (!Array.isArray(record.assets)) return failure("assets must be an array"); + if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) { + return failure("asset count exceeds its bound"); + } + + const rows: StaticAssetRow[] = []; + const seen = new Set(); + let totalBytes = 0; + let previousUrl: string | null = null; + for (const candidate of record.assets) { + const row = exactKeys(candidate, ASSET_ROW_KEYS); + if (!row) return failure("asset row keys are not exact"); + const { url, sha256, bytes, contentType } = row; + if (typeof url !== "string" || !ASSET_URL.test(url)) { + return failure("asset url must be root-relative without dot segments"); + } + if (url.includes("/../") || url.includes("/./")) { + return failure("asset url must not contain dot segments"); + } + if (seen.has(url)) return failure("asset urls must be unique"); + // A sorted set makes the canonical bytes independent of directory order. + if (previousUrl !== null && url <= previousUrl) { + return failure("asset urls must be sorted"); + } + if (typeof sha256 !== "string" || !DIGEST.test(sha256)) { + return failure("asset sha256 is not a lower-hex sha256"); + } + if ( + typeof bytes !== "number" || + !Number.isSafeInteger(bytes) || + bytes < 0 || + bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes + ) { + return failure("asset byte length is invalid"); + } + if (typeof contentType !== "string") { + return failure("asset content type is invalid"); + } + const expectedContentType = + CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)]; + if (!expectedContentType || expectedContentType !== contentType) { + return failure("asset extension and content type do not match"); + } + totalBytes += bytes; + if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) { + return failure("asset set exceeds its byte bound"); + } + seen.add(url); + previousUrl = url; + rows.push(Object.freeze({ url, sha256, bytes, contentType })); + } + + return Object.freeze({ + ok: true as const, + manifest: Object.freeze({ + schemaVersion: 1 as const, + buildId: record.buildId, + releaseId: record.releaseId, + setDigest: record.setDigest, + assets: Object.freeze(rows), + }), + }); +} + +/** + * The exact bytes both the Node generator and the worker hash. A reordered + * directory listing, a renamed field or a changed byte length all change these + * bytes; nothing else does. + */ +export function canonicalStaticManifestBytes( + assets: readonly StaticAssetRow[], +): Uint8Array { + const encoder = new TextEncoder(); + const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)]; + for (const asset of assets) { + parts.push(lengthPrefixed(encoder, asset.url)); + parts.push(lengthPrefixed(encoder, asset.sha256)); + parts.push(lengthPrefixed(encoder, String(asset.bytes))); + parts.push(lengthPrefixed(encoder, asset.contentType)); + } + let total = 0; + for (const part of parts) total += part.byteLength; + const bytes = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array { + const encoded = encoder.encode(value); + const prefix = encoder.encode(`${encoded.byteLength}:`); + const combined = new Uint8Array(prefix.byteLength + encoded.byteLength); + combined.set(prefix, 0); + combined.set(encoded, prefix.byteLength); + return combined; +} + +function failure(reason: string): StaticManifestDecodeResult { + return Object.freeze({ + ok: false as const, + error: Object.freeze({ reason }), + }); +} diff --git a/tests/unit/service-worker-build-input.test.ts b/tests/unit/service-worker-build-input.test.ts index e22b690..5900d45 100644 --- a/tests/unit/service-worker-build-input.test.ts +++ b/tests/unit/service-worker-build-input.test.ts @@ -1,9 +1,31 @@ +import { createHash } from "node:crypto"; + import { describe, expect, it } from "vitest"; import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts"; +import { + canonicalStaticManifestBytes, + type StaticAssetRow, +} from "../../src/contracts/service-worker-static-manifest.ts"; const digest = (character: string) => `sha256:${character.repeat(64)}`; +/** SW-05. The gate recomputes this from the shared canonical bytes. */ +function setDigestFor(rows: readonly StaticAssetRow[]): string { + return `sha256:${createHash("sha256") + .update(canonicalStaticManifestBytes(rows)) + .digest("hex")}`; +} + +const ASSET_ROWS: readonly StaticAssetRow[] = Object.freeze([ + Object.freeze({ + url: "/assets/app.0123456789abcdef.js", + sha256: digest("c"), + bytes: 128, + contentType: "text/javascript", + }), +]); + describe("service worker build input", () => { const selection = { mode: "ACTIVE" as const, @@ -14,8 +36,8 @@ describe("service worker build input", () => { schemaVersion: 1 as const, buildId: "build-1", releaseId: "release-1", - setDigest: digest("a"), - assets: [], + setDigest: setDigestFor(ASSET_ROWS), + assets: [...ASSET_ROWS], }; it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => { @@ -84,4 +106,90 @@ describe("service worker build input", () => { releaseManifestUrl: "/release-manifest.json", }); }); + + it("rejects asset row or canonical set-digest tampering at build input", () => { + const base = { + selection, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }; + const tampered: readonly Readonly<{ + label: string; + assets: unknown; + }>[] = [ + { + label: "stale set digest", + assets: { ...assets, setDigest: digest("a") }, + }, + { + label: "tampered byte length", + assets: { + ...assets, + assets: [{ ...ASSET_ROWS[0]!, bytes: 129 }], + }, + }, + { + label: "cross-origin url", + assets: { + ...assets, + assets: [ + { ...ASSET_ROWS[0]!, url: "https://evil.example/a.js" }, + ], + }, + }, + { + label: "dot segment", + assets: { + ...assets, + assets: [{ ...ASSET_ROWS[0]!, url: "/assets/../a.js" }], + }, + }, + { + label: "extension and content type mismatch", + assets: { + ...assets, + assets: [{ ...ASSET_ROWS[0]!, contentType: "text/css" }], + }, + }, + { + label: "unknown row field", + assets: { + ...assets, + assets: [{ ...ASSET_ROWS[0]!, extra: "smuggled" }], + }, + }, + { + label: "duplicate url", + assets: { + ...assets, + assets: [ASSET_ROWS[0]!, ASSET_ROWS[0]!], + }, + }, + ]; + for (const entry of tampered) { + expect( + () => + resolveServiceWorkerBuildInput({ + ...base, + assets: entry.assets as never, + }), + entry.label, + ).toThrow(TypeError); + } + }); + + it("accepts generator-shaped output unchanged", () => { + expect(() => + resolveServiceWorkerBuildInput({ + selection, + assets, + contractSet: { setDigest: digest("b") }, + runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, + buildId: "build-1", + releaseId: "release-1", + }), + ).not.toThrow(); + }); }); diff --git a/tests/unit/service-worker-runtime.test.ts b/tests/unit/service-worker-runtime.test.ts index 4513d2e..04bd7aa 100644 --- a/tests/unit/service-worker-runtime.test.ts +++ b/tests/unit/service-worker-runtime.test.ts @@ -118,6 +118,253 @@ function workerScope() { return { scope, clients, deleted }; } +describe("service worker static cache authority", () => { + const setDigest = `sha256:${"c".repeat(64)}` as const; + const currentCacheName = `${STATIC_CACHE_PREFIX}${setDigest.slice( + "sha256:".length, + "sha256:".length + 16, + )}`; + + function staticRuntime( + caches: Readonly<{ + open: (name: string) => Promise; + keys: () => Promise; + delete: (name: string) => Promise; + }>, + ) { + return createServiceWorkerRuntime( + { + caches, + clients: { matchAll: vi.fn(async () => []) }, + registrationScope: `${ORIGIN}/app/`, + skipWaiting: vi.fn(async () => {}), + fetcher: vi.fn(), + digest: vi.fn(), + } as never, + { + identity: { ...identity, staticAssetSetDigest: setDigest }, + handlers: ["PWA_STATIC_ASSETS"], + manifest: { + schemaVersion: 1, + buildId: identity.buildId, + releaseId: identity.releaseId, + setDigest, + // SW-URL-01. Generator output is root-relative. + assets: [ + { + url: "/assets/app.0123456789abcdef.js", + sha256: `sha256:${"d".repeat(64)}`, + bytes: 10, + contentType: "text/javascript", + }, + ], + }, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }, + ); + } + + it("classifies a generated root-relative asset against an absolute Request URL", async () => { + const currentResponse = new Response("current", { status: 200 }); + const opened: string[] = []; + const runtime = staticRuntime({ + open: vi.fn(async (name: string) => { + opened.push(name); + return { + match: async () => currentResponse.clone(), + delete: async () => true, + }; + }), + keys: vi.fn(async () => [currentCacheName]), + delete: vi.fn(async () => true), + }); + + const served = await runtime.onFetch({ + method: "GET", + url: `${ORIGIN}/assets/app.0123456789abcdef.js`, + }); + expect(served).not.toBeNull(); + expect(opened).toEqual([currentCacheName]); + }); + + it("matches static responses only in the current release cache", async () => { + const previousCacheName = `${STATIC_CACHE_PREFIX}${"e".repeat(16)}`; + const previousMatch = vi.fn( + async () => new Response("previous", { status: 200 }), + ); + const runtime = staticRuntime({ + open: vi.fn( + async ( + name: string, + ): Promise> => + name === previousCacheName + ? { + match: previousMatch, + delete: async (): Promise => true, + } + : { + match: async (): Promise => undefined, + delete: async (): Promise => true, + }, + ), + keys: vi.fn(async () => [currentCacheName, previousCacheName]), + delete: vi.fn(async () => true), + }); + + // Only the previous cache holds the entry, so the request falls through to + // the network rather than serving a stale release. + await expect( + runtime.onFetch({ + method: "GET", + url: `${ORIGIN}/assets/app.0123456789abcdef.js`, + }), + ).resolves.toBeNull(); + expect(previousMatch).not.toHaveBeenCalled(); + }); + + it("deletes an invalid hit only from the current release cache", async () => { + const deletes: string[] = []; + const runtime = staticRuntime({ + open: vi.fn(async (name: string) => ({ + match: async () => new Response("bad", { status: 500 }), + delete: async (url: string): Promise => { + deletes.push(`${name}:${url}`); + return true; + }, + })), + keys: vi.fn(async () => [currentCacheName]), + delete: vi.fn(async () => true), + }); + + await expect( + runtime.onFetch({ + method: "GET", + url: `${ORIGIN}/assets/app.0123456789abcdef.js`, + }), + ).resolves.toBeNull(); + expect(deletes).toEqual([ + `${currentCacheName}:${ORIGIN}/assets/app.0123456789abcdef.js`, + ]); + }); +}); + +describe("service worker exact ownership and truthful removal", () => { + it("deletes only exact owned static cache names", async () => { + const fixture = workerScope(); + fixture.scope.caches.keys = vi.fn(async () => [ + `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, + `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, + // SW-02. Same prefix, not owned. + `${STATIC_CACHE_PREFIX}not-owned`, + `${STATIC_CACHE_PREFIX}${"c".repeat(17)}`, + `${STATIC_CACHE_PREFIX}${"A".repeat(16)}`, + "foreign-cache", + ]); + const runtime = createServiceWorkerRuntime(fixture.scope as never, { + identity, + handlers: [], + manifest: null, + runtimeConfigUrl: "/runtime-config.json", + releaseManifestUrl: "/release-manifest.json", + }); + const request = createServiceWorkerMessage({ + kind: "CACHE_RESET_REQUEST", + sourceBuildId: "page-build", + targetBuildId: identity.buildId, + nonce: "nonce-reset-0001", + }); + await runtime.onCacheResetRequest(request, fixture.clients[0] as never); + + expect(fixture.deleted).toEqual([ + `${STATIC_CACHE_PREFIX}${"a".repeat(16)}`, + `${STATIC_CACHE_PREFIX}${"b".repeat(16)}`, + ]); + }); + it.each([ + { + label: "unregister false", + unregister: async () => false, + expected: { kind: "FAILED" }, + }, + { + label: "unregister rejects", + unregister: async () => { + throw new TypeError("unregister exploded"); + }, + expected: { kind: "FAILED" }, + }, + { + label: "unregister true", + unregister: async () => true, + expected: { kind: "DISABLED" }, + }, + ])( + "does not hide $label behind DISABLED", + async ({ unregister, expected }) => { + const registration = { + scope: `${ORIGIN}/`, + installing: null, + waiting: null, + active: { scriptURL: SCRIPT_URL }, + unregister: vi.fn(unregister), + update: vi.fn(async () => {}), + } as unknown as ServiceWorkerRegistration; + const controller = createServiceWorkerPageController({ + container: { + controller: null, + register: vi.fn(), + getRegistration: vi.fn(async () => registration), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as never, + routerBasePath: "/", + origin: ORIGIN, + selection: { + mode: "REMOVE_REGISTRATION", + scriptPath: "service-worker.js", + handlers: [], + }, + } as never); + + await expect(controller.start()).resolves.toMatchObject(expected); + }, + ); + + it("reports an ownership mismatch as INCOMPATIBLE rather than DISABLED", async () => { + const foreign = { + scope: `${ORIGIN}/`, + installing: null, + waiting: null, + active: { scriptURL: `${ORIGIN}/someone-else.js` }, + unregister: vi.fn(async () => true), + update: vi.fn(async () => {}), + } as unknown as ServiceWorkerRegistration; + const controller = createServiceWorkerPageController({ + container: { + controller: null, + register: vi.fn(), + getRegistration: vi.fn(async () => foreign), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as never, + routerBasePath: "/", + origin: ORIGIN, + selection: { + mode: "REMOVE_REGISTRATION", + scriptPath: "service-worker.js", + handlers: [], + }, + } as never); + + await expect(controller.start()).resolves.toMatchObject({ + kind: "INCOMPATIBLE", + }); + expect(foreign.unregister).not.toHaveBeenCalled(); + }); + +}); + describe("service worker page protocol", () => { it("does not attach late listeners when stopped during registration", async () => { const browser = pageContainer();