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