diff --git a/src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts b/src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts new file mode 100644 index 00000000000..4cc356026f3 --- /dev/null +++ b/src/shared/mobile-web-bundle/bundle-rpc-contract.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import { sha256 } from '../sha256' +import { + computeMobileWebBundleId, + MOBILE_WEB_BUNDLE_ENTRYPOINT, + MOBILE_WEB_BUNDLE_SCHEMA_VERSION, + type MobileWebBundleAsset +} from './manifest-contract' +import { + MobileWebBundleChunkParamsSchema, + MobileWebBundleChunkResultSchema, + MobileWebBundleErrorCodeSchema, + MobileWebBundleManifestParamsSchema, + MobileWebBundleManifestResultSchema, + MOBILE_WEB_BUNDLE_CHUNK_BYTES, + MOBILE_WEB_BUNDLE_CHUNK_METHOD, + MOBILE_WEB_BUNDLE_ERROR_CODES, + MOBILE_WEB_BUNDLE_MANIFEST_METHOD +} from './bundle-rpc-contract' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from './mobile-web-bundle-capability' + +const BUILD_ID = 'a'.repeat(64) +const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8 + +function hexDigest(input: string): string { + return Array.from(sha256(new TextEncoder().encode(input)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') +} + +const ENTRY_ASSET: MobileWebBundleAsset = { + path: MOBILE_WEB_BUNDLE_ENTRYPOINT, + sha256: hexDigest(MOBILE_WEB_BUNDLE_ENTRYPOINT), + byteLength: 64, + contentType: 'text/html; charset=utf-8' +} + +const VALID_MANIFEST = { + schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION, + buildId: computeMobileWebBundleId([ENTRY_ASSET]), + desktopVersion: '1.4.200', + minCompatibleRuntimeProtocolVersion: 3, + runtimeProtocolVersion: 3, + entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT, + totalBytes: ENTRY_ASSET.byteLength, + assets: [ENTRY_ASSET] +} + +function chunkResult(overrides: Record = {}): Record { + return { + buildId: BUILD_ID, + path: 'assets/a.js', + offset: 0, + assetByteLength: 1024, + sha256: 'b'.repeat(64), + dataBase64: 'AAAA', + eof: true, + ...overrides + } +} + +describe('names and sizes', () => { + it('pins the wire constants', () => { + expect(MOBILE_WEB_BUNDLE_CHUNK_BYTES).toBe(49152) + expect(MOBILE_WEB_BUNDLE_MANIFEST_METHOD).toBe('mobileWeb.bundle.manifest') + expect(MOBILE_WEB_BUNDLE_CHUNK_METHOD).toBe('mobileWeb.bundle.chunk') + expect(MOBILE_WEB_BUNDLE_CAPABILITY).toBe('mobileWeb.bundle.v1') + }) +}) + +describe('MobileWebBundleErrorCodeSchema', () => { + it('round-trips every code', () => { + expect(MOBILE_WEB_BUNDLE_ERROR_CODES).toHaveLength(6) + for (const code of MOBILE_WEB_BUNDLE_ERROR_CODES) { + expect(MobileWebBundleErrorCodeSchema.parse(code)).toBe(code) + } + }) + + it('is closed', () => { + expect(MobileWebBundleErrorCodeSchema.safeParse('mobile_web_bundle_unknown').success).toBe( + false + ) + expect(MobileWebBundleErrorCodeSchema.safeParse('').success).toBe(false) + }) +}) + +describe('mobileWeb.bundle.manifest payloads', () => { + it('takes null params', () => { + expect(MobileWebBundleManifestParamsSchema.safeParse(null).success).toBe(true) + expect(MobileWebBundleManifestParamsSchema.safeParse({}).success).toBe(false) + }) + + it('carries a parsed manifest and the advertised chunk size', () => { + const reply = { manifest: VALID_MANIFEST, chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES } + const parsed = MobileWebBundleManifestResultSchema.safeParse(reply) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.manifest.buildId).toBe(VALID_MANIFEST.buildId) + expect(MobileWebBundleManifestResultSchema.safeParse({ ...reply, manifest: {} }).success).toBe( + false + ) + expect(MobileWebBundleManifestResultSchema.safeParse({ ...reply, extra: 1 }).success).toBe( + false + ) + }) + + it('allows a shrunk chunk size but not one past the constant', () => { + const reply = { manifest: VALID_MANIFEST, chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES } + expect( + MobileWebBundleManifestResultSchema.safeParse({ ...reply, chunkBytes: 8 * 1024 }).success + ).toBe(true) + expect( + MobileWebBundleManifestResultSchema.safeParse({ + ...reply, + chunkBytes: MOBILE_WEB_BUNDLE_CHUNK_BYTES + 1 + }).success + ).toBe(false) + expect(MobileWebBundleManifestResultSchema.safeParse({ ...reply, chunkBytes: 0 }).success).toBe( + false + ) + }) +}) + +describe('mobileWeb.bundle.chunk params', () => { + const params = { buildId: BUILD_ID, path: 'assets/a.js', offset: 0 } + + it('accepts a well-formed request', () => { + expect(MobileWebBundleChunkParamsSchema.safeParse(params).success).toBe(true) + }) + + it('is strict and bounded', () => { + expect(MobileWebBundleChunkParamsSchema.safeParse({ ...params, gzip: true }).success).toBe( + false + ) + expect(MobileWebBundleChunkParamsSchema.safeParse({ ...params, offset: -1 }).success).toBe( + false + ) + expect(MobileWebBundleChunkParamsSchema.safeParse({ ...params, offset: 1.5 }).success).toBe( + false + ) + expect( + MobileWebBundleChunkParamsSchema.safeParse({ ...params, path: '../escape.js' }).success + ).toBe(false) + expect(MobileWebBundleChunkParamsSchema.safeParse({ ...params, buildId: 'abc' }).success).toBe( + false + ) + }) + + it('does not pin offset to the constant chunk size, so the host may shrink it', () => { + expect(MobileWebBundleChunkParamsSchema.safeParse({ ...params, offset: 1024 }).success).toBe( + true + ) + }) +}) + +describe('mobileWeb.bundle.chunk result', () => { + it('accepts a self-describing chunk', () => { + expect(MobileWebBundleChunkResultSchema.safeParse(chunkResult()).success).toBe(true) + }) + + it('accepts base64 of a full chunk and rejects one character past the bound', () => { + const full = Buffer.alloc(MOBILE_WEB_BUNDLE_CHUNK_BYTES).toString('base64') + expect(full.length).toBeLessThanOrEqual(MAX_DATA_BASE64_LENGTH) + expect( + MobileWebBundleChunkResultSchema.safeParse(chunkResult({ dataBase64: full })).success + ).toBe(true) + + const atBound = 'A'.repeat(MAX_DATA_BASE64_LENGTH) + expect( + MobileWebBundleChunkResultSchema.safeParse(chunkResult({ dataBase64: atBound })).success + ).toBe(true) + expect( + MobileWebBundleChunkResultSchema.safeParse(chunkResult({ dataBase64: `${atBound}A` })).success + ).toBe(false) + }) + + it('leaves the padding slack the +8 term buys, so the host enforces the chunk size', () => { + const overshoot = Buffer.alloc(MOBILE_WEB_BUNDLE_CHUNK_BYTES + 1).toString('base64') + expect(overshoot.length).toBeLessThanOrEqual(MAX_DATA_BASE64_LENGTH) + const wellPast = Buffer.alloc(MOBILE_WEB_BUNDLE_CHUNK_BYTES + 64).toString('base64') + expect( + MobileWebBundleChunkResultSchema.safeParse(chunkResult({ dataBase64: wellPast })).success + ).toBe(false) + }) + + it('is strict and requires every echoed field', () => { + expect( + MobileWebBundleChunkResultSchema.safeParse(chunkResult({ contentEncoding: 'gzip' })).success + ).toBe(false) + for (const key of [ + 'buildId', + 'path', + 'offset', + 'assetByteLength', + 'sha256', + 'dataBase64', + 'eof' + ]) { + const partial = chunkResult() + delete partial[key] + expect(MobileWebBundleChunkResultSchema.safeParse(partial).success).toBe(false) + } + }) +}) diff --git a/src/shared/mobile-web-bundle/bundle-rpc-contract.ts b/src/shared/mobile-web-bundle/bundle-rpc-contract.ts new file mode 100644 index 00000000000..08b4aa88a72 --- /dev/null +++ b/src/shared/mobile-web-bundle/bundle-rpc-contract.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import { hostUnionArms } from '../zod-salvage' +import { + MobileWebBundleAssetPathSchema, + MobileWebBundleManifestSchema, + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES +} from './manifest-contract' + +/** 48 KiB survives the compounded ~1.78x expansion (base64 body inside a base64 mobile E2EE reply) + * against the 1 MiB frame ceiling on both the WebSocket and relay transports. */ +export const MOBILE_WEB_BUNDLE_CHUNK_BYTES = 48 * 1024 + +export const MOBILE_WEB_BUNDLE_MANIFEST_METHOD = 'mobileWeb.bundle.manifest' +export const MOBILE_WEB_BUNDLE_CHUNK_METHOD = 'mobileWeb.bundle.chunk' + +const SHA256_PATTERN = /^[a-f0-9]{64}$/ +const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8 + +export type MobileWebBundleErrorCode = + | 'mobile_web_bundle_unavailable' + | 'mobile_web_bundle_build_changed' + | 'mobile_web_bundle_asset_unknown' + | 'mobile_web_bundle_asset_changed' + | 'mobile_web_bundle_offset_invalid' + | 'mobile_web_bundle_read_limited' + +/** Coverage record, so tsc fails on an arm added to the union without a schema arm and vice versa. */ +export const MOBILE_WEB_BUNDLE_ERROR_CODES = hostUnionArms({ + mobile_web_bundle_unavailable: true, + mobile_web_bundle_build_changed: true, + mobile_web_bundle_asset_unknown: true, + mobile_web_bundle_asset_changed: true, + mobile_web_bundle_offset_invalid: true, + mobile_web_bundle_read_limited: true +}) + +export const MobileWebBundleErrorCodeSchema = z.enum(MOBILE_WEB_BUNDLE_ERROR_CODES) + +export const MobileWebBundleManifestParamsSchema = z.null() + +export const MobileWebBundleManifestResultSchema = z + .object({ + manifest: MobileWebBundleManifestSchema, + /** Read, never assumed, so the host can shrink it without a client release. Capped at the + * constant because a larger value would overshoot the chunk reply's `dataBase64` bound. */ + chunkBytes: z.number().int().positive().max(MOBILE_WEB_BUNDLE_CHUNK_BYTES) + }) + .strict() + +/** No `multipleOf` pin on `offset`: alignment is against the host's advertised `chunkBytes`, which + * may be smaller than the constant, so the host rejects a misaligned offset instead. */ +export const MobileWebBundleChunkParamsSchema = z + .object({ + buildId: z.string().regex(SHA256_PATTERN), + path: MobileWebBundleAssetPathSchema, + offset: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES) + }) + .strict() + +/** Strict, so a later `contentEncoding` is only a Rule 1 optional-field addition for clients whose + * own reply readers are not strict. */ +export const MobileWebBundleChunkResultSchema = z + .object({ + buildId: z.string().regex(SHA256_PATTERN), + path: MobileWebBundleAssetPathSchema, + offset: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + /** The whole asset, not this chunk: named for it so a reassembler cannot misread the two, and + * paired with `sha256` it describes the asset without a second index. */ + assetByteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + sha256: z.string().regex(SHA256_PATTERN), + dataBase64: z.string().max(MAX_DATA_BASE64_LENGTH), + eof: z.boolean() + }) + .strict() + +export type MobileWebBundleManifestParams = z.infer +export type MobileWebBundleManifestResult = z.infer +export type MobileWebBundleChunkParams = z.infer +export type MobileWebBundleChunkResult = z.infer diff --git a/src/shared/mobile-web-bundle/manifest-contract.test.ts b/src/shared/mobile-web-bundle/manifest-contract.test.ts new file mode 100644 index 00000000000..350197fa8da --- /dev/null +++ b/src/shared/mobile-web-bundle/manifest-contract.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest' +import { sha256 } from '../sha256' +import { + computeMobileWebBundleId, + serializeMobileWebBundleAssets, + MobileWebBundleManifestSchema, + MOBILE_WEB_BUNDLE_ENTRYPOINT, + MOBILE_WEB_BUNDLE_MAX_ASSETS, + MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, + MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, + MOBILE_WEB_BUNDLE_SCHEMA_VERSION, + type MobileWebBundleAsset +} from './manifest-contract' + +function hexDigest(input: string): string { + return Array.from(sha256(new TextEncoder().encode(input)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') +} + +function asset(path: string, byteLength: number): MobileWebBundleAsset { + return { + path, + sha256: hexDigest(path), + byteLength, + contentType: path.endsWith('.html') ? 'text/html; charset=utf-8' : 'text/javascript' + } +} + +const ENTRY = asset(MOBILE_WEB_BUNDLE_ENTRYPOINT, 64) + +function manifestOf( + assets: readonly MobileWebBundleAsset[], + overrides: Record = {} +): Record { + const sorted = [...assets].sort((left, right) => (left.path < right.path ? -1 : 1)) + return { + schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION, + buildId: computeMobileWebBundleId(sorted), + desktopVersion: '1.4.200', + minCompatibleRuntimeProtocolVersion: 3, + runtimeProtocolVersion: 3, + entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT, + totalBytes: sorted.reduce((sum, entry) => sum + entry.byteLength, 0), + assets: sorted, + ...overrides + } +} + +function assetsTotalling(count: number, totalBytes: number): MobileWebBundleAsset[] { + const others = Array.from({ length: count - 1 }, (_, index) => + asset(`assets/${String(index).padStart(3, '0')}.js`, 0) + ) + return [{ ...ENTRY, byteLength: totalBytes }, ...others] +} + +describe('serializeMobileWebBundleAssets', () => { + const assets = [ENTRY, asset('assets/a.js', 10), asset('assets/b.js', 20)] + + it('is stable under reordered input', () => { + const reversed = assets.toReversed() + const rotated = [assets[1], assets[2], assets[0]] + expect(serializeMobileWebBundleAssets(reversed)).toBe(serializeMobileWebBundleAssets(assets)) + expect(computeMobileWebBundleId(rotated)).toBe(computeMobileWebBundleId(assets)) + expect(computeMobileWebBundleId(reversed)).toMatch(/^[a-f0-9]{64}$/) + }) + + it('serializes in path order with a fixed key order', () => { + expect(serializeMobileWebBundleAssets(assets.toReversed())).toBe( + JSON.stringify([assets[1], assets[2], assets[0]]) + ) + }) + + it('changes the id when any hashed field changes', () => { + const base = computeMobileWebBundleId(assets) + expect(computeMobileWebBundleId([...assets.slice(1), { ...ENTRY, byteLength: 65 }])).not.toBe( + base + ) + expect( + computeMobileWebBundleId([...assets.slice(1), { ...ENTRY, contentType: 'text/plain' }]) + ).not.toBe(base) + expect(computeMobileWebBundleId(assets.slice(1))).not.toBe(base) + }) +}) + +describe('MobileWebBundleManifestSchema', () => { + it('accepts a well-formed manifest', () => { + expect(MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY])).success).toBe(true) + }) + + it('rejects an unknown key', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY], { bridge: {} })).success + ).toBe(false) + }) + + it('rejects another schema version', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY], { schemaVersion: 2 })).success + ).toBe(false) + }) +}) + +describe('contract ceilings', () => { + it('accepts the asset count ceiling and rejects one past it', () => { + const atCeiling = assetsTotalling(MOBILE_WEB_BUNDLE_MAX_ASSETS, 64) + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(atCeiling)).success).toBe(true) + const overCeiling = [...atCeiling, asset('assets/overflow.js', 0)] + expect(overCeiling).toHaveLength(MOBILE_WEB_BUNDLE_MAX_ASSETS + 1) + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(overCeiling)).success).toBe(false) + }) + + it('accepts the per-asset ceiling and rejects one byte past it', () => { + const atCeiling = [{ ...ENTRY, byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES }] + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(atCeiling)).success).toBe(true) + const overCeiling = [{ ...ENTRY, byteLength: MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES + 1 }] + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(overCeiling)).success).toBe(false) + }) + + it('accepts the total ceiling and rejects one byte past it', () => { + const perAsset = MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES / 4 + const atCeiling = [ + { ...ENTRY, byteLength: perAsset }, + asset('assets/a.js', perAsset), + asset('assets/b.js', perAsset), + asset('assets/c.js', perAsset) + ] + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(atCeiling)).success).toBe(true) + const overCeiling = [...atCeiling.slice(1), { ...ENTRY, byteLength: perAsset + 1 }] + expect(MobileWebBundleManifestSchema.safeParse(manifestOf(overCeiling)).success).toBe(false) + }) +}) + +describe('manifest invariants', () => { + const twoAssets = [ENTRY, asset('assets/a.js', 10)] + + it('rejects an unsorted or duplicated asset list', () => { + const reversed = [...twoAssets].sort((left, right) => (left.path < right.path ? 1 : -1)) + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf(twoAssets, { assets: reversed })).success + ).toBe(false) + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf(twoAssets, { assets: [ENTRY, ENTRY] })) + .success + ).toBe(false) + }) + + it('rejects a totalBytes that disagrees with the asset sum', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf(twoAssets, { totalBytes: 0 })).success + ).toBe(false) + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf(twoAssets, { totalBytes: 75 })).success + ).toBe(false) + }) + + it('rejects a manifest whose entrypoint is not listed', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([asset('assets/a.js', 10)])).success + ).toBe(false) + }) + + it('rejects paths that collide when case is folded', () => { + const parsed = MobileWebBundleManifestSchema.safeParse( + manifestOf([ENTRY, asset('assets/A.js', 10), asset('assets/a.js', 10)]) + ) + expect(parsed.success).toBe(false) + // Both sort strictly ascending, so it must be the fold check that fires, not the order check. + expect(parsed.error?.issues.map((issue) => issue.message)).toEqual([ + 'asset paths must not collide when case is folded' + ]) + }) + + it('rejects a buildId that is not the content hash of the assets', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf(twoAssets, { buildId: 'f'.repeat(64) })) + .success + ).toBe(false) + // A stale id: correct for a previous asset list, so only the recomputation catches it. + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf(twoAssets, { buildId: computeMobileWebBundleId([ENTRY]) }) + ).success + ).toBe(false) + }) + + it('rejects an inverted protocol window', () => { + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf(twoAssets, { minCompatibleRuntimeProtocolVersion: 4 }) + ).success + ).toBe(false) + }) +}) + +describe('refinement short-circuit', () => { + const WRONG_BUILD_ID = 'f'.repeat(64) + + function issuePaths(manifest: Record): string[] { + const parsed = MobileWebBundleManifestSchema.safeParse(manifest) + expect(parsed.success).toBe(false) + return (parsed.error?.issues ?? []).map((issue) => issue.path.join('.')) + } + + it('reports buildId when every cheaper invariant holds', () => { + const withinCeiling = assetsTotalling(MOBILE_WEB_BUNDLE_MAX_ASSETS, 64) + expect(issuePaths(manifestOf(withinCeiling, { buildId: WRONG_BUILD_ID }))).toEqual(['buildId']) + }) + + it('does not hash an oversized asset list', () => { + const overCeiling = [ + ...assetsTotalling(MOBILE_WEB_BUNDLE_MAX_ASSETS, 64), + asset('assets/overflow.js', 0) + ] + const paths = issuePaths(manifestOf(overCeiling, { buildId: WRONG_BUILD_ID })) + expect(paths).toEqual(['assets']) + expect(paths).not.toContain('buildId') + }) + + it('does not hash once a cheaper invariant has failed', () => { + const twoAssets = [ENTRY, asset('assets/a.js', 10)] + expect(issuePaths(manifestOf(twoAssets, { totalBytes: 0, buildId: WRONG_BUILD_ID }))).toEqual([ + 'totalBytes' + ]) + expect( + issuePaths( + manifestOf(twoAssets, { + minCompatibleRuntimeProtocolVersion: 4, + buildId: WRONG_BUILD_ID + }) + ) + ).toEqual(['minCompatibleRuntimeProtocolVersion']) + }) +}) + +describe('asset paths', () => { + it.each([ + '../escape.js', + 'assets/../../escape.js', + '/absolute.js', + 'assets\\escape.js', + 'a/./b.js', + 'assets/nul.js', + 'assets/CON', + 'assets/lpt1.js', + 'assets/foo.', + 'assets/...' + ])('rejects %s', (path) => { + const parsed = MobileWebBundleManifestSchema.safeParse( + manifestOf([ENTRY, { ...asset('assets/a.js', 10), path }]) + ) + expect(parsed.success).toBe(false) + }) + + it('accepts exactly one spelling of a parameterised content type', () => { + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf([{ ...ENTRY, contentType: 'text/html; charset=utf-8' }]) + ).success + ).toBe(true) + // A2's builder emits the single-space form; the other spellings are the same bytes under a + // different build id. + for (const contentType of ['text/html;charset=utf-8', 'text/html; charset=utf-8']) { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([{ ...ENTRY, contentType }])).success + ).toBe(false) + } + }) + + it('rejects an uppercase content type, which would give the same bytes two ids', () => { + for (const contentType of ['Text/HTML; charset=utf-8', 'text/JavaScript', 'TEXT/PLAIN']) { + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf([ENTRY, { ...asset('assets/a.js', 10), contentType }]) + ).success + ).toBe(false) + } + }) + + it('rejects a malformed sha256 or content type', () => { + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf([ENTRY, { ...asset('assets/a.js', 10), sha256: 'AB'.repeat(32) }]) + ).success + ).toBe(false) + expect( + MobileWebBundleManifestSchema.safeParse( + manifestOf([ENTRY, { ...asset('assets/a.js', 10), contentType: 'nope' }]) + ).success + ).toBe(false) + }) +}) diff --git a/src/shared/mobile-web-bundle/manifest-contract.ts b/src/shared/mobile-web-bundle/manifest-contract.ts new file mode 100644 index 00000000000..9b110d28e64 --- /dev/null +++ b/src/shared/mobile-web-bundle/manifest-contract.ts @@ -0,0 +1,185 @@ +import { z } from 'zod' +import { sha256 } from '../sha256' + +/** A reader that sees another value must reject rather than guess at the shape. */ +export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1 as const + +/** The only stable-named asset, and the only one that references the content-addressed names. */ +export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html' + +// Permanent contract ceilings. They bound host memory at manifest-read time and never move with the +// per-phase build budget, which lives in the build's own verifier. +export const MOBILE_WEB_BUNDLE_MAX_ASSETS = 256 +export const MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES = 32 * 1024 * 1024 +export const MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES = 10 * 1024 * 1024 + +const SHA256_PATTERN = /^[a-f0-9]{64}$/ +const ASSET_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/ +const WINDOWS_RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i +// One spelling only, lowercase with a single space before `charset`: content type feeds the build +// id, so every accepted variant of the same type is another id for the same bytes. +const CONTENT_TYPE_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*(?:; charset=[a-z0-9-]+)?$/ +const MAX_ASSET_PATH_LENGTH = 255 +const MAX_CONTENT_TYPE_LENGTH = 128 +const MAX_DESKTOP_VERSION_LENGTH = 64 + +/** Every segment must be a name the bundle root can hold on all three desktop platforms: no + * traversal, and none of the Windows shapes that cannot be created or that resolve to a device. + * The regex already bans absolute paths, backslashes, spaces, and empty segments. */ +function isPortableAssetSegment(segment: string): boolean { + return ( + segment !== '.' && + segment !== '..' && + !segment.endsWith('.') && + !WINDOWS_RESERVED_SEGMENT.test(segment) + ) +} + +export const MobileWebBundleAssetPathSchema = z + .string() + .max(MAX_ASSET_PATH_LENGTH) + .regex(ASSET_PATH_PATTERN) + .refine( + (path) => path.split('/').every(isPortableAssetSegment), + 'asset path segment must be portable across macOS, Linux, and Windows' + ) + +export const MobileWebBundleAssetSchema = z + .object({ + path: MobileWebBundleAssetPathSchema, + sha256: z.string().regex(SHA256_PATTERN), + byteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), + contentType: z.string().min(1).max(MAX_CONTENT_TYPE_LENGTH).regex(CONTENT_TYPE_PATTERN) + }) + .strict() + +export type MobileWebBundleAsset = z.infer + +/** Code-unit order, not `localeCompare`: the sort feeds a content hash, so it must not vary. */ +function compareAssetPaths(left: MobileWebBundleAsset, right: MobileWebBundleAsset): number { + if (left.path === right.path) { + return 0 + } + return left.path < right.path ? -1 : 1 +} + +/** The one input to `buildId`: assets sorted by path, fixed key order, no whitespace. Sorting here + * rather than requiring it of the caller is what makes the id a pure function of content. */ +export function serializeMobileWebBundleAssets(assets: readonly MobileWebBundleAsset[]): string { + return JSON.stringify( + [...assets].sort(compareAssetPaths).map((asset) => ({ + path: asset.path, + sha256: asset.sha256, + byteLength: asset.byteLength, + contentType: asset.contentType + })) + ) +} + +/** Pure-JS sha256 rather than `node:crypto`: Metro ships no Node core shims, so the phone must be + * able to recompute the id from a manifest it cached. */ +export function computeMobileWebBundleId(assets: readonly MobileWebBundleAsset[]): string { + const digest = sha256(new TextEncoder().encode(serializeMobileWebBundleAssets(assets))) + return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function validateManifestInvariants( + manifest: { + buildId: string + entrypoint: string + totalBytes: number + minCompatibleRuntimeProtocolVersion: number + runtimeProtocolVersion: number + assets: readonly MobileWebBundleAsset[] + }, + context: z.RefinementCtx +): void { + // Cheapest first, and each check returns: the build id below is the only one that hashes, and + // zod runs this refinement even when the array ceiling has already failed. + if (manifest.assets.length > MOBILE_WEB_BUNDLE_MAX_ASSETS) { + return + } + let previousPath: string | null = null + let summedBytes = 0 + const foldedPaths = new Set() + for (const asset of manifest.assets) { + if (previousPath !== null && asset.path <= previousPath) { + context.addIssue({ + code: 'custom', + path: ['assets'], + message: 'assets must be sorted by path and unique' + }) + return + } + // Two paths differing only in case are one file on macOS and Windows, so the host would serve + // the same bytes under two entries and one of the two hashes would never match. + const folded = asset.path.toLocaleLowerCase('en-US') + if (foldedPaths.has(folded)) { + context.addIssue({ + code: 'custom', + path: ['assets'], + message: 'asset paths must not collide when case is folded' + }) + return + } + foldedPaths.add(folded) + previousPath = asset.path + summedBytes += asset.byteLength + } + // Without this the total ceiling bounds nothing: a manifest could declare totalBytes 0 and still + // list 256 assets of 10 MiB each. + if (summedBytes !== manifest.totalBytes) { + context.addIssue({ + code: 'custom', + path: ['totalBytes'], + message: 'totalBytes must equal the sum of asset byte lengths' + }) + return + } + if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) { + context.addIssue({ + code: 'custom', + path: ['entrypoint'], + message: 'entrypoint must be one of the listed assets' + }) + return + } + if (manifest.minCompatibleRuntimeProtocolVersion > manifest.runtimeProtocolVersion) { + context.addIssue({ + code: 'custom', + path: ['minCompatibleRuntimeProtocolVersion'], + message: 'protocol window must not be inverted' + }) + return + } + // A stale id survives every other check and would then serve the wrong bytes under a cache key + // the client already trusts. + if (manifest.buildId !== computeMobileWebBundleId(manifest.assets)) { + context.addIssue({ + code: 'custom', + path: ['buildId'], + message: 'buildId must be the content hash of the asset list' + }) + } +} + +/** Closed in both directions: `.strict()` rejects an unknown key and `schemaVersion` is a literal, + * so there is no additive path here. Any manifest change is a `schemaVersion` bump, and a phone + * reading a bundle it cached must treat an unrecognised `schemaVersion` as an unusable bundle to + * re-fetch, never as a crash. */ +export const MobileWebBundleManifestSchema = z + .object({ + schemaVersion: z.literal(MOBILE_WEB_BUNDLE_SCHEMA_VERSION), + buildId: z.string().regex(SHA256_PATTERN), + /** The app version that produced the bundle; the update wall's only honest age source. */ + desktopVersion: z.string().min(1).max(MAX_DESKTOP_VERSION_LENGTH), + minCompatibleRuntimeProtocolVersion: z.number().int().nonnegative(), + runtimeProtocolVersion: z.number().int().nonnegative(), + entrypoint: z.literal(MOBILE_WEB_BUNDLE_ENTRYPOINT), + totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES), + assets: z.array(MobileWebBundleAssetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS) + }) + .strict() + .superRefine(validateManifestInvariants) + +export type MobileWebBundleManifest = z.infer diff --git a/src/shared/mobile-web-bundle/mobile-web-bundle-capability.ts b/src/shared/mobile-web-bundle/mobile-web-bundle-capability.ts new file mode 100644 index 00000000000..bf8c250d224 --- /dev/null +++ b/src/shared/mobile-web-bundle/mobile-web-bundle-capability.ts @@ -0,0 +1,4 @@ +/** Negotiated, never inferred from the desktop version: a build can ship without a bundle. + * Zod-free and dependency-free so `protocol-version.ts` can name it without pulling a schema + * library into the phone's capability path. */ +export const MOBILE_WEB_BUNDLE_CAPABILITY = 'mobileWeb.bundle.v1'