feat(mobile-web-bundle): manifest and RPC contract for the desktop-served mobile web bundle (OTA phase A, 1/5) (#21325)

* feat(mobile-web-bundle): add the manifest contract and content-addressed build id

The schema every later Phase A lane parses against: the ceilings that bound host
memory (256 assets, 32 MiB total, 10 MiB per asset), and a build id that is a
pure function of content so a client can use it as a cache key unconditionally.

The serializer sorts its input rather than trusting the caller, so a producer
that emits assets in any order still lands on the same id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile-web-bundle): add the bundle RPC payload contract

Method names, capability name, the 48 KiB chunk size, params/result schemas for
both methods, and the six error codes as a closed union pinned by a coverage
record. Constants and data only; the host wiring and the capability push land in
later lanes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web-bundle): hash the build id without node:crypto

Metro ships no Node core shims, so a value import from these modules would fail
to bundle on the phone. The pure-JS sha256 keeps both contract modules
runtime-neutral, which also lets a cached manifest be re-verified on device.

Verified digest parity against node:crypto across the 55/56/64-byte padding
boundaries before the swap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web-bundle): reject a manifest whose buildId is not its content hash

A stale id passed every other check and would then serve the wrong bytes under a
cache key the client already trusts. Runs last of the invariants because it is
the only one that hashes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web-bundle): require a lowercase content type

The pattern carried an `i` flag over lowercase character classes, so the same
bytes described as `Text/HTML` and `text/html` produced two different build ids.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web-bundle): move the capability name to a zod-free module

A4 wires this constant into protocol-version.ts, which the phone reads on the
capability path. Leaving it in the schema module would have dragged zod along
with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-web-bundle): name the chunk reply's length assetByteLength

It is the whole asset's length, not the chunk's, and sitting beside dataBase64
under the old name it read as the chunk's. Both are non-negative integers, so a
producer that emitted the wrong one would only surface at the final hash check.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web-bundle): reject asset paths that are not portable or that fold together

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 could
never match. Windows-reserved segment names and trailing dots cannot be written
to the bundle root at all.

Both follow skill-package-manifest's checks, the folded-path Set and the
reserved-segment pattern.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web-bundle): accept one spelling of a parameterised content type

The optional space in `; ?charset=` let the same bytes carry two content types
and therefore two build ids. Pinned to the single-space form the bundle builder
emits.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(mobile-web-bundle): stop hashing a manifest a cheaper invariant already rejected

zod runs superRefine even after the asset-array ceiling has failed, so a 257
asset manifest was still sorted and hashed. Each invariant now returns on its
own issue and the count is checked first, which is what the comment claimed.

The tests read the issue paths: an oversized or otherwise invalid manifest with
a deliberately wrong buildId reports no buildId issue, while the same wrong id
inside the ceiling does.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile-web-bundle): say that the manifest has no additive path

`.strict()` plus a literal schemaVersion closes the shape completely, so the
version bump is the only way to change it. The phone value-imports this schema,
so Phase B must read an unrecognised schemaVersion as a bundle to re-fetch
rather than as a parse crash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-17 22:08:18 -04:00
committed by GitHub
parent f442a5c484
commit f2ca3cbfb7
5 changed files with 763 additions and 0 deletions
@@ -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<string, unknown> = {}): Record<string, unknown> {
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)
}
})
})
@@ -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<MobileWebBundleErrorCode>({
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<typeof MobileWebBundleManifestParamsSchema>
export type MobileWebBundleManifestResult = z.infer<typeof MobileWebBundleManifestResultSchema>
export type MobileWebBundleChunkParams = z.infer<typeof MobileWebBundleChunkParamsSchema>
export type MobileWebBundleChunkResult = z.infer<typeof MobileWebBundleChunkResultSchema>
@@ -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<string, unknown> = {}
): Record<string, unknown> {
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, unknown>): 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)
})
})
@@ -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<typeof MobileWebBundleAssetSchema>
/** 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<string>()
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<typeof MobileWebBundleManifestSchema>
@@ -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'