mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
* feat(flutter): identify Dart snapshot builds by pure APK parsing Add a flutter-provider family with one operation, identify_flutter_build, that reads an APK's lib/<abi>/libapp.so and libflutter.so through the hardened ZIP reader and reports build facts as observations: - The Dart VM snapshot hash, read 20 bytes after every snapshot magic (f5f5dcdc). The vm and isolate sections are cross-checked: agreeing sections collapse to one snapshot_hash, disagreeing sections keep every candidate with snapshot_hash null and partial coverage. - Per-ABI library digests and byte sizes, plus the engine library's GNU build-id note and any labeled 'Dart VM version' or Android clang/LLVM toolchain strings, verbatim; release builds without a version string report null rather than a guess. - The APK is digested before parsing so Evidence binds the observation to the exact input, and a target with no libapp.so reports not-detected with complete coverage. The scanner is streaming with explicit pending-magic and rescan bookkeeping, so chunk boundaries cannot double-count or drop sections, and magic-free streams never accumulate. No engine, no external lookups: the hash-to-SDK mapping stays with external datasets by design. Verified against Flutter Gallery 2.9.2 (112 MB, three ABIs): every ABI reports the same agreeing hash 65817c30a78bb44c3dc3771876b6010a from two sections, per-ABI build-ids, and the clang toolchain line; a plain aapt2-built APK reports not-detected with complete coverage. Scanner goldens come from the same libraries. READMEs carry the new 'Flutter apps' row in every supported language. * feat(flutter): inspect Dart AOT snapshots through the ELF symbol table Add inspect_dart_aot to the flutter family: for one ABI's libapp.so it locates the four _kDart*Snapshot* symbols through the ELF dynamic symbol table (ELF32 and ELF64 little-endian, bounds-checked reads; a payload without exported symbols is refused), reports each section's offset and size, validates data-section headers (magic, kind, length; instructions sections honestly report nulls for the fields their layout does not carry), and projects a bounded string pool from the isolate data section: distinct package: source URIs (the app's dependency inventory), dart: SDK URIs, and identifier-shaped tokens, each with counts, bounds, and partial coverage when truncated. The string pool is labeled as a byte-pattern projection, not deserialized cluster semantics: typed class and function recovery remains an SDK-specific parser, which the snapshot hash now unlocks. The ABI is caller-selected with a deterministic default (arm64-v8a, x86_64, then first available). Verified against Flutter Gallery 2.9.2: all four symbols on both ELF64 (arm64-v8a) and ELF32 (armeabi-v7a) images, header validation, hash agreement with identify_flutter_build, and a 756 package: / 201 dart: URI pool from the isolate data section; goldens in DartElf.test.ts come from the same libraries. * fix(flutter): own snapshots, bound image reads, and preserve cancellation Mirror the ownership bar the maintainers applied across the merged JEB, ADB, and Apktool PRs: - Copy the admitted APK into a provider-owned, read-only snapshot (EXCL create, chmod 0400) before digesting and parsing; every later read uses the snapshot, so a concurrently modified original cannot mix into one observation. The result reports the snapshot's byte size and digest. - Retain failed snapshot-root cleanup in a provider set and retry it on close(), throwing an aggregate error when a root is still owned; root removal is injectable so the retention is testable. - Allocate the AOT image buffer at exactly the declared library size before reading (bounded before allocation, not concat-after-stream), keep the 256 MiB ceiling, and check cancellation between digest and stream chunks through a captured abort reason. - Accept APK paths containing spaces (covered by a fixture test) and escape the ABI pattern's dash for v-mode regex compatibility. Also add tests for cleanup retention with close retry and for cancellation before any snapshot root is created. * docs(flutter): cover the Italian README with the Flutter row The Italian translation landed between the merged feature PRs; its table now carries every merged row plus this one, keeping its documentation-link set identical to README.md. * fix(flutter): make the ABI pattern portable for v-mode validators Use a hex-escaped hyphen in the ABI character class, matching the form the ADB portability fix uses, so clients compiling advertised schemas in unicodeSets mode accept the inspect_dart_aot input schema. * refactor(flutter): reuse shared harnesses and deduplicate the vertical The duplicate-detection gate (jscpd, 2.5% threshold) tipped to 2.51% with the flutter files added. Reuse the existing machinery instead of repeating it: - FlutterBuildAnalysisService delegates to executeProviderOperation, the shared admission/Evidence composition, instead of restating its body. - The CLI registers both commands from one table-driven spec. - Boundary tests use the shared serveCli and assertAdvertisedMcpContracts harnesses rather than hand-rolled equivalents. - The provider test consolidates the four payload-shape cases into one table-driven case; the service test uses one admission table and distinct phrasings. Duplicate lines fall from 2.51% to 2.48%, back under the gate. * fix(flutter): address review findings from real-app verification CassieFox23 verified the PR against three real F-Droid Flutter apps and a control app, and found five gaps. All are fixed and re-verified against cone (info.tangential.cone), one of the tested apps: - Build-id: the scanner now reads the descriptor length from the note header instead of assuming 20 bytes, so 8-byte ids from older toolchains parse; the schema accepts 8-128 hex characters. Verified against cone's readelf-reported ids on all three ABIs. - Compiler line: the toolchain capture bound rises from 200 to 500 characters, keeping cone's 262-character clang line verbatim. - Zero-byte libapp.so: the extraction target is created before streaming, so an empty entry yields a clean unsupported_target refusal for inspect_dart_aot and elf_header_valid: false with partial coverage for identify_flutter_build, instead of a raw ENOENT with code UNKNOWN. - Truncated library: identify_flutter_build marks fewer than two snapshot sections as partial coverage with a limitation naming the count, and a missing ELF magic gets its own limitation. - Docs: the default ABI preference now matches the code exactly (arm64-v8a, x86_64, x86, armeabi-v7a, then the rest alphabetically). Also: every test that read the author's /tmp/opencode goldens now builds a self-contained synthetic ELF64 image with dynsym, four snapshot symbols, two snapshot sections, and a string pool, so the suite runs anywhere; the verify lane now requires a present hex build-id per ABI instead of accepting null. * fix(flutter): address the Codex review findings Eight findings, all applied: - P1: SnapshotHashScanner keeps candidates in a Set (no quadratic Array.includes) with a 64-distinct-hash cap, so a hostile payload of magic+hash records cannot stall the scan or bloat the response. - P1: projectStringPool stops admitting new distinct entries once each category reaches twice its projection bound and reports collectionCapped; allocation and sorting now track the response, not the payload. - P2: ZIP reader rejections during entry iteration or entry streaming map into the typed error algebra (cancellation vs input refusal) instead of escaping as unhandled exceptions in both operations. - P2: magic_valid now marks coverage only for the *Data symbols; a complete four-symbol image with valid data magics reports complete coverage (the instruction symbols intentionally lack the magic). - P2: the MCP server closes the Flutter provider during shutdown, so a retained snapshot root is retried and reported, not leaked. - P2: LabeledStringScanner commits matches only when a printable run is delimiter-terminated; the carry grows to 600 bytes so a 500-character toolchain line survives any chunk boundary untruncated. - P2: both Flutter tool effects declare writesFilesystem: true; the owned APK snapshot and extracted libapp.so are temporary writes. - P2: the verify lane asserts every ABI agrees on exactly one snapshot hash instead of discarding the computed set. * fix(flutter): address the second Codex review round Nine findings on the first-round fixes: - P1: identify_flutter_build enforces a cumulative extraction budget (768 MiB) across every library entry, not just per library, so an APK with many ABIs cannot stream an unbounded total. - P1: a single NUL-free printable run stops materializing at 1024 bytes and marks the pool capped, so an adversarial one-run section cannot allocate unbounded strings. - P1: ELF cString bounds the NUL-terminator scan to the name maximum plus one, so malformed symbol tables cannot scan the whole image per entry. - P2: mapReaderFailure distinguishes ArtifactReaderFailure texts — budget/limit rejections map to resource constraints, integrity failures (CRC/overlap/signature) to their own input refusal — instead of collapsing all reader failures into one message. - P2: the labeled-string carry is cleared when a delimiter commits a run, so consecutive toolchain lines are never concatenated. - P2: string-pool counts are renamed *_count_at_least and documented as lower bounds, because capped collection discards values beyond twice the projection bound. - P2: inspect_dart_aot marks hash disagreements, non-pair source counts, and candidate-cap saturation as partial coverage. - P2: only valid lowercase-hex hash records count as snapshot sources; complete magic records with non-hex fields do not inflate agreement. - P2: the per-entry size refusal in inspect_dart_aot moves after ABI selection, so an oversized unselected ABI no longer refuses a small request. * fix(flutter): address the third Codex review round Five findings: - P2: the owned snapshot revalidates the source after the copy (size, mtime, inode), so an in-place replacement or swap mid-copy refuses the observation instead of publishing mixed bytes. - P2: a failed ZipArtifactReader close retains the snapshot root for close() retry instead of silently swallowing the descriptor leak, in both operations. - P2: snapshot headers are parsed within their symbol bounds and a symbol smaller than the 16-byte header reports magic_valid false, so trailing-image bytes can no longer fake a valid data section. - P2: the verify lane counts provider.close() cleanup failures as lane failures instead of certifying a run with retained roots. - P2: LabeledStringScanner reports toolchain_capped when further distinct matching lines are observed past the 4-line projection; identify marks such results partial with a naming limitation. * fix(flutter): address rounds three and four of the Codex review Round three (one missed finding) plus round four (five findings): - P2: the GNU build-id note descriptor size is decoded from all four bytes of the little-endian field, rejecting nonzero high bytes instead of masking them to 16 bits. - P1: the Flutter MCP handlers route through createContractToolHandler and the session's analysisAdmission, so close_binary and snapshot operations drain in-flight Flutter calls instead of racing them. - P2: the owned-snapshot copy is a streamed, abort-checked write (no copyFile), so cancellation stops the write promptly while the source-identity revalidation (size, mtime, inode) still guards against mid-copy replacement. - P2: identify marks any snapshot_hash_sources value other than exactly two as partial, matching the inspection path's pair requirement. - P2: the verify lane refuses to run without --apk (exit 2) instead of printing a false-green pass, and the engine check requires every libapp-carrying ABI to have a present libflutter with a valid hex build-id rather than filtering missing engines away vacuously. * fix(flutter): address the fifth Codex review round Six findings plus the upstream duplication baseline: - P2: the flutter-provider kind reports available in the capability inventory, so binary_session no longer lists always-registered Flutter tools as provider_missing. - P2: printable runs that exceed the 1024-byte cap are discarded at their delimiter instead of emitting their truncated prefix as a fabricated observation. - P2: ELF symbols must fit inside the file-backed portion of their PT_LOAD segment (p_filesz), so addresses in gaps or BSS are refused instead of accepting unrelated bytes. - P2: the build-id carry spans the complete 80-byte maximum note (16-byte header + 64-byte descriptor) so chunk-boundary splits cannot drop the first byte. - P2: the labeled-string carry is sized for the toolchain pattern's true 605-byte maximum, not the previous 600. - P2: the AOT completeness predicate requires exactly two sources and one candidate unconditionally, so zero-source images with valid data magics report partial instead of complete. Also: the shared ELF fixture builder and snapshot-section helper moved to a dedicated FlutterTestFixtures module, eliminating cross-file test duplication; jscpd now passes at 2.50% while main sits at 2.52%. * fix(flutter): address the sixth Codex review round Five findings: - P2: snapshot-hash scanning is bounded to the exported data symbols' byte ranges, so decoy or stale magic/hash sequences elsewhere in the image cannot inflate source counts or contribute candidates. - P2: labeled-string runs longer than the carry bound are rejected instead of truncated, preventing a regex prefix from being published as a verbatim source string. - P2: the AOT image reread checks cancellation between file reads and before the synchronous parsing stage, so a cancelled caller cannot receive a success from a completed 256 MiB read. - P2: the verify lane requires exactly two snapshot sources per ABI, matching the provider's pair requirement, instead of accepting any count >= 2. - P2: ELF section and program header entry sizes are validated against the class-defined minimum widths before walking either table, so undersized entries cannot cause field reads to cross into neighboring bytes. * fix(flutter): address the seventh Codex review round Six findings: - P2: data-symbol ranges are deduplicated by offset and size, so aliased symbols mapping to the same bytes are scanned once and the result marks partial coverage with a limitation naming the shared range. - P2: openEntrySafely forwards return/throw to the underlying Readable's iterator, so an early for-await exit closes the ZIP entry stream and releases its producer instead of leaking a backpressured pipe. - P2: inspect_dart_aot now reports snapshot_hash_candidates and snapshot_hash_sources alongside snapshot_hash, so a null hash from conflicting evidence is distinguishable from missing hash data. - P2: overlong printable runs only set toolchain_capped when they begin with a recognized label prefix (Dart VM version: or Android (); other long strings such as license text are skipped without marking partial. - P2: readElfSymbol falls back to PT_DYNAMIC/DT_SYMTAB when section headers are absent (e_shnum 0, ET_DYN), so stripped Flutter libraries with exported snapshot symbols are no longer refused; the dynamic table is bounded by DT_STRSZ, DT_SYMENT, and the image end. - P2: the verify lane exercises inspect_dart_aot for every ABI carrying libapp.so, not just the preferred default. * fix(flutter): address the eighth Codex review round Five findings: - P2: each data-symbol range gets a fresh SnapshotHashScanner so the carry from one noncontiguous slice cannot fabricate a record spanning into the next; results are aggregated after scanning. - P2: dynsym name lookups are bounded to the linked string table — st_name offsets outside the table and terminators beyond it are rejected instead of reading unrelated payload bytes as symbol names, in both the section-header and PT_DYNAMIC paths. - P2: identify_flutter_build routes its reader.entries and reader.open calls through the same collectEntriesSafely and openEntrySafely wrappers as inspect_dart_aot, so malformed ZIPs surface as input refusals instead of AnalysisProtocolError. - P2: mapReaderFailure recognizes ArtifactReaderFailure texts carrying a cancelled reason, mapping them to AnalysisCancelledError instead of AnalysisInputError. - P2: the cumulative extraction cap is enforced inside the chunk loop, not after each library completes, so refusal happens before the remaining bytes are streamed and hashed. * fix(flutter): harden streaming, sizes, and cancellation after self-review Four issues found in a systematic self-audit across all bug classes from eight Codex rounds: - createReadStream in both the digest function and the owned-snapshot copy now receives the AbortSignal directly, so cancellation aborts the filesystem read at the source instead of waiting for the next chunk to arrive on a slow filesystem. - The owned snapshot enforces a 2 GiB APK-level size cap before any copy I/O, so a path pointing at an enormous file is refused before disk usage grows. - identify_flutter_build preflights the sum of declared entry sizes against the 768 MiB cumulative budget, refusing before any ZIP extraction when the declared total already exceeds it. - The LabeledStringScanner triple result() call is collapsed to a single invocation (the previous code called result() three times in the emission, relying on end() being idempotent). No behavior change for valid inputs; all 36 tests, check:fast, and the cone verify lane pass unchanged. * fix(flutter): require structurally parseable ELF headers for complete coverage CassieFox23's third corrupt-header case: an ELF with valid magic but zeroed section/program header offsets passes the 4-byte magic check and the byte scanner still finds hash patterns in the data sections, so identify_flutter_build reports complete coverage for a structurally broken file. The elf_header_valid check now also verifies beyond the magic: e_type must be a known value (ET_REL/ET_EXEC/ET_DYN), at least one of e_shoff/e_phoff must be non-zero, and at least one of e_shnum/e_phnum must be non-zero. A file with valid magic but zeroed structure reports partial coverage with the existing ELF-magic limitation. * fix(flutter): validate ELF and snapshot coverage Share bounded ELF header parsing between identification and inspection. Accumulate headers across ZIP chunks and validate table entry widths and extents before claiming complete identification coverage. Bind AOT hashes to the exported VM and isolate headers, validate stored snapshot lengths including the magic bytes, and preserve partial evidence for missing, malformed, aliased, or overlapping data headers. Preserve native stream cancellations and APK resource constraints across both operations. Bound snapshot copies while streaming and retain useful input-failure diagnostics and lower-bound count wording. Validation: 72 focused tests; check:fast; check including formatting; docs:check; test discovery; jscpd; real cone APK and non-Flutter control on Linux. Verified CLI/MCP parity for valid and corrupted ELF headers and AOT inspection across three ABIs. Native Windows and macOS were not run. * fix(flutter): unify artifact ownership and bound ELF evidence * fix(flutter): preserve ELF provenance and partial coverage Read GNU build IDs only from bounded ELF note ranges and retain conflicting candidates. Preserve source versus owned storage failures and honor cancellation through parsing and final cleanup. Separate snapshot structure from string-pool coverage. Verify every Gallery ABI with digest-bound engine labels and URI counts, and document the supported snapshot-kind layout. --------- Co-authored-by: REA Contributor <contributor@rea.tools> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
1333 lines
47 KiB
TypeScript
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);
|
|
});
|