rea/tests/boundary/filesystem/flutterBuildProvider.test.ts

1333 lines
47 KiB
TypeScript

import { createReadStream } from "node:fs";
import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { expect, it, onTestFinished, vi } from "vitest";
import {
aotFixtureImage,
engineFixtureImage,
buildElf64Image,
REFERENCE_HASH,
} from "../../../src/flutter/FlutterBuild.fixture.js";
import { snapshotSection } from "../../../src/flutter/FlutterBuild.fixture.js";
import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../../../src/domain/analysisErrorCore.js";
import {
flutterResultSchemas,
type FlutterOperation,
} from "../../../src/domain/flutter/flutterBuildAnalysis.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import {
createFlutterApk as buildApk,
createDuplicateFlutterApk,
} from "../../fixtures/flutterApk.js";
import * as regularFile from "../../../src/filesystem/RegularFile.js";
import * as fsPromises from "node:fs/promises";
import * as dartElf from "../../../src/flutter/DartElf.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return { ...original, createReadStream: vi.fn(original.createReadStream) };
});
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return { ...original };
});
const buildIdNote = (): Buffer =>
Buffer.concat([
Buffer.from([4, 0, 0, 0, 20, 0, 0, 0, 3, 0, 0, 0, 0x47, 0x4e, 0x55, 0]),
Buffer.from([
0xe8, 0xd9, 0x07, 0xef, 0x7c, 0x17, 0x5e, 0xc4, 0x31, 0x3f, 0x47, 0x11,
0x10, 0x19, 0xad, 0xc9, 0x58, 0x8c, 0x5f, 0x61,
]),
]);
const TOOLCHAIN_LINE =
"Android (5900059 based on r365631c) clang version 9.0.8 (based on LLVM 9.0.8svn)";
const libapp = aotFixtureImage;
const libflutter = (): Buffer =>
engineFixtureImage({ notes: buildIdNote(), labels: [TOOLCHAIN_LINE] });
/**
* Self-contained Dart AOT image: a synthetic ELF64 carrying the four
* snapshot symbols, two snapshot sections, and a string pool, so goldens
* do not depend on any file outside the repository.
*/
const aotImage = aotFixtureImage;
/** Two data symbols at one offset and size: aliased exports. */
const aliasedDataImage = (): Buffer => {
const elf = buildElf64Image([
{ name: "_kDartVmSnapshotData", value: 0x1000, size: 52 },
{ name: "_kDartIsolateSnapshotData", value: 0x1000, size: 52 },
]);
return Buffer.concat([
elf,
Buffer.alloc(0x1000 - elf.length),
snapshotSection(REFERENCE_HASH),
]);
};
/** Two data symbols whose snapshot hashes disagree. */
const conflictingHashImage = (): Buffer => {
const elf = buildElf64Image([
{ name: "_kDartVmSnapshotData", value: 0x1000, size: 52 },
{ name: "_kDartIsolateSnapshotData", value: 0x1200, size: 52 },
]);
const vm = snapshotSection("11111111111111111111111111111111");
const isolate = snapshotSection("22222222222222222222222222222222");
return Buffer.concat([
elf,
Buffer.alloc(0x1000 - elf.length),
vm,
Buffer.alloc(0x1200 - 0x1000 - vm.length),
isolate,
]);
};
const analyzeImage = async (bytes: Buffer, operation: FlutterOperation) => {
const path = await buildApk("image.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes },
{ path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() },
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (!result.ok) throw result.error;
return result.value;
};
it.each([32, 64] as const)(
"reads only ELF-owned notes in an ELF%s engine",
async (bits) => {
const fake = Buffer.from(buildIdNote());
fake.fill(0x11, 16);
for (const ownership of ["program", "section", "both"] as const) {
const path = await buildApk("owned-notes.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
{
path: "lib/arm64-v8a/libflutter.so",
bytes: engineFixtureImage({
bits,
ownership,
prefix: fake,
notes: buildIdNote(),
}),
},
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (!result.ok) throw result.error;
const value = flutterResultSchemas.identify_flutter_build.parse(
result.value.result,
);
expect(value.coverage).toBe("complete");
expect(value.abis[0]!.libflutter).toMatchObject({
build_id: "e8d907ef7c175ec4313f47111019adc9588c5f61",
build_id_candidates: ["e8d907ef7c175ec4313f47111019adc9588c5f61"],
});
}
},
);
it.each(["conflicting", "truncated", "capacity"] as const)(
"preserves %s engine-note uncertainty",
async (shape) => {
const second = Buffer.from(buildIdNote());
second.fill(0x11, 16);
const notes =
shape === "conflicting"
? Buffer.concat([buildIdNote(), second])
: shape === "truncated"
? Buffer.concat([buildIdNote(), Buffer.from([1])])
: Buffer.concat(
Array.from({ length: 65 }, (_, index) => {
const note = Buffer.from(buildIdNote());
note.writeUInt32LE(index, 16);
return note;
}),
);
const path = await buildApk("uncertain-notes.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
{
path: "lib/arm64-v8a/libflutter.so",
bytes: engineFixtureImage({ notes }),
},
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (!result.ok) throw result.error;
const value = flutterResultSchemas.identify_flutter_build.parse(
result.value.result,
);
expect(value.coverage).toBe("partial");
expect(value.abis[0]!.libflutter.build_id).toBeNull();
expect(value.abis[0]!.libflutter.build_id_candidates).toHaveLength(
shape === "conflicting" ? 2 : shape === "truncated" ? 1 : 64,
);
},
);
it.each([0, 1, 2, 4, 0xffff])(
"retains snapshot kind %s with partial decoding coverage",
async (kind) => {
const image = aotImage();
image.writeBigUInt64LE(BigInt(kind), 0x1000 + 12);
image.writeBigUInt64LE(BigInt(kind), 0x2000 + 12);
const execution = await analyzeImage(image, "inspect_dart_aot");
const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result);
expect(value).toMatchObject({
coverage: "partial",
libapp: { coverage: "partial" },
string_pool: { coverage: "complete" },
});
expect(
value.libapp.sections
.filter((section) => section.name.endsWith("Data"))
.map((section) => section.kind),
).toEqual([kind, kind]);
expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH);
},
);
it.each(["absent", "range", "metadata-budget"] as const)(
"preserves %s engine-note coverage at the ELF boundary",
async (shape) => {
const engine = engineFixtureImage({
notes:
shape === "metadata-budget"
? Buffer.alloc(8 * 1024 * 1024)
: Buffer.alloc(0),
});
if (shape === "range")
engine.writeBigUInt64LE(BigInt(engine.length + 1), 64 + 56 + 8);
const path = await buildApk("note-bounds.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
{ path: "lib/arm64-v8a/libflutter.so", bytes: engine },
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (!result.ok) throw result.error;
const value = flutterResultSchemas.identify_flutter_build.parse(
result.value.result,
);
expect(value.coverage).toBe(shape === "absent" ? "complete" : "partial");
expect(value.abis[0]!.libflutter).toMatchObject({
build_id: null,
build_id_candidates: [],
});
if (shape !== "absent")
expect(result.value.limitations.join(" ")).toContain(
shape === "range" ? "not file-backed" : "metadata budget",
);
},
);
it.each([
["root", "EACCES"],
["open", "EPERM"],
["write", "ENOSPC"],
["close", "EIO"],
["chmod", "EACCES"],
["stat", "EIO"],
["digest", "EACCES"],
["zip-library", "EIO"],
] as const)(
"classifies owned snapshot %s failure (%s) separately from source input",
async (stage, code) => {
const path = await buildApk("owned-io.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const cause = Object.assign(new Error("owned snapshot failure"), { code });
const native =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
if (stage === "zip-library")
vi.spyOn(ZipArtifactReader.prototype, "open").mockRejectedValueOnce(
cause,
);
if (stage === "root")
vi.spyOn(fsPromises, "mkdtemp").mockRejectedValueOnce(cause);
if (stage === "chmod")
vi.spyOn(fsPromises, "chmod").mockRejectedValueOnce(cause);
if (stage === "stat")
vi.spyOn(fsPromises, "stat").mockImplementation((...args) => {
if (args[0] !== path) return Promise.reject(cause);
return native.stat(...args);
});
if (["open", "write", "close"].includes(stage))
vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => {
if (args[1] === "wx" && stage === "open") throw cause;
const handle = await native.open(...args);
if (args[1] === "wx") {
if (stage === "write")
vi.spyOn(handle, "writeFile").mockRejectedValueOnce(cause);
if (stage === "close")
vi.spyOn(handle, "close").mockRejectedValueOnce(cause);
}
return handle;
});
if (stage === "digest")
vi.mocked(createReadStream).mockImplementation(
() =>
Readable.from(
(async function* () {
yield Buffer.alloc(0);
throw cause;
})(),
) as ReturnType<typeof createReadStream>,
);
onTestFinished(() => {
vi.restoreAllMocks();
vi.mocked(createReadStream).mockReset();
});
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (result.ok) throw new Error("expected owned I/O failure");
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
expect(result.error.cause).toBe(cause);
expect(result.error.message).toContain(
"Provider-owned Flutter snapshot storage",
);
},
);
it.each(["close", "elf", "pool", "cleanup"] as const)(
"honors cancellation during final %s work",
async (stage) => {
const path = await buildApk("late-cancellation.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const controller = new AbortController();
const originalClose = OwnedFileHandle.prototype.close;
let closed = 0;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation(
async function (this: OwnedFileHandle) {
await originalClose.call(this);
if (++closed === 4) {
if (stage === "close") controller.abort();
if (stage === "elf") setImmediate(() => controller.abort());
}
},
);
if (stage === "pool") {
const original = dartElf.readElfSymbols;
vi.spyOn(dartElf, "readElfSymbols").mockImplementation(
async (...args) => {
const value = await original(...args);
setImmediate(() => controller.abort());
return value;
},
);
}
onTestFinished(() => {
vi.restoreAllMocks();
});
const provider = new FlutterBuildProvider({
removeRoot: async (root) => {
await rm(root, { recursive: true, force: true });
if (stage === "cleanup") controller.abort();
},
});
onTestFinished(() => provider.close());
const result = await provider.execute(
{ operation: "inspect_dart_aot", input: { path } },
{ signal: controller.signal },
);
if (result.ok) throw new Error("late cancellation must prevent success");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
},
);
it("projects each payload shape through the owned snapshot", async () => {
const hash = REFERENCE_HASH;
const agreeBoth = await buildApk("agree.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) },
{ path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() },
{ path: "lib/x86_64/libapp.so", bytes: libapp(hash) },
{ path: "lib/x86_64/libflutter.so", bytes: libflutter() },
]);
const half = await buildApk("half.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) },
]);
const disagree = await buildApk("disagree.apk", [
{
path: "lib/arm64-v8a/libapp.so",
bytes: Buffer.concat([
snapshotSection("11111111111111111111111111111111"),
snapshotSection("22222222222222222222222222222222"),
]),
},
{ path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() },
]);
const plain = await buildApk("plain.apk", [
{ path: "classes.dex", bytes: Buffer.from("dex\n0123456789") },
{ path: "lib/arm64-v8a/libnative.so", bytes: Buffer.from("plain-elf") },
]);
const provider = new FlutterBuildProvider();
const identify = async (apk: string) => {
const outcome = await provider.execute({
operation: "identify_flutter_build",
input: { path: apk },
});
if (!outcome.ok) throw new Error(outcome.error.message);
return outcome.value.result as {
flutter_detected: boolean;
coverage: string;
abis: {
abi: string;
libapp: {
present: boolean;
snapshot_hash: string | null;
snapshot_hash_sources: number;
snapshot_hash_candidates: string[];
};
libflutter: { present: boolean; build_id: string | null };
}[];
};
};
const both = await identify(agreeBoth);
expect(both.flutter_detected).toBe(true);
expect(both.coverage).toBe("complete");
expect(both.abis.map((abi) => abi.abi)).toEqual(["arm64-v8a", "x86_64"]);
for (const abi of both.abis) {
expect(abi.libapp.snapshot_hash).toBe(hash);
expect(abi.libapp.snapshot_hash_sources).toBe(2);
expect(abi.libflutter.build_id).toBe(
"e8d907ef7c175ec4313f47111019adc9588c5f61",
);
}
const engineless = await identify(half);
expect(engineless.coverage).toBe("partial");
expect(engineless.abis[0]?.libapp.present).toBe(true);
expect(engineless.abis[0]?.libflutter.present).toBe(false);
const conflicted = await identify(disagree);
expect(conflicted.coverage).toBe("partial");
expect(conflicted.abis[0]?.libapp.snapshot_hash).toBeNull();
expect(conflicted.abis[0]?.libapp.snapshot_hash_candidates).toEqual([
"11111111111111111111111111111111",
"22222222222222222222222222222222",
]);
const negative = await identify(plain);
expect(negative.flutter_detected).toBe(false);
expect(negative.abis).toEqual([]);
expect(negative.coverage).toBe("complete");
});
it("rejects a target that is not a readable file", async () => {
const projected = await (async () => {
const result = await new FlutterBuildProvider().execute({
operation: "identify_flutter_build",
input: { path: "/nonexistent/app.apk" },
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
})();
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("refuses an APK without a Dart payload", async () => {
const apk = await buildApk("noflutter.apk", [
{ path: "classes.dex", bytes: Buffer.from("dex\n0123456789") },
]);
const result = await new FlutterBuildProvider().execute({
operation: "inspect_dart_aot",
input: { path: apk },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
});
});
it("refuses a requested ABI the payload does not carry", async () => {
const apk = await buildApk("oneabi.apk", [
{
path: "lib/arm64-v8a/libapp.so",
bytes: libapp(REFERENCE_HASH),
},
]);
const result = await new FlutterBuildProvider().execute({
operation: "inspect_dart_aot",
input: { path: apk, abi: "x86_64" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
});
expect(result.error.message).toContain("arm64-v8a");
});
it("inspects a full synthetic AOT image end to end", async () => {
const apk = await buildApk("realgallery.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const result = await new FlutterBuildProvider().execute({
operation: "inspect_dart_aot",
input: { path: apk },
});
if (!result.ok) throw new Error(result.error.message);
const value = result.value.result as {
abi: string;
libapp: {
snapshot_hash: string | null;
snapshot_hash_candidates: string[];
snapshot_hash_sources: number;
sections: { name: string; magic_valid: boolean }[];
};
string_pool: {
package_uri_count_at_least: number;
dart_uri_count_at_least: number;
};
coverage: string;
};
expect(value.abi).toBe("arm64-v8a");
expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH);
expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]);
expect(value.libapp.snapshot_hash_sources).toBe(2);
expect(value.libapp.sections.map((section) => section.name)).toEqual([
"_kDartVmSnapshotData",
"_kDartVmSnapshotInstructions",
"_kDartIsolateSnapshotData",
"_kDartIsolateSnapshotInstructions",
]);
expect(
value.libapp.sections
.filter((section) => section.name.endsWith("Data"))
.every((section) => section.magic_valid),
).toBe(true);
expect(value.string_pool.package_uri_count_at_least).toBe(2);
expect(value.string_pool.dart_uri_count_at_least).toBe(2);
// All four symbols present, both data magics valid, pool under budget:
// the honest answer for a complete payload is complete coverage.
expect(value.coverage).toBe("complete");
});
it("reports aliased snapshot data symbols as partial coverage", async () => {
const apk = await buildApk("aliased.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aliasedDataImage() },
]);
const result = await new FlutterBuildProvider().execute({
operation: "inspect_dart_aot",
input: { path: apk },
});
if (!result.ok) throw new Error(result.error.message);
const value = result.value.result as {
coverage: string;
libapp: {
snapshot_hash: string | null;
snapshot_hash_candidates: string[];
snapshot_hash_sources: number;
};
};
// The aliased range is scanned once, so one candidate no longer counts
// as two agreeing sources.
expect(value.coverage).toBe("partial");
expect(value.libapp.snapshot_hash_sources).toBe(1);
expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]);
expect(result.value.limitations).toContain(
"Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones",
);
});
it("preserves conflicting snapshot hash candidates from the AOT image", async () => {
const apk = await buildApk("conflicted.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: conflictingHashImage() },
]);
const result = await new FlutterBuildProvider().execute({
operation: "inspect_dart_aot",
input: { path: apk },
});
if (!result.ok) throw new Error(result.error.message);
const value = result.value.result as {
coverage: string;
libapp: {
snapshot_hash: string | null;
snapshot_hash_candidates: string[];
snapshot_hash_sources: number;
};
};
expect(value.coverage).toBe("partial");
expect(value.libapp.snapshot_hash).toBeNull();
expect(value.libapp.snapshot_hash_sources).toBe(2);
expect(value.libapp.snapshot_hash_candidates).toEqual([
"11111111111111111111111111111111",
"22222222222222222222222222222222",
]);
});
it("accepts an APK whose path contains spaces through the owned snapshot", async () => {
const apk = await buildApk("flutter spaced.apk", [
{
path: "lib/arm64-v8a/libapp.so",
bytes: libapp(REFERENCE_HASH),
},
{ path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() },
]);
expect(apk).toContain(" ");
const result = await new FlutterBuildProvider().execute({
operation: "identify_flutter_build",
input: { path: apk },
});
if (!result.ok) throw new Error(result.error.message);
const value = result.value.result as { flutter_detected: boolean };
expect(value.flutter_detected).toBe(true);
});
it("retains failed snapshot cleanup and retries it on close", async () => {
const apk = await buildApk("cleanup.apk", [
{
path: "lib/arm64-v8a/libapp.so",
bytes: libapp(REFERENCE_HASH),
},
]);
const spaces: string[] = [];
let failing = true;
const provider = new FlutterBuildProvider({
removeRoot: async (path) => {
if (failing) throw new Error("cleanup refused");
spaces.push(path);
await rm(path, { recursive: true, force: true });
},
});
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path: apk },
});
expect(result.ok).toBe(true);
await expect(provider.close()).rejects.toThrow(/snapshot cleanup failed/u);
failing = false;
await provider.close();
expect(spaces.length).toBeGreaterThanOrEqual(1);
});
it("cancels before creating any snapshot root", async () => {
const apk = await buildApk("cancel.apk", [
{
path: "lib/arm64-v8a/libapp.so",
bytes: libapp(REFERENCE_HASH),
},
]);
const controller = new AbortController();
controller.abort();
const provider = new FlutterBuildProvider();
const result = await provider.execute(
{ operation: "identify_flutter_build", input: { path: apk } },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
await provider.close();
});
it.each([1, 20, 47, 48, 61])(
"identifies a valid ELF when ZIP chunks are only %i bytes",
async (chunkBytes) => {
const originalOpen = ZipArtifactReader.prototype.open;
const spy = vi
.spyOn(ZipArtifactReader.prototype, "open")
.mockImplementation(async function (
this: ZipArtifactReader,
entry,
signal,
) {
const source = await originalOpen.call(this, entry, signal);
return Readable.from(
(async function* () {
for await (const chunk of source) {
const bytes = chunk as Buffer;
for (let offset = 0; offset < bytes.length; offset += chunkBytes)
yield bytes.subarray(offset, offset + chunkBytes);
}
})(),
);
});
onTestFinished(() => spy.mockRestore());
const execution = await analyzeImage(aotImage(), "identify_flutter_build");
const value = flutterResultSchemas.identify_flutter_build.parse(
execution.result,
);
expect(value.coverage).toBe("complete");
expect(value.abis[0]?.libapp).toMatchObject({
elf_header_valid: true,
snapshot_hash: REFERENCE_HASH,
snapshot_hash_sources: 2,
});
},
);
it.each([
{
name: "zeroed offsets",
corrupt: (image: Buffer) => image.fill(0, 0x20, 0x30),
},
{
name: "program table past EOF",
corrupt: (image: Buffer) =>
image.writeBigUInt64LE(BigInt(image.length + 1), 0x20),
},
{
name: "section table past EOF",
corrupt: (image: Buffer) =>
image.writeBigUInt64LE(BigInt(image.length - 1), 0x28),
},
{
name: "zero program entry width",
corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x36),
},
{
name: "short section entry width",
corrupt: (image: Buffer) => image.writeUInt16LE(1, 0x3a),
},
{
name: "absent program table",
corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x38),
},
{
name: "unsupported class",
corrupt: (image: Buffer) => image.writeUInt8(3, 4),
},
{
name: "unsupported byte order",
corrupt: (image: Buffer) => image.writeUInt8(2, 5),
},
{
name: "unsafe table offset",
corrupt: (image: Buffer) => image.writeBigUInt64LE(1n << 63n, 0x20),
},
{
name: "table inside header",
corrupt: (image: Buffer) => image.writeBigUInt64LE(1n, 0x20),
},
{
name: "extended section count",
corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x3c),
},
])(
"retains hash observations with partial coverage for $name",
async ({ corrupt }) => {
const image = aotImage();
corrupt(image);
const execution = await analyzeImage(image, "identify_flutter_build");
const value = flutterResultSchemas.identify_flutter_build.parse(
execution.result,
);
expect(value.coverage).toBe("partial");
expect(value.abis[0]?.libapp).toMatchObject({
elf_header_valid: false,
snapshot_hash: REFERENCE_HASH,
snapshot_hash_sources: 2,
});
expect(execution.limitations.join("\n")).toContain(
"ELF header or table extent",
);
},
);
it.each([0, 4, 20, 47, 61])(
"reports a %i-byte ELF prefix as partial without throwing",
async (length) => {
const execution = await analyzeImage(
aotImage().subarray(0, length),
"identify_flutter_build",
);
const value = flutterResultSchemas.identify_flutter_build.parse(
execution.result,
);
expect(value.coverage).toBe("partial");
expect(value.abis[0]?.libapp.elf_header_valid).toBe(false);
},
);
it("accepts a section-stripped ELF32 header and bounds its program table", async () => {
const image = Buffer.alloc(52 + 32 + 104);
image.writeUInt32LE(0x464c457f);
image.set([1, 1, 1], 4); // ELF32, little-endian, version 1
image.writeUInt16LE(3, 0x10); // ET_DYN
image.writeUInt32LE(1, 0x14);
image.writeUInt32LE(52, 0x1c);
image.writeUInt16LE(52, 0x28);
image.writeUInt16LE(32, 0x2a);
image.writeUInt16LE(1, 0x2c);
image.writeUInt32LE(1, 52); // PT_LOAD
image.writeUInt32LE(image.length, 52 + 16);
snapshotSection(REFERENCE_HASH).copy(image, 84);
snapshotSection(REFERENCE_HASH).copy(image, 136);
const good = await analyzeImage(image, "identify_flutter_build");
expect(
flutterResultSchemas.identify_flutter_build.parse(good.result).coverage,
).toBe("complete");
image.writeUInt32LE(image.length - 31, 0x1c); // One byte short of the table.
const bad = await analyzeImage(image, "identify_flutter_build");
expect(
flutterResultSchemas.identify_flutter_build.parse(bad.result).coverage,
).toBe("partial");
});
it.each([0n, 15n, 47n, 49n, 1n << 63n])(
"retains malformed snapshot length %s as partial evidence",
async (length) => {
const image = aotImage();
image.writeBigUInt64LE(length, 0x1004);
const execution = await analyzeImage(image, "inspect_dart_aot");
const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result);
expect(value.coverage).toBe("partial");
expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH);
expect(value.libapp.sections[0]).toMatchObject({
magic_valid: true,
header_length:
length > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(length),
});
expect(execution.limitations.join("\n")).toContain(
"_kDartVmSnapshotData has an incomplete or invalid snapshot header",
);
},
);
it("does not let two VM records replace a missing isolate header hash", async () => {
const image = aotImage();
const elf = buildElf64Image([
{ name: "_kDartVmSnapshotData", value: 0x1000, size: 104 },
{ name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 },
{ name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 },
{ name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 },
]);
elf.copy(image);
snapshotSection(REFERENCE_HASH).copy(image, 0x1000 + 52);
image[0x2000 + 20] = 0x78; // Keep the isolate magic but invalidate its hash.
const execution = await analyzeImage(image, "inspect_dart_aot");
const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result);
expect(value.libapp.sections).toHaveLength(4);
expect(value.coverage).toBe("partial");
expect(value.libapp.snapshot_hash_sources).toBe(1);
expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]);
expect(execution.limitations.join("\n")).toContain(
"_kDartIsolateSnapshotData has an incomplete or invalid snapshot header",
);
});
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s preserves an oversized APK refusal as a resource constraint",
async (operation) => {
const root = await createTestTempDirectory("rea-flutter-size-");
const path = join(root, "oversized.apk");
const file = await open(path, "wx");
try {
await file.truncate(2 * 1024 ** 3 + 1); // Sparse file: no multi-GiB allocation.
} finally {
await file.close();
}
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (result.ok) throw new Error("expected a size refusal");
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
expect(result.error).toMatchObject({
operation,
reportedLimits: {
max_apk_bytes: 2 * 1024 ** 3,
observed_bytes: 2 * 1024 ** 3 + 1,
},
});
},
);
it.each([
{ operation: "identify_flutter_build" },
{ operation: "inspect_dart_aot" },
] as const)(
"preserves native stream aborts during $operation digest",
async ({ operation }) => {
const path = await buildApk("abort.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const controller = new AbortController();
const nativeFs = await vi.importActual<typeof import("node:fs")>("node:fs");
const streamMock = vi.mocked(createReadStream);
let abortCode: string | undefined;
streamMock.mockImplementation((streamPath, options) => {
const stream = nativeFs.createReadStream(streamPath, options);
if (streamPath !== path) {
stream.once("error", (cause: NodeJS.ErrnoException) => {
abortCode = cause.code;
});
stream.once("readable", () =>
controller.abort(new Error("caller requested stop")),
);
}
return stream;
});
onTestFinished(() => {
streamMock.mockReset();
});
const removedRoots: string[] = [];
const provider = new FlutterBuildProvider({
removeRoot: async (root) => {
await rm(root, { recursive: true, force: true });
removedRoots.push(root);
},
});
onTestFinished(() => provider.close());
const result = await provider.execute(
{ operation, input: { path } },
{ signal: controller.signal },
);
if (result.ok) throw new Error("expected cancellation");
expect(abortCode).toBe("ABORT_ERR");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error).toMatchObject({ operation });
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(removedRoots).toHaveLength(1);
},
);
it.each([undefined, "arm64-v8a", "x86_64", "mips"])(
"applies the library cap only to selected ABI %s",
async (abi) => {
const path = await buildApk("mixed-sizes.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
{ path: "lib/x86_64/libapp.so", bytes: aotImage() },
]);
const originalEntries = ZipArtifactReader.prototype.entries;
const inventory = vi
.spyOn(ZipArtifactReader.prototype, "entries")
.mockImplementation(async function* (this: ZipArtifactReader, signal) {
for await (const entry of originalEntries.call(this, signal))
yield entry.path.includes("x86_64")
? { ...entry, declaredSize: 256 * 1024 ** 2 + 1 }
: entry;
});
const reads = vi.spyOn(ZipArtifactReader.prototype, "open");
onTestFinished(() => {
inventory.mockRestore();
reads.mockRestore();
});
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_dart_aot",
input: { path, ...(abi === undefined ? {} : { abi }) },
});
if (abi === "x86_64" || abi === "mips") {
if (result.ok) throw new Error("expected selected-ABI refusal");
expect(projectAnalysisError(result.error).code).toBe(
abi === "x86_64" ? "resource_constraint" : "unsupported_target",
);
expect(reads).not.toHaveBeenCalled();
} else {
if (!result.ok) throw result.error;
expect(
flutterResultSchemas.inspect_dart_aot.parse(result.value.result).abi,
).toBe("arm64-v8a");
expect(reads.mock.calls.map(([entry]) => entry.path)).toEqual([
"lib/arm64-v8a/libapp.so",
]);
}
},
);
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s preserves a real ZIP metadata budget refusal",
async (operation) => {
const root = await createTestTempDirectory("rea-flutter-metadata-");
const path = join(root, "large-directory.apk");
const writer = new ZipWriter(new Uint8ArrayWriter(), {
useWebWorkers: false,
});
const comment = "x".repeat(65535);
for (let index = 0; index < 129; index += 1)
await writer.add(
`assets/empty-${String(index)}`,
new Uint8ArrayReader(new Uint8Array()),
{ comment, level: 0 },
);
await writeFile(path, await writer.close());
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (result.ok) throw new Error("expected metadata capacity refusal");
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
expect(result.error).toMatchObject({
operation,
reportedLimits: { max_metadata_read_bytes: 8 * 1024 ** 2 },
});
expect(result.error.message).toContain("ZIP metadata read requires");
},
);
it("refuses an AOT image whose snapshot names are all undefined imports", async () => {
const image = aotImage();
const table = Number(
image.readBigUInt64LE(Number(image.readBigUInt64LE(0x28)) + 64 + 0x18),
);
for (let index = 0; index < 4; index += 1)
image.writeUInt16LE(0, table + index * 24 + 6);
await expect(analyzeImage(image, "inspect_dart_aot")).rejects.toMatchObject({
_tag: "AnalysisUnsupportedTargetError",
});
});
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s retains a reader whose close failed before removing its root",
async (operation) => {
const path = await buildApk("reader-close.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const readers = new Set<ZipArtifactReader>();
const originalClose = ZipArtifactReader.prototype.close;
let failClose = true;
const close = vi
.spyOn(ZipArtifactReader.prototype, "close")
.mockImplementation(async function (this: ZipArtifactReader) {
readers.add(this);
if (failClose) throw new Error("file handle close refused");
await originalClose.call(this);
});
const removeRoot = vi.fn((root: string) =>
rm(root, { recursive: true, force: true }),
);
const provider = new FlutterBuildProvider({ removeRoot });
onTestFinished(async () => {
failClose = false;
await provider.close();
for (const reader of readers) await originalClose.call(reader);
close.mockRestore();
});
const result = await provider.execute({ operation, input: { path } });
if (!result.ok) throw result.error;
expect(removeRoot).not.toHaveBeenCalled();
await expect(provider.close()).rejects.toThrow("snapshot cleanup failed");
expect(removeRoot).not.toHaveBeenCalled();
failClose = false;
await provider.close();
expect(close).toHaveBeenCalledTimes(3);
expect(removeRoot).toHaveBeenCalledTimes(1);
},
);
it("reports omitted Dart labels independently of toolchain labels", async () => {
const path = await buildApk("dart-label.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
{
path: "lib/arm64-v8a/libflutter.so",
bytes: Buffer.concat([
libflutter(),
Buffer.from(`Dart VM version:${"x".repeat(201)}\0`),
]),
},
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (!result.ok) throw result.error;
const value = flutterResultSchemas.identify_flutter_build.parse(
result.value.result,
);
expect(value.coverage).toBe("partial");
expect(value.abis[0]?.libflutter).toMatchObject({
dart_version: null,
dart_version_capped: true,
toolchain_capped: false,
});
});
it.each([0, 1, 2, 3])(
"retains failed local file-handle closure at acquisition stage %i",
async (stage) => {
const path = await buildApk("file-close.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
// Close order: snapshot writer, admitted source, extracted-image writer, image reader.
const owners: OwnedFileHandle[] = [];
const originalClose = OwnedFileHandle.prototype.close;
let failing = true;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!owners.includes(this)) owners.push(this);
if (failing && owners[stage] === this)
throw new Error("handle close refused");
await originalClose.call(this);
});
const removeRoot = vi.fn((root: string) =>
rm(root, { recursive: true, force: true }),
);
const provider = new FlutterBuildProvider({ removeRoot });
onTestFinished(async () => {
failing = false;
await provider.close();
for (const owner of owners) await originalClose.call(owner);
close.mockRestore();
});
const result = await provider.execute({
operation: "inspect_dart_aot",
input: { path },
});
expect(result.ok).toBe(false);
expect(removeRoot).not.toHaveBeenCalled();
await expect(provider.close()).rejects.toThrow("snapshot cleanup failed");
failing = false;
await provider.close();
expect(removeRoot).toHaveBeenCalledTimes(1);
},
);
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s rejects duplicate real ZIP members before extracting either occurrence",
async (operation) => {
const path = await createDuplicateFlutterApk();
const extract = vi.spyOn(ZipArtifactReader.prototype, "open");
onTestFinished(() => extract.mockRestore());
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (result.ok) throw new Error("Duplicate libraries must be refused");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
});
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
"duplicate Flutter library members",
);
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(path);
expect(extract).not.toHaveBeenCalled();
},
);
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s refuses ABI metadata expansion before opening empty libraries",
async (operation) => {
const path = await buildApk(
"many-abis.apk",
Array.from({ length: 33 }, (_, index) => ({
path: `lib/abi_${String(index)}/libapp.so`,
bytes: Buffer.alloc(0),
})),
);
const extract = vi.spyOn(ZipArtifactReader.prototype, "open");
onTestFinished(() => extract.mockRestore());
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (result.ok) throw new Error("ABI expansion must be refused");
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
expect(extract).not.toHaveBeenCalled();
},
);
it.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
"%s uses the selected path in malformed-APK diagnostics",
async (operation) => {
const root = await createTestTempDirectory("rea-flutter-malformed-");
const path = join(root, "selected.apk");
await writeFile(path, "not a ZIP");
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({ operation, input: { path } });
if (result.ok) throw new Error("Malformed APK must be refused");
const error = projectAnalysisError(result.error);
expect(error).toMatchObject({
code: "invalid_request",
details: { issues: [{ message: expect.stringContaining(path) }] },
});
expect(JSON.stringify(error)).not.toContain("input.apk");
},
);
it.each(["EACCES", "EPERM"] as const)(
"preserves %s at the shared source admission boundary",
async (code) => {
const admitted = vi
.spyOn(regularFile, "openRegularFile")
.mockRejectedValue(Object.assign(new Error("denied"), { code }));
onTestFinished(() => admitted.mockRestore());
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
for (const operation of [
"identify_flutter_build",
"inspect_dart_aot",
] as const) {
const result = await provider.execute({
operation,
input: { path: "/selected.apk" },
});
if (result.ok) throw new Error("Denied read must fail");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
});
}
},
);
it.each(["EACCES", "EPERM"] as const)(
"preserves %s during opened-source reads and path revalidation",
async (code) => {
const path = await buildApk("source-denied.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const original = regularFile.openRegularFile;
const cause = Object.assign(new Error("source denied"), { code });
for (const stage of ["read", "revalidate"] as const) {
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const admitted = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (...args) => {
const handle = await original(...args);
if (stage === "read")
vi.spyOn(handle, "read").mockRejectedValueOnce(cause);
return handle;
});
const pathStat =
stage === "revalidate"
? vi.spyOn(fsPromises, "stat").mockRejectedValueOnce(cause)
: undefined;
try {
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (result.ok) throw new Error("Denied source must fail");
expect(projectAnalysisError(result.error).code).toBe("access_denied");
expect(result.error.cause).toBe(cause);
} finally {
admitted.mockRestore();
pathStat?.mockRestore();
}
}
},
);
it("detects same-size source rewrites with restored mtime through the opened descriptor", async () => {
const path = await buildApk("changing.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const before = await stat(path);
const content = await readFile(path);
const nativeAdmission = regularFile.openRegularFile;
const admitted = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (...args) => {
const handle = await nativeAdmission(...args);
// Admission returns an open descriptor; mutate its file before the final
// descriptor stat, preserving the size and restoring the visible mtime.
const nativeStat = handle.stat.bind(handle);
let calls = 0;
vi.spyOn(handle, "stat").mockImplementation(async (...statArgs) => {
calls += 1;
if (calls === 2) {
await new Promise((resolve) => setTimeout(resolve, 10));
await writeFile(path, content);
await utimes(path, before.atime, before.mtime);
}
return nativeStat(...statArgs);
});
return handle;
});
onTestFinished(() => admitted.mockRestore());
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (result.ok) throw new Error("Concurrent source rewrite must fail");
expect(projectAnalysisError(result.error).code).toBe("artifact_changed");
});
it("refuses truncated hash-candidate evidence and includes five-character tokens", async () => {
const image = aotImage();
Buffer.from("Login\0").copy(image, 0x2300);
const aot = await analyzeImage(image, "inspect_dart_aot");
expect(
flutterResultSchemas.inspect_dart_aot.parse(aot.result).string_pool
.class_like_tokens,
).toContain("Login");
const bytes = Buffer.concat(
Array.from({ length: 65 }, (_, index) =>
snapshotSection(index.toString(16).padStart(32, "0")),
),
);
const path = await buildApk("many-hashes.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes },
]);
const provider = new FlutterBuildProvider();
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "identify_flutter_build",
input: { path },
});
if (result.ok) throw new Error("Incomplete candidate set must be explicit");
expect(projectAnalysisError(result.error).code).toBe("resource_constraint");
});
it("stops a cancelled descriptor copy before writing its next chunk and releases every owner", async () => {
const path = await buildApk("copy-cancel.apk", [
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
]);
const controller = new AbortController();
const nativeAdmission = regularFile.openRegularFile;
const admitted = vi
.spyOn(regularFile, "openRegularFile")
.mockImplementation(async (...args) => {
const handle = await nativeAdmission(...args);
const nativeRead = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
const result = await nativeRead(...readArgs);
controller.abort(new Error("cancelled while copy read was pending"));
return result;
});
return handle;
});
onTestFinished(() => admitted.mockRestore());
const removeRoot = vi.fn((root: string) =>
rm(root, { recursive: true, force: true }),
);
const provider = new FlutterBuildProvider({ removeRoot });
onTestFinished(() => provider.close());
const result = await provider.execute(
{ operation: "identify_flutter_build", input: { path } },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Cancelled copying must fail");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(removeRoot).toHaveBeenCalledTimes(1);
});