fix(mobile-web): bound the package gzip chunk ceiling by zlib's real worst case

A full-range read of an incompressible asset (PNG, woff2, wasm) gzips larger than
its source, so the flat MAX_RANGE_BYTES + 64 ceiling failed the host's own response
schema and aborted the whole package download.

Derive the ceiling from zlib's deflateBound instead, and have the host fall back to
stored blocks when level 6 does not shrink the range, so it never emits a stream
larger than it has to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-06 14:45:27 -04:00
parent 68a5825716
commit 6654e83588
5 changed files with 97 additions and 14 deletions
@@ -1,4 +1,5 @@
import { Buffer } from 'buffer/'
import { randomBytes } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import { sha256 } from '@noble/hashes/sha256'
import { describe, expect, it, vi } from 'vitest'
@@ -164,6 +165,22 @@ describe('mobile web package download pipelining', () => {
expect(fixture.paramsByCall.length).toBe(chunkCount(fixture.manifest))
})
// A PNG/woff2 range gzips larger than its source, which the page's chunk schema rejected while
// its ceiling was a flat +64 over the range — the download failed on `invalid_chunk`.
it('accepts a full incompressible range that gzip expands', async () => {
const fixture = createFixture({ incompressibleScript: true })
const stager = createStager()
await downloadMobileWebPackage(fixture.request, stager, {
shellBridgeVersion: 1,
useGzip: true,
rangeBytes: MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES
})
expect(stager.abort).not.toHaveBeenCalled()
expectStagedInOrder(fixture, stager)
})
it('rejects a ranged asset whose bytes do not hash to the manifest entry', async () => {
const fixture = createFixture({ alterLastRangeByte: true })
const stager = createStager()
@@ -203,13 +220,17 @@ function chunkCount(manifest: MobileWebManifest): number {
}
function createFixture(
options: { readLimitedCalls?: number; alterLastRangeByte?: boolean } = {}
options: {
readLimitedCalls?: number
alterLastRangeByte?: boolean
incompressibleScript?: boolean
} = {}
): Fixture {
const document = Buffer.from('<!doctype html><title>Orca</title>')
const script = Buffer.alloc(
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES + MOBILE_WEB_PACKAGE_CHUNK_BYTES + 11,
0x61
)
const scriptBytes = MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES + MOBILE_WEB_PACKAGE_CHUNK_BYTES + 11
const script = options.incompressibleScript
? Buffer.from(randomBytes(scriptBytes))
: Buffer.alloc(scriptBytes, 0x61)
const assets: MobileWebAsset[] = [
asset('index.html', document, 'text/html; charset=utf-8', 'document'),
asset(`assets/${sha256Hex(script)}.js`, script, 'text/javascript; charset=utf-8', 'script')
@@ -172,7 +172,7 @@ export class MobileWebPackageAssets {
)
}
const read = await this.readVerifiedRange(params, options, requestedLength)
const compressed = gzipSync(read.bytes, { level: 6 })
const compressed = compressAssetRange(read.bytes)
this.storeGzipChunk(key, compressed)
return this.gzipResponse(
read.buildId,
@@ -287,3 +287,10 @@ export class MobileWebPackageAssets {
}
export const mobileWebPackageAssets = new MobileWebPackageAssets()
// Why: gzip buys nothing on a PNG or a woff2, and level 6 expands such a range past the
// declared ceiling. Stored blocks are the smallest framing deflate has for it.
function compressAssetRange(bytes: Buffer): Buffer {
const deflated = gzipSync(bytes, { level: 6 })
return deflated.byteLength < bytes.byteLength ? deflated : gzipSync(bytes, { level: 0 })
}
@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,6 +6,7 @@ import { gunzipSync } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from '../../../shared/mobile-web/bridge-contract'
import {
MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES,
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES,
MobileWebPackageAssetParamsSchema
} from '../../../shared/mobile-web/package-rpc-contract'
@@ -147,6 +148,29 @@ describe('mobile web package ranged gzip reads', () => {
).rejects.toThrow('mobile_web_package_offset_invalid')
})
// An incompressible asset (PNG, woff2, wasm) is the case a flat gzip headroom got wrong: level 6
// expands it, the response failed its own schema, and the whole download aborted.
it('answers a full incompressible range inside the declared gzip ceiling', async () => {
const scriptBytes = randomBytes(RANGE_FIXTURE_ASSET_BYTES)
const fixture = await createRangeFixture(scriptBytes)
const assets = new MobileWebPackageAssets({ resolveRoot: () => fixture.root })
const script = fixture.manifest.assets.find((asset) => asset.role === 'script')!
const chunk = await assets.getAssetGzipChunk({
buildId: fixture.manifest.buildId,
path: script.path,
offset: 0,
length: MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES
})
expect(chunk.sourceByteLength).toBe(MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES)
expect(chunk.byteLength).toBeGreaterThan(MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES)
expect(chunk.byteLength).toBeLessThanOrEqual(MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES)
expect(gunzipSync(Buffer.from(chunk.dataBase64, 'base64'))).toEqual(
scriptBytes.subarray(0, MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES)
)
})
it('only accepts chunk-aligned range lengths within the cap', async () => {
const address = { buildId: '0'.repeat(64), path: 'index.html', offset: 0 }
@@ -175,17 +199,18 @@ describe('mobile web package ranged gzip reads', () => {
})
})
async function createRangeFixture(): Promise<{
const RANGE_FIXTURE_ASSET_BYTES =
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES + MOBILE_WEB_PACKAGE_CHUNK_BYTES + 23
async function createRangeFixture(
scriptBytes: Buffer = Buffer.alloc(RANGE_FIXTURE_ASSET_BYTES, 0x61)
): Promise<{
root: string
manifest: MobileWebManifest
scriptBytes: Buffer
}> {
const root = await mkdtemp(join(tmpdir(), 'orca-mobile-web-range-'))
temporaryRoots.push(root)
const scriptBytes = Buffer.alloc(
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES + MOBILE_WEB_PACKAGE_CHUNK_BYTES + 23,
0x61
)
const documentBytes = Buffer.from('<!doctype html><title>Orca</title>', 'utf8')
const scriptHash = sha256(scriptBytes)
const assets = [
@@ -1,6 +1,10 @@
import { randomBytes } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_PACKAGE_CHUNK_BASE64_CHARS,
MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES,
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES,
MobileWebPackageAssetChunkSchema,
MobileWebPackageAssetParamsSchema,
MobileWebPackageGzipAssetChunkSchema
@@ -46,6 +50,18 @@ describe('mobile web package RPC contract', () => {
).toBe(true)
})
// The gzip ceiling used to be a flat +64, which zlib exceeds on incompressible input at every
// level; a full-range PNG read then failed the response schema and aborted the download.
it('covers what zlib really emits for a full incompressible range', () => {
const source = randomBytes(MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES)
for (const level of [0, 6, 9] as const) {
const compressed = gzipSync(source, { level })
expect(compressed.byteLength).toBeGreaterThan(MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES)
expect(compressed.byteLength).toBeLessThanOrEqual(MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES)
}
})
it.each(['../secret', '/index.html', 'assets//app.js', 'assets\\app.js', 'a%2Fb.js'])(
'rejects unsafe request path %s',
(path) => {
+16 -2
View File
@@ -17,8 +17,22 @@ export const MOBILE_WEB_PACKAGE_MAX_IN_FLIGHT_BYTES =
MOBILE_WEB_PACKAGE_MAX_CONCURRENT_READS * MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES
export const MOBILE_WEB_PACKAGE_CHUNK_BASE64_CHARS =
Math.ceil(MOBILE_WEB_PACKAGE_CHUNK_BYTES / 3) * 4
// Deflate can add a small stored-block header to incompressible chunks.
export const MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES = MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES + 64
// zlib's own deflateBound: incompressible input grows by up to a bit per byte plus a block
// header per 64 bytes, and the gzip wrapper adds a 10-byte header and an 8-byte trailer. A flat
// margin is not enough — level 6 on 384 KiB of random bytes really does exceed the source length.
export const MOBILE_WEB_PACKAGE_GZIP_WRAPPER_BYTES = 18
export function mobileWebPackageGzipBound(sourceByteLength: number): number {
return (
sourceByteLength +
((sourceByteLength + 7) >> 3) +
((sourceByteLength + 63) >> 6) +
5 +
MOBILE_WEB_PACKAGE_GZIP_WRAPPER_BYTES
)
}
export const MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES = mobileWebPackageGzipBound(
MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES
)
export const MOBILE_WEB_PACKAGE_GZIP_CHUNK_BASE64_CHARS =
Math.ceil(MOBILE_WEB_PACKAGE_GZIP_CHUNK_BYTES / 3) * 4