From 6654e8358865e01bfe55b8ed4b3f3a9fbedd83cf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 14:45:27 -0400 Subject: [PATCH] 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 --- ...le-web-package-download-pipelining.test.ts | 31 +++++++++++++--- .../runtime/rpc/mobile-web-package-assets.ts | 9 ++++- .../mobile-web-package-range-reads.test.ts | 37 ++++++++++++++++--- .../mobile-web/package-rpc-contract.test.ts | 16 ++++++++ src/shared/mobile-web/package-rpc-contract.ts | 18 ++++++++- 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/mobile/src/mobile-web/mobile-web-package-download-pipelining.test.ts b/mobile/src/mobile-web/mobile-web-package-download-pipelining.test.ts index 4ba3251f2e2..42660f07cc8 100644 --- a/mobile/src/mobile-web/mobile-web-package-download-pipelining.test.ts +++ b/mobile/src/mobile-web/mobile-web-package-download-pipelining.test.ts @@ -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('Orca') - 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') diff --git a/src/main/runtime/rpc/mobile-web-package-assets.ts b/src/main/runtime/rpc/mobile-web-package-assets.ts index 344e4f74d1d..d24d2bb9884 100644 --- a/src/main/runtime/rpc/mobile-web-package-assets.ts +++ b/src/main/runtime/rpc/mobile-web-package-assets.ts @@ -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 }) +} diff --git a/src/main/runtime/rpc/mobile-web-package-range-reads.test.ts b/src/main/runtime/rpc/mobile-web-package-range-reads.test.ts index dd70f312a89..30b8d3d6814 100644 --- a/src/main/runtime/rpc/mobile-web-package-range-reads.test.ts +++ b/src/main/runtime/rpc/mobile-web-package-range-reads.test.ts @@ -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('Orca', 'utf8') const scriptHash = sha256(scriptBytes) const assets = [ diff --git a/src/shared/mobile-web/package-rpc-contract.test.ts b/src/shared/mobile-web/package-rpc-contract.test.ts index 321d9e6ea8f..d3b1d8200ed 100644 --- a/src/shared/mobile-web/package-rpc-contract.test.ts +++ b/src/shared/mobile-web/package-rpc-contract.test.ts @@ -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) => { diff --git a/src/shared/mobile-web/package-rpc-contract.ts b/src/shared/mobile-web/package-rpc-contract.ts index 89dd9cbb9f1..7486f67df6c 100644 --- a/src/shared/mobile-web/package-rpc-contract.ts +++ b/src/shared/mobile-web/package-rpc-contract.ts @@ -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