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