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"; /** * SW-RR-03. The single authoritative extension → content type table. * * The build generator and this decoder must agree exactly: an extension the * generator emits but the decoder refuses turns a correct build into a runtime * contract failure, and the reverse admits an asset kind no build produces. * `.json` is deliberately absent — every JSON file in a build output is a * control document (runtime config, release manifest, schema), not a cacheable * static asset, and the generator excludes them by name. */ export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly< Record > = Object.freeze({ ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript", ".png": "image/png", ".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; /** * SW-02. The one canonical asset-path predicate, shared by the build generator * and this decoder. Sharing only the extension table left the two with * different path grammars: the generator emitted a URL for a directory * containing a space, an `@` or a percent-escape, and the decoder then refused * the manifest it had just produced, failing the release build. */ export function isCanonicalStaticAssetUrl(url: string): boolean { return ( typeof url === "string" && ASSET_URL.test(url) && !url.includes("/../") && !url.includes("/./") ); } 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" || !isCanonicalStaticAssetUrl(url)) { return failure("asset url must be root-relative without 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 }), }); }