Files
orca/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.cjs
Kelvin Amoaba 64a2a6f779 perf(mobile): let newer phones fetch their language catalog from the desktop instead of downloading all of them (#40519)
* perf(mobile): let newer phone shells fetch the language catalog from the desktop instead of downloading all of them

* fix(mobile): hash only the buildId fields so phone readers can recompute it

* fix(mobile): use the catalog chunk when the generation still has it

* fix(desktop): give catalog reads their own read-slot budget
2026-11-10 03:52:19 +00:00

239 lines
9.3 KiB
JavaScript

const { createHash } = require('node:crypto')
const { readFileSync, readdirSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
const { brotliDecompressSync } = require('node:zlib')
const { darkBuildMarkerFileNames } = require('../../../dark-build-switches.cjs')
const projectDir = resolve(__dirname, '../../..', '..')
const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web')
const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).'
const ENTRYPOINT = 'index.html'
const SHA256_PATTERN = /^[0-9a-f]{64}$/
function failure(message) {
return new Error(`[verify-packaged-mobile-web-bundle] ${message}`)
}
function assertSafeRelativePath(path) {
if (typeof path !== 'string' || path.length === 0) {
throw failure('manifest asset has a missing or empty path')
}
const segments = path.split('/')
if (
path.includes('\\') ||
path.startsWith('/') ||
/^[a-zA-Z]:/.test(path) ||
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
) {
throw failure(`manifest asset path is not a safe relative path: ${path}`)
}
}
function assertInteger(value, field) {
if (!Number.isSafeInteger(value) || value < 0) {
throw failure(`manifest field ${field} is not a non-negative integer: ${String(value)}`)
}
}
// Keep canonical asset bytes identical to mobile-web-bundle-manifest.mjs serializer or packaging rejects valid bundles.
function serializeAssets(assets) {
return JSON.stringify(
[...assets]
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
.map(({ path, sha256, byteLength, contentType }) => ({
path,
sha256,
byteLength,
contentType
}))
)
}
function parseManifest(bundleDir) {
const manifestPath = join(bundleDir, 'manifest.json')
let raw
try {
raw = readFileSync(manifestPath, 'utf8')
} catch (error) {
throw failure(
`no bundle manifest at ${manifestPath} (${error.code ?? error.message}). ${REMEDY}`
)
}
let manifest
try {
manifest = JSON.parse(raw)
} catch (error) {
throw failure(`${manifestPath} is not valid JSON: ${error.message}. ${REMEDY}`)
}
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
throw failure(`${manifestPath} is not a JSON object. ${REMEDY}`)
}
if (manifest.schemaVersion !== 1) {
throw failure(`unsupported manifest schemaVersion: ${String(manifest.schemaVersion)}`)
}
if (typeof manifest.buildId !== 'string' || !SHA256_PATTERN.test(manifest.buildId)) {
throw failure(`manifest buildId is not a sha256 digest: ${String(manifest.buildId)}`)
}
if (typeof manifest.desktopVersion !== 'string' || manifest.desktopVersion.length === 0) {
throw failure('manifest desktopVersion is missing')
}
assertInteger(manifest.minCompatibleRuntimeProtocolVersion, 'minCompatibleRuntimeProtocolVersion')
assertInteger(manifest.runtimeProtocolVersion, 'runtimeProtocolVersion')
assertInteger(manifest.totalBytes, 'totalBytes')
if (manifest.entrypoint !== ENTRYPOINT) {
throw failure(`manifest entrypoint must be ${ENTRYPOINT}, got ${String(manifest.entrypoint)}`)
}
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw failure('manifest lists no assets')
}
for (const asset of manifest.assets) {
if (typeof asset !== 'object' || asset === null) {
throw failure('manifest asset entry is not an object')
}
assertSafeRelativePath(asset.path)
if (typeof asset.sha256 !== 'string' || !SHA256_PATTERN.test(asset.sha256)) {
throw failure(`manifest asset ${asset.path} has no sha256 digest`)
}
assertInteger(asset.byteLength, `assets[${asset.path}].byteLength`)
if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) {
throw failure(`manifest asset ${asset.path} has no contentType`)
}
if (asset.brotliByteLength !== undefined) {
assertInteger(asset.brotliByteLength, `assets[${asset.path}].brotliByteLength`)
if (asset.brotliByteLength >= asset.byteLength) {
throw failure(`manifest asset ${asset.path} declares a brotli copy no smaller than itself`)
}
}
}
if (manifest.catalogs !== undefined && !Array.isArray(manifest.catalogs)) {
throw failure('manifest catalogs is not a list')
}
for (const catalog of manifest.catalogs ?? []) {
if (typeof catalog !== 'object' || catalog === null || typeof catalog.locale !== 'string') {
throw failure('manifest catalog entry has no locale')
}
assertSafeRelativePath(catalog.path)
if (typeof catalog.sha256 !== 'string' || !SHA256_PATTERN.test(catalog.sha256)) {
throw failure(`manifest catalog ${catalog.path} has no sha256 digest`)
}
assertInteger(catalog.byteLength, `catalogs[${catalog.path}].byteLength`)
}
if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) {
throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`)
}
const declaredTotal = manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
if (declaredTotal !== manifest.totalBytes) {
throw failure(
`manifest totalBytes is ${String(manifest.totalBytes)}, its assets sum to ${String(declaredTotal)}`
)
}
const recomputed = createHash('sha256')
.update(serializeAssets(manifest.assets), 'utf8')
.digest('hex')
if (recomputed !== manifest.buildId) {
throw failure(
`manifest buildId ${manifest.buildId} does not match its asset list (expected ${recomputed}). ${REMEDY}`
)
}
return manifest
}
// Assets go to the phone; catalogs stay on the host, which serves one on request.
function servedFiles(manifest) {
return [...manifest.assets, ...(manifest.catalogs ?? [])]
}
function listBundleFiles(directory, prefix = '') {
const found = []
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const relativePath = prefix === '' ? entry.name : `${prefix}/${entry.name}`
if (entry.isDirectory()) {
found.push(...listBundleFiles(join(directory, entry.name), relativePath))
} else {
found.push(relativePath)
}
}
return found
}
// Mirrors mobileWebBundleBrotliPath in src/shared/mobile-web-bundle/manifest-contract.ts.
function brotliPath(asset) {
return `${asset.path}.br`
}
function brotliCopies(manifest) {
return manifest.assets.filter((asset) => asset.brotliByteLength !== undefined)
}
function statSize(bundleDir, path) {
try {
return statSync(join(bundleDir, path)).size
} catch (error) {
throw failure(
`manifest lists ${path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
)
}
}
// Reject unlisted on-disk assets: content-addressed stale files survive interrupted builds and would ship unreachable/unverified in asar.
function assertNoUnlistedFiles(bundleDir, manifest) {
// A dark build's marker is accounted for here; packaging refuses it through assertNotDarkBuild.
const listed = new Set([
'manifest.json',
...darkBuildMarkerFileNames(),
...servedFiles(manifest).map((file) => file.path),
...brotliCopies(manifest).map((asset) => brotliPath(asset))
])
const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path))
if (strays.length > 0) {
throw failure(
`${bundleDir} holds ${String(strays.length)} file(s) the manifest does not list: ` +
`${strays.sort().join(', ')}. ${REMEDY}`
)
}
}
// Fail missing/stale bundle (electron-builder only warns); hash check catches half-written output before advertised capability fails every request.
function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
const manifest = parseManifest(bundleDir)
assertNoUnlistedFiles(bundleDir, manifest)
for (const asset of servedFiles(manifest)) {
const assetPath = join(bundleDir, asset.path)
const size = statSize(bundleDir, asset.path)
if (size !== asset.byteLength) {
throw failure(
`${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}`
)
}
const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex')
if (sha256 !== asset.sha256) {
throw failure(
`${asset.path} hashes to ${sha256} on disk, manifest says ${asset.sha256}. ${REMEDY}`
)
}
}
// Phones verify only the inflated bytes, so a copy that inflates to anything else fails every download.
for (const asset of brotliCopies(manifest)) {
const path = brotliPath(asset)
const size = statSize(bundleDir, path)
if (size !== asset.brotliByteLength) {
throw failure(
`${path} is ${String(size)} bytes on disk, manifest says ${String(asset.brotliByteLength)}. ${REMEDY}`
)
}
const inflated = brotliDecompressSync(readFileSync(join(bundleDir, path)))
if (createHash('sha256').update(inflated).digest('hex') !== asset.sha256) {
throw failure(`${path} does not inflate to ${asset.path}. ${REMEDY}`)
}
}
console.log(
`[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` +
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes`
)
return manifest
}
// Export serializer solely for three-way parity proof; packaging consumes it internally.
module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt, serializeAssets }