mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
69a2faa120
* feat: add FreeBuff agent support for REA setup Register FreeBuff as a supported setup client using the shared user agents directory (~/.agents/mcp.json with mcpServers, skills in ~/.agents/skills), which matches what FreeBuff Desktop loads. Adds the client definition, boundary tests, supported-agent docs, and the skill reference client list. * fix(freebuff): detect FreeBuff from its profile directory, not shared .agents The ~/.agents marker made FreeBuff appear installed for any home where REA had installed shared skills (the default skill destination, also Gemini CLI's), producing false doctor failures. Detect FreeBuff from its own fixed profile directory ~/.config/freebuff-desktop instead, and add a regression test covering a home with only the shared agents skill directory. * fix(setup): require product evidence for FreeBuff discovery Address review findings at their producing boundaries and preserve directly affected consumers. Source inspection and git diff --check completed. Tests and automated checks were not run at the user’s explicit request. * fix(uninstall): retain unverified shared client configuration * fix(clients): preserve scoped removal evidence * fix(doctor): omit undefined selectedClientIds under exactOptionalPropertyTypes Passing selectedClientIds explicitly as undefined fails typecheck on this branch (TS2379), which blocks the focused test workflow before any test can run. Spread the option only when defined. * fix(update): separate ownership evidence from product applicability rea update's maintenance scope inspected only product applicability evidence, so when a FreeBuff profile was absent but REA already owned a version-pinned row in the shared ~/.agents/mcp.json, the plan omitted freebuff and left the integration stale. FreeBuff now declares its registration storage as shared, which proves ownership of an owned row but never product applicability, and maintenance requests ownership evidence through that declaration instead of reinterpreting the shared paths. Doctor and setup keep readiness evidence, foreign rows stay out of maintenance, and the Doctor exactOptionalPropertyTypes fix makes the focused test workflow runnable. * test(dotnet): split oversized operand extent diagnostics describe The describe callback reached 162 lines (max 150), failing max-lines-per-function and blocking the pre-push check for this branch. Move the single-case extent scenarios into their own describe; no test content or order changes. * fix(update): preserve ZCode's disabled shared registration during maintenance The shared user agents file doubles as ZCode's fallback, where `enable: false` disables the registration. Ownership-based maintenance read that row as a FreeBuff registration, whose format does not recognize the flag, so the row was selected and the emitted setup command rewrote it, dropping the flag and silently re-enabling a registration the user had disabled. registrationActivation now honors `enable: false` for any client whose registration storage is shared, because the activation fact binds every reader of that storage. Maintenance leaves such rows out; doctor reports them as stale, matching ZCode's own treatment. * fix(doctor): let explicit client selection authorize the config read When an ACL or an inaccessible shared parent made every access probe fail, a client selected by name was omitted before the configuration read could return its unreadable-config detail, so doctor reported it as not detected with installation remediation: an unresolved selected path collapsed to absence. An explicit selection now authorizes the read outright and the probes serve automatic discovery only. A selected client with an unreadable config reports invalid with the read failure; one with no config reports missing; automatic discovery still probes before inspecting. * fix(uninstall): share shared-storage checks between ZCode and FreeBuff clients() queued ~/.agents/mcp.json twice: once as zcode_fallback and once as freebuff. ZCode has no discoveryPaths, so its strict read still blocked uninstall on malformed shared text, despite FreeBuff's new retain-and-continue behavior. For a valid owned entry, ZCode removed it first, so FreeBuff reported skipped rather than removed. Make the shared file's checks and removal one shared decision: - Add zcodeDiscoveryPaths to ZCode so it has its own applicability evidence. - Gate zcode_fallback push on applicable, not just zcodeFallbackPath presence. - Report shared storage ownership as one decision for both clients: FreeBuff and ZCode now use clientOwnershipEvidencePaths together, so a recognizable REA-owned row proves ownership once for the shared storage, and both clients report the same result consistently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(uninstall): merge duplicated host and prove shared ownership from owned rows Uninstall.ts declared systemUninstallHost twice, preventing module load, with the exported copy returning only clients and the second copy missing clientWriteBlocker. Merge into one exported host carrying both blockers. hasOwnershipEvidence used access() existence, so an unrelated malformed ~/.agents/mcp.json established ownership and blocked every removal. Ownership now requires a readable, recognizable REA-owned row via the injected filesystem, so unrelated shared data stays retained. Also cover ZCode discoveryPaths in its path test. * fix(uninstall): keep denied product-local configurations fail-closed Unproven product applicability marked every configuration path of that client unverified, so a denied ZCode profile turned an unreadable product-local config into a retained item: uninstall continued and could report complete with REA still registered. The retain-and-continue exception now applies only to storage REA does not own, classified once by clientSharedRegistrationPaths (shared configuration plus another product's same-scope fallback). Covers the Greptile P1 on Uninstall.ts. Validation: the new test fails on the pre-fix Uninstall and passes after it; freebuff/zcode/updateMaintenance/bom/dsh suites green; clientLifecycle, clientRegistrationStatus and claudeDesktop keep the same 8 host failures as the pre-merge baseline (symlink EPERM, stale-vs-aligned on Windows). * fix(update): retain FreeBuff when ZCode reads the shared fallback Maintenance deduplicated clients on the path each inspection read, so ZCode's fallback view of ~/.agents/mcp.json marked that file "covered" and dropped FreeBuff from the scope. ZCode setup writes ~/.zcode/cli/config.json and deliberately leaves the fallback untouched (docs/client-configuration.md), so the emitted command refreshed only ZCode while the shared row both products load stayed pinned to the old package version. Each setup action rewrites only its own configuration path and no other action rewrites the shared row, so every selected client keeps its own maintenance action; the read-path covered/deduped filter is removed. Covers the Codex P2 on UpdateMaintenance.ts:104. Validation: the new regression fails one2fdec384with {clients:["zcode"]} and passes with {clients:["freebuff","zcode"]}; updateMaintenance 31/31, setupClientConfigCleanupOwnership, cliUpdateAlias, updateOutput, cliStartupImports green; tsc --noEmit, oxlint (0 errors) and oxfmt --check clean; clientRegistrationStatus keeps the same 4 symlink/stale-vs-aligned failures this Windows host has on basee2fdec384(verified by stashing the change). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(uninstall): preserve completed reads as shared ownership evidence hasOwnershipEvidence caught every read failure and kept scanning, so a handle-close failure after a successful read — a RegularFileCleanupFailure whose completed outcome still contains the exact shared-file text — was discarded as "no proof". FreeBuff's applicability-unverified storage therefore never got its ownership fact, and once the retained handle blocked later reads, readClientConfiguration downgraded them to retained: inspection passed, removals soft-failed, and runUninstall(true) could purge REA data while the active shared registration remained. The catch now parses the completed outcome for the ownership text while missing or unreadable storage and malformed data keep their proves-nothing semantics, so ownership holds and every later read failure stays blocking. Covers the Codex P2 on Uninstall.ts:252. Validation: the new regression fails on4dbfdf1a9(freebuff reports no blocking failure while the poisoned-scope error lands on claude_code) and passes after; setupClientConfigCleanupOwnership 25/25; the other 21 Uninstall-consumer suites are byte-identical to base4dbfdf1a9(61 pre-existing Windows-host failures: symlink EPERM, stale-vs-aligned); tsc --noEmit, oxlint (0 errors, same 6 warnings) and oxfmt --check clean. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(uninstall): keep owned-row proof through removal refusals hasOwnershipEvidence treated any clientRegistrationRemoval refusal as no ownership: an effective REA-owned rea member shadowed by an earlier foreign duplicate (shadowed_unowned_member), or an owned native row beside a foreign V1 fallback (foreign_legacy_fallback), discarded the already-parsed positive row fact. With FreeBuff unverified and close failures recurring on the preflight/removal rereads, those failures were then downgraded to retained, so runUninstall(true) removed the skill and purged REA data while the active owned row remained. The refusal now carries the owned rows that motivated it — the refusal only refuses the edit — and hasOwnershipEvidence evaluates that fact on both paths; preflight and removal keep refusing the unsafe edit exactly as before. Covers the Codex P2 on Uninstall.ts:269. Validation: the new regression fails on225cb214a(freebuff reports no blocking failure while the poisoned-scope error lands on claude_code) and passes after; setupClientConfigCleanupOwnership 26/26; the 21 Uninstall-consumer suites are byte-identical to the recorded225cb214abaseline (61 pre-existing Windows-host failures: symlink EPERM, stale-vs-aligned); tsc --noEmit, oxlint (0 errors, same 6 warnings) and oxfmt --check clean. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(uninstall): keep unresolved discovery probes from softening shared failures The discovery probe swallowed every stat failure, so a FreeBuff profile denied with EACCES/EIO was indistinguishable from an absent profile: shared storage was marked applicability-unverified and, when ~/.agents/mcp.json also remained unreadable, the ownership and removal reads softened to retained. runUninstall(true) then removed the skill and purged REA data while an installed FreeBuff could still hold an active registration — the regression even reports complete. Absence (ENOENT) is now the only state that leaves shared paths unverified; any other probe failure leaves applicability unresolved, and shared-config failures stay blocking. Covers the Codex P2 on Uninstall.ts:318. Validation: the new regression fails on7572a7082with status complete and purged data and passes after; freebuffClientConfiguration 13/13; the Uninstall-consumer batches are byte-identical to the recorded baselines (61 pre-existing Windows-host failures: symlink EPERM, stale-vs-aligned); tsc --noEmit, oxlint (0 errors, same warning rules) and oxfmt --check clean. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(uninstall): retain completed configuration reads as removal evidence A close failure after a completed read discarded the text that read produced, so an owned registration row the text proves softened to retained. The completed text now drives removal, keeping shared-config failures blocking when ownership evidence exists. Co-Authored-By: Anthropic <noreply@anthropic.com> * fix(uninstall): stop removal when a cleanup retry leaves a handle unreleased A completed reread whose handle the cleanup retry cannot release now fails blockingly instead of serving the text: with no owned row, the soft path let removeAll purge managed data before close() reported the incomplete cleanup. The completed text is served only after the retry releases the handle; an unreleased handle is REA's own failure, never applicability uncertainty, so it bypasses the retained softening. Co-Authored-By: Anthropic <noreply@anthropic.com> * fix(uninstall): keep the ZCode fallback when its profile probe is unresolved A denied or failed profile probe leaves ZCode's applicability unresolved, never absent, but the fallback branch dropped the shared row on applicability alone: with the native config and FreeBuff's profile both absent, an installed ZCode kept its uninspected registration while uninstall purged skill and data. Unresolved probes now keep the fallback in scope with the same fail-closed treatment as other unresolved shared paths; definitive absence still keeps ZCode out of another product's primary storage. Co-Authored-By: Anthropic <noreply@anthropic.com> * fix(setup): preserve a shared row's enable:false during FreeBuff repair The shared agents file doubles as ZCode's fallback, so its `enable: false` binds every reader: doctor now reports the disabled row as stale for FreeBuff, but following that remediation rebuilt the generic JSON entry without the flag and silently re-enabled a registration ZCode disabled. Repairs of shared storage now carry the activation fact forward, while a client's own native storage keeps its established single-reader repair contract; the denied-profile uninstall regression also pins the fallback's new fail-closed inspection beside the denied read. Co-Authored-By: Anthropic <noreply@anthropic.com> * test(uninstall): retarget the completed-reread regression to a released retry The concurrent-writer regression held the reread handle open forever, which the fail-closed path now serves: it duplicated the unreleased-handle regression while documenting the opposite contract. The fixture's close spy gains a failure limit so the cleanup retry releases the handle, and the test again proves the completed text drives removal of the concurrently added owned row, with purge verified against the cache layout purge actually removes. Co-Authored-By: Anthropic <noreply@anthropic.com> * fix(doctor): report a preserved shared disable as a manual conflict Preserving a shared row's enable:false left doctor recommending a setup run that registrationCurrent correctly treats as already current, so the scoped doctor returned needs_human forever. The shared-enable interpretation now has one owner, sharedEnableFlagBinds, used by both the activation rule and the remediation: a row disabled through the shared flag reports the manual conflict and states that rea setup will not change it, instead of an endlessly ineffective repair. Co-Authored-By: Anthropic <noreply@anthropic.com> * fix(doctor): keep a native ZCode disable on the setup repair path The manual-conflict remediation matched a native zcode row through sharedEnableFlagBinds, but setup drops enable:false on single-reader storage, so a native disabled registration was told setup would not change it. The remediation now reads setup's own writer rule, exported as setupPreservesDisabledActivation: shared storage and the zcode fallback view keep the manual-conflict message, while a native row keeps the working rea setup instruction. Regressions pin both remediations. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * revert: drop the unrelated managed-CIL describe split The split does not belong to FreeBuff client support and only widens this PR's diff. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(uninstall): remove the shared agent registration once for both clients ZCode's ~/.agents/mcp.json fallback and FreeBuff's own configuration queued the same physical file twice, so ZCode removed the row and FreeBuff then reported it absent, losing the outcome for the client that owns the file. Resolve every configuration path to one owner -- the client whose configuration it is -- and project that single inspection and removal to each client that loads it. A fallback reader still contributes its discovery probe, so an installed or unresolved reader keeps the shared file fail-closed. Discovery also accepted a dangling ~/.config/freebuff-desktop link, which setup and doctor reject because they follow the target, so an unrelated malformed shared file blocked every other removal. Resolve the link before treating the profile as installation evidence, keeping lstat for removal safety. Also date the FreeBuff release wording against the published 6.7.0 package. * fix(uninstall): keep a row per client when two clients own one file OPENCODE_CONFIG can point OpenCode at FreeBuff's configuration file, where OpenCode writes `mcp.rea` and FreeBuff writes `mcpServers.rea`. Grouping by filename gave that path a single owner, so uninstall removed only OpenCode's row, reported that removal for FreeBuff, and could purge managed data with FreeBuff's registration still active. Share the decision only through a fallback view of another client's row: every client that declares the path as its own configuration keeps its own inspection and removal, and the fallback reader reports each of them. * fix(clients): preserve discovery and fallback registration identity Follow product discovery link targets without losing configuration entry presence or explicitly selected inspections. Associate fallback readers with the format owner's registration table and project outcomes by owner and path, so OpenCode removal cannot be reported as a ZCode fallback removal. All three new boundary regressions fail against the previous source and pass with the fixes. Validation: check:fast; 217 focused tests passed; the two changed test files were rerun with 50 passing tests. No installed FreeBuff client was launched. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Codebuff <noreply@codebuff.com>
1590 lines
56 KiB
TypeScript
1590 lines
56 KiB
TypeScript
import { createReadStream } from "node:fs";
|
|
import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
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,
|
|
snapshotSection,
|
|
} from "../../../src/flutter/FlutterBuild.fixture.js";
|
|
import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js";
|
|
import { FlutterBuildAnalysisService } from "../../../src/application/flutter/FlutterBuildAnalysisService.js";
|
|
import { finishAnalysisCommand } from "../../../src/cli/finishAnalysisCommand.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.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([false, true])(
|
|
"qualifies unified snapshot exports without aliasing legacy sources (text=%s)",
|
|
async (includeText) => {
|
|
const symbols = [
|
|
{ name: "_kDartSnapshotData", value: 0x1000, size: 52 },
|
|
...(includeText
|
|
? [{ name: "_kDartSnapshotText", value: 0x1100, size: 16 }]
|
|
: []),
|
|
];
|
|
const image = Buffer.alloc(0x1110);
|
|
buildElf64Image(symbols).copy(image);
|
|
const data = snapshotSection(REFERENCE_HASH);
|
|
data.writeBigUInt64LE(2n, 12); // Pinned unified producer enum; support is not inferred.
|
|
data.copy(image, 0x1000);
|
|
const path = await buildApk("unified.apk", [
|
|
{ path: "lib/arm64-v8a/libapp.so", bytes: image },
|
|
]);
|
|
const provider = new FlutterBuildProvider();
|
|
onTestFinished(() => provider.close());
|
|
const inspected = await provider.execute({
|
|
operation: "inspect_dart_aot",
|
|
input: { path },
|
|
});
|
|
expect(inspected.ok).toBe(false);
|
|
if (inspected.ok)
|
|
throw new Error("Unified profile was admitted as split VM/isolate");
|
|
expect(projectAnalysisError(inspected.error)).toMatchObject({
|
|
code: "unsupported_target",
|
|
category: "unsupported_target",
|
|
details: {
|
|
diagnostics: {
|
|
supported_profile: "dart-2.14-split-full-aot",
|
|
observed_exports: symbols.map(({ name, value, size }) => ({
|
|
name,
|
|
offset: value,
|
|
size,
|
|
})),
|
|
abi: "arm64-v8a",
|
|
libapp_bytes: image.length,
|
|
libapp_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
|
},
|
|
},
|
|
});
|
|
const identified = await provider.execute({
|
|
operation: "identify_flutter_build",
|
|
input: { path },
|
|
});
|
|
if (!identified.ok) throw identified.error;
|
|
const value = flutterResultSchemas.identify_flutter_build.parse(
|
|
identified.value.result,
|
|
);
|
|
expect(value.coverage).toBe("partial");
|
|
expect(value.abis[0]?.libapp).toMatchObject({
|
|
snapshot_hash: REFERENCE_HASH,
|
|
snapshot_hash_sources: 1,
|
|
});
|
|
expect(identified.value.limitations.join(" ")).toContain(
|
|
"supported legacy split VM/isolate profile",
|
|
);
|
|
expect(identified.value.limitations.join(" ")).not.toContain(
|
|
"library is truncated",
|
|
);
|
|
},
|
|
);
|
|
|
|
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(result.error.cause).toBe(cause);
|
|
// Only storage exhaustion is a resource constraint; other owned-storage
|
|
// failures keep their system code instead of claiming a size allowance.
|
|
// These causes carry no errno path, so the failure is located by the owned
|
|
// workspace once allocated; before allocation no workspace exists.
|
|
const workspace =
|
|
stage === "root"
|
|
? null
|
|
: (expect.stringMatching(/\/rea-flutter-[^/]+$/u) as unknown);
|
|
const location = { workspace_path: workspace, path: workspace };
|
|
expect(projectAnalysisError(result.error)).toMatchObject(
|
|
code === "ENOSPC"
|
|
? {
|
|
code: "resource_constraint",
|
|
details: { reported_limits: { ...location, system_code: code } },
|
|
}
|
|
: {
|
|
code: "execution_failure",
|
|
details: {
|
|
provider_id: "flutter",
|
|
diagnostics: {
|
|
stage: "workspace",
|
|
...location,
|
|
system_code: code,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
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.each(["identify_flutter_build", "inspect_dart_aot"] as const)(
|
|
"%s keeps completed Evidence when cancelled during successful snapshot removal",
|
|
async (operation) => {
|
|
const path = await buildApk("removal-cancellation.apk", [
|
|
{ path: "lib/arm64-v8a/libapp.so", bytes: aotImage() },
|
|
{ path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() },
|
|
]);
|
|
const controlProvider = new FlutterBuildProvider();
|
|
onTestFinished(() => controlProvider.close());
|
|
const control = await new FlutterBuildAnalysisService(
|
|
controlProvider,
|
|
).execute(operation, { path });
|
|
if (!control.ok) throw control.error;
|
|
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller interrupted snapshot removal");
|
|
const removedRoots: string[] = [];
|
|
const provider = new FlutterBuildProvider({
|
|
removeRoot: async (root) => {
|
|
await rm(root, { recursive: true, force: true });
|
|
removedRoots.push(root);
|
|
controller.abort(reason);
|
|
},
|
|
});
|
|
const result = await new FlutterBuildAnalysisService(provider).execute(
|
|
operation,
|
|
{ path },
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("cancellation must prevent success");
|
|
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(result.error.partialObservation).toEqual(control.value);
|
|
expect(removedRoots).toHaveLength(1);
|
|
await expect(stat(removedRoots[0]!)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
await provider.close();
|
|
expect(removedRoots).toHaveLength(1);
|
|
},
|
|
);
|
|
|
|
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 roots: string[] = [];
|
|
let failing = true;
|
|
const provider = new FlutterBuildProvider({
|
|
removeRoot: async (path) => {
|
|
roots.push(path);
|
|
if (failing) throw new Error("cleanup refused");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
onTestFinished(async () => {
|
|
failing = false;
|
|
await provider.close();
|
|
});
|
|
const service = new FlutterBuildAnalysisService(provider);
|
|
const result = await finishAnalysisCommand(
|
|
() =>
|
|
service.execute("identify_flutter_build", {
|
|
path: apk,
|
|
}),
|
|
{ close: () => provider.close() },
|
|
"flutter",
|
|
"identify_flutter_build",
|
|
new AbortController().signal,
|
|
);
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: [expect.stringContaining("rea-flutter-")],
|
|
partial_observation: {
|
|
operation: "identify_flutter_build",
|
|
subject: { local_path: apk },
|
|
normalized_result: { flutter_detected: true },
|
|
},
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
details: { resources: [roots[0]] },
|
|
});
|
|
expect(roots).toHaveLength(2);
|
|
expect(roots[1]).toBe(roots[0]);
|
|
failing = false;
|
|
await provider.close();
|
|
expect(roots).toHaveLength(3);
|
|
expect(roots[2]).toBe(roots[0]);
|
|
});
|
|
|
|
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);
|
|
const projected = projectAnalysisError(result.error);
|
|
expect(projected.code).toBe("resource_constraint");
|
|
expect(projected.retryable).toBe(false);
|
|
expect(projected.remediation.action).toContain("APK snapshot allowance");
|
|
expect(projected.remediation.action).not.toMatch(/evidence reply/u);
|
|
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();
|
|
let cleanupFailure: unknown;
|
|
try {
|
|
await provider.close();
|
|
} catch (cause) {
|
|
cleanupFailure = cause;
|
|
}
|
|
expect(cleanupFailure).toBeInstanceOf(ProviderCleanupError);
|
|
expect(cleanupFailure).toMatchObject({
|
|
operation: "close_flutter_build",
|
|
cleanupResources: [expect.stringContaining("rea-flutter-")],
|
|
diagnostics: {
|
|
reason: expect.stringContaining("file handle close refused"),
|
|
retained_roots: [
|
|
{
|
|
path: expect.stringContaining("rea-flutter-"),
|
|
failures: [
|
|
{ message: expect.stringContaining("file handle close refused") },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
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.toBeInstanceOf(ProviderCleanupError);
|
|
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",
|
|
details: {
|
|
issues: [
|
|
{
|
|
message: expect.stringContaining(
|
|
`duplicate Flutter library members: ${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");
|
|
},
|
|
);
|
|
|
|
const expectSelectionIssue = async (
|
|
provider: FlutterBuildProvider,
|
|
path: string,
|
|
message: string,
|
|
): Promise<void> => {
|
|
for (const operation of [
|
|
"identify_flutter_build",
|
|
"inspect_dart_aot",
|
|
] as const) {
|
|
const result = await provider.execute({ operation, input: { path } });
|
|
if (result.ok) throw new Error("A non-file APK selection must fail");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_format",
|
|
message: `${message}: ${path}`,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
it("names a missing or non-file APK selection instead of blaming package bytes", async () => {
|
|
const root = await createTestTempDirectory("rea-flutter-selection-");
|
|
const provider = new FlutterBuildProvider();
|
|
onTestFinished(() => provider.close());
|
|
await expectSelectionIssue(
|
|
provider,
|
|
join(root, "absent.apk"),
|
|
"Selected APK does not exist",
|
|
);
|
|
await expectSelectionIssue(
|
|
provider,
|
|
root,
|
|
"Selected APK is not a regular file",
|
|
);
|
|
});
|
|
|
|
// Windows listens on named pipes rather than filesystem sockets.
|
|
it.skipIf(process.platform === "win32")(
|
|
"names an unopenable Unix socket APK selection as a non-file",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-flutter-socket-");
|
|
const socketPath = join(root, "s.sock");
|
|
const socket = createServer();
|
|
await new Promise<void>((resolve) => socket.listen(socketPath, resolve));
|
|
onTestFinished(
|
|
() => new Promise<void>((resolve) => socket.close(() => resolve())),
|
|
);
|
|
const provider = new FlutterBuildProvider();
|
|
onTestFinished(() => provider.close());
|
|
await expectSelectionIssue(
|
|
provider,
|
|
socketPath,
|
|
"Selected APK is not a regular file",
|
|
);
|
|
},
|
|
);
|
|
|
|
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 descriptor = await original(...args);
|
|
const { handle } = descriptor;
|
|
if (stage === "read")
|
|
vi.spyOn(handle, "read").mockRejectedValueOnce(cause);
|
|
return descriptor;
|
|
});
|
|
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 descriptor = await nativeAdmission(...args);
|
|
const { handle } = descriptor;
|
|
// Rewrite after a real descriptor read. The retained admission snapshot
|
|
// remains the baseline for final verification, including ctime.
|
|
const nativeRead = handle.read.bind(handle);
|
|
let rewritten = false;
|
|
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
|
|
const result = await nativeRead(...readArgs);
|
|
if (!rewritten) {
|
|
rewritten = true;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
await writeFile(path, content);
|
|
await utimes(path, before.atime, before.mtime);
|
|
}
|
|
return result;
|
|
});
|
|
return descriptor;
|
|
});
|
|
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 descriptor = await nativeAdmission(...args);
|
|
const { handle } = descriptor;
|
|
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 descriptor;
|
|
});
|
|
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);
|
|
});
|