From f3c4a332f7bd198bd67f0187bc2a85c7b31bb960 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:16:04 +0800 Subject: [PATCH] fix(targets): cancel resolution before provider admission --- scripts/lib/ghidra-reference-snapshot-e2e.mjs | 9 +- src/application/BinaryTargetResolver.ts | 75 ++++++++-- src/application/DirectAnalysis.ts | 15 +- src/application/Doctor.ts | 23 +++- .../android/AndroidAnalysisService.ts | 4 +- src/application/binary/BinarySessionOpen.ts | 16 ++- .../boundary/filesystem/binaryTarget.test.ts | 128 ++++++++++++++---- .../boundary/filesystem/dosComTarget.test.ts | 25 ++-- .../boundary/process/jadxIntegration.test.ts | 26 +++- 9 files changed, 244 insertions(+), 77 deletions(-) diff --git a/scripts/lib/ghidra-reference-snapshot-e2e.mjs b/scripts/lib/ghidra-reference-snapshot-e2e.mjs index b0ce2318f..7a99099b6 100644 --- a/scripts/lib/ghidra-reference-snapshot-e2e.mjs +++ b/scripts/lib/ghidra-reference-snapshot-e2e.mjs @@ -51,11 +51,10 @@ export async function verifyLegacyGhidraReferenceSnapshot( legacyParameters, ); assert.notEqual(legacyProfile.digest, observed.analysis_profile.digest); - const parsed = await parseBinaryTarget( - target.path, - process.cwd(), - process.arch, - ); + const parsed = await parseBinaryTarget(target.path, { + cwd: process.cwd(), + hostArchitecture: process.arch, + }); if (!parsed.ok) throw parsed.error; const nativeTarget = parsed.value; const result = structuredClone(observed.normalized_result); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 24fb5358f..f45a1d9a6 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -10,6 +10,7 @@ import { import { extname, isAbsolute, resolve } from "node:path"; import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -32,6 +33,15 @@ import { type ExecutableMetadata, } from "../domain/binaryTarget.js"; +/** Options for resolving a local binary target and its stable identity. */ +export interface BinaryTargetResolverOptions { + readonly cwd?: string; + readonly hostArchitecture?: NodeJS.Architecture; + readonly targetKind?: BinaryTarget["kind"]; + readonly formatHint?: ExecutableFormatHint; + readonly signal?: AbortSignal; +} + /** * Resolve and classify a readable local target before a provider is selected. * FAT Mach-O inputs select only a host-compatible architecture so setup remains @@ -39,24 +49,32 @@ import { */ export const parseBinaryTarget = async ( input: string, - cwd = process.cwd(), - hostArchitecture: NodeJS.Architecture = process.arch, - targetKind?: BinaryTarget["kind"], - formatHint?: ExecutableFormatHint, -): Promise> => { + options: BinaryTargetResolverOptions = {}, +): Promise< + Result +> => { + const cwd = options.cwd ?? process.cwd(); + const hostArchitecture = options.hostArchitecture ?? process.arch; + const { targetKind, formatHint, signal } = options; const candidate = isAbsolute(input) ? input : resolve(cwd, input); try { + throwIfTargetResolutionCancelled(signal); await access(candidate, constants.R_OK); + throwIfTargetResolutionCancelled(signal); const canonical = await realpath(candidate); + throwIfTargetResolutionCancelled(signal); const resolved = await resolveAppBundle(canonical); + throwIfTargetResolutionCancelled(signal); if (!resolved.ok) return err(resolved.error); const { executable: path, infoPlist } = resolved.value; const bundle = infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; const handle = await open(path, "r"); try { + throwIfTargetResolutionCancelled(signal); if (!(await handle.stat()).isFile()) return err(new BinaryTargetError(path, "target is not a regular file")); + throwIfTargetResolutionCancelled(signal); if (formatHint === "dos-com") { if (targetKind !== undefined && targetKind !== "executable") return err( @@ -70,7 +88,7 @@ export const parseBinaryTarget = async ( return ok({ path, sourcePath: canonical, - sha256: await sha256Handle(handle), + sha256: await sha256Handle(handle, signal), kind: "executable", format: "dos-com", architecture: "x86", @@ -84,28 +102,32 @@ export const parseBinaryTarget = async ( return ok({ path, sourcePath: process.platform === "win32" ? candidate : canonical, - sha256: await sha256Handle(handle), + sha256: await sha256Handle(handle, signal), kind: "database", format: "analysis-database", }); - const artifactFormat = await detectArtifactFormat(path, handle); + const artifactFormat = await detectArtifactFormat(path, handle, signal); if (artifactFormat !== undefined) { const identity = { path, sourcePath: process.platform === "win32" ? candidate : canonical, - sha256: await sha256Handle(handle), + sha256: await sha256Handle(handle, signal), }; return isArchiveFormat(artifactFormat) ? ok({ ...identity, kind: "archive", format: artifactFormat }) : ok({ ...identity, kind: "artifact", format: artifactFormat }); } - const detected = await readExecutableMetadata(handle, hostArchitecture); + const detected = await readExecutableMetadata( + handle, + hostArchitecture, + signal, + ); if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); return ok({ path, sourcePath: process.platform === "win32" ? candidate : canonical, ...bundle, - sha256: await sha256Handle(handle), + sha256: await sha256Handle(handle, signal), kind: "executable", ...detected.value, }); @@ -113,6 +135,7 @@ export const parseBinaryTarget = async ( await handle.close(); } } catch (cause: unknown) { + if (cause instanceof AnalysisCancelledError) return err(cause); if (!(cause instanceof Error)) throw cause; const code = filesystemErrorCode(cause); if (code === undefined) throw cause; @@ -146,9 +169,15 @@ const targetFailureReason = (code: string, detail: string): string => { } }; +const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("resolve_binary_target"); +}; + const detectArtifactFormat = async ( path: string, handle: FileHandle, + signal?: AbortSignal, ): Promise< | Exclude< BinaryTarget["format"], @@ -156,9 +185,11 @@ const detectArtifactFormat = async ( > | undefined > => { + throwIfTargetResolutionCancelled(signal); const lower = path.toLowerCase(); const magic = Buffer.alloc(8); const observed = await handle.read(magic, 0, magic.length, 0); + throwIfTargetResolutionCancelled(signal); if (hasZipSignature(magic.subarray(0, observed.bytesRead))) { return zipPackageFormatForPath(lower) ?? "zip"; } @@ -171,10 +202,13 @@ const detectArtifactFormat = async ( ) return "pkg"; if (lower.endsWith(".dmg")) { + throwIfTargetResolutionCancelled(signal); const size = (await handle.stat()).size; + throwIfTargetResolutionCancelled(signal); if (size >= 512) { const trailer = Buffer.alloc(4); const read = await handle.read(trailer, 0, trailer.length, size - 512); + throwIfTargetResolutionCancelled(signal); if (read.bytesRead === 4 && trailer.toString("ascii") === "koly") return "dmg"; } @@ -199,12 +233,17 @@ const isArchiveFormat = ( ): format is Extract["format"] => ["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format); -const sha256Handle = async (handle: FileHandle): Promise => { +const sha256Handle = async ( + handle: FileHandle, + signal?: AbortSignal, +): Promise => { const hash = createHash("sha256"); const chunk = Buffer.allocUnsafe(64 * 1024); let position = 0; while (true) { + throwIfTargetResolutionCancelled(signal); const observed = await handle.read(chunk, 0, chunk.length, position); + throwIfTargetResolutionCancelled(signal); if (observed.bytesRead === 0) break; hash.update(chunk.subarray(0, observed.bytesRead)); position += observed.bytesRead; @@ -233,19 +272,23 @@ const resolveAppBundle = async ( const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, + signal?: AbortSignal, ): Promise> => { + throwIfTargetResolutionCancelled(signal); const prefix = Buffer.alloc(4096); const prefixRead = await handle.read(prefix, 0, prefix.length, 0); + throwIfTargetResolutionCancelled(signal); const bytes = prefix.subarray(0, prefixRead.bytesRead); // Header commitments such as Mach-O load commands and FAT slices are // checked against the whole file, not the probed prefix. const fileSize = (await handle.stat()).size; + throwIfTargetResolutionCancelled(signal); if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) { const offset = mzWindowsHeaderOffset(bytes); if (offset !== null) { if (offset < 64) return err("invalid Windows new-header offset in MZ image"); - return readPeMetadata(handle, offset); + return readPeMetadata(handle, offset, signal); } // MZ relocation records may extend past the initial probe; their format // bounds are at most 65535 records inside a 65535-paragraph header. @@ -261,6 +304,7 @@ const readExecutableMetadata = async ( ) { const table = Buffer.alloc(tableEnd); const observed = await handle.read(table, 0, table.length, 0); + throwIfTargetResolutionCancelled(signal); return parseExecutableHeader( table.subarray(0, observed.bytesRead), hostArchitecture, @@ -278,6 +322,7 @@ const readExecutableMetadata = async ( if (count <= 128 && required > bytes.length) { const header = Buffer.alloc(required); const headerRead = await handle.read(header, 0, header.length, 0); + throwIfTargetResolutionCancelled(signal); return parseExecutableHeader( header.subarray(0, headerRead.bytesRead), hostArchitecture, @@ -292,7 +337,9 @@ const readExecutableMetadata = async ( const readPeMetadata = async ( handle: FileHandle, offset: number, + signal?: AbortSignal, ): Promise> => { + throwIfTargetResolutionCancelled(signal); const fileHeader = Buffer.alloc(24); const fileHeaderRead = await handle.read( fileHeader, @@ -300,6 +347,7 @@ const readPeMetadata = async ( fileHeader.length, offset, ); + throwIfTargetResolutionCancelled(signal); if (fileHeaderRead.bytesRead !== fileHeader.length) return err("invalid or truncated PE header"); const signature = fileHeader.toString("ascii", 0, 2); @@ -309,6 +357,7 @@ const readPeMetadata = async ( if (optionalHeaderSize > 4096) return err("invalid PE optional header size"); const record = Buffer.alloc(24 + optionalHeaderSize); const recordRead = await handle.read(record, 0, record.length, offset); + throwIfTargetResolutionCancelled(signal); return recordRead.bytesRead === record.length ? parsePeRecord(record) : err("invalid or truncated PE header"); diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index f23cee936..b51c88656 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -201,6 +201,7 @@ const runAnalysis = async ( const prepared = await prepareSnapshot({ path, snapshotPath, + signal, ...(options.formatHint === undefined ? {} : { formatHint: options.formatHint }), @@ -434,6 +435,7 @@ const prepareSnapshot = async (options: { readonly path: string; readonly formatHint?: ExecutableFormatHint; readonly snapshotPath: string | undefined; + readonly signal: AbortSignal; }): Promise< Result< { readonly snapshot?: AnalysisSnapshot; readonly target?: BinaryTarget }, @@ -445,13 +447,12 @@ const prepareSnapshot = async (options: { return ok({}); const loaded = await readAnalysisSnapshot(snapshotPath); if (!loaded.ok) return loaded; - const target = await parseBinaryTarget( - path, - process.cwd(), - process.arch, - undefined, - options.formatHint, - ); + const target = await parseBinaryTarget(path, { + signal: options.signal, + ...(options.formatHint === undefined + ? {} + : { formatHint: options.formatHint }), + }); if (!target.ok) return target; if (!snapshotMatchesTarget(loaded.value.target, target.value)) return err( diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 95ecfbfd2..5426a6453 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -4,6 +4,7 @@ import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { join } from "node:path"; import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js"; +import { BinaryTargetError } from "../domain/configurationErrors.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -324,10 +325,18 @@ export const systemDoctorHost = ( readMacosVersion(hostExecFileOutput, commandEnvironment), linuxDistribution: readLinuxDistribution, async validTarget(path) { - return (await parseBinaryTarget(path, process.cwd(), architecture)).ok; + return ( + await parseBinaryTarget(path, { + cwd: process.cwd(), + hostArchitecture: architecture, + }) + ).ok; }, async inspectTarget(path) { - const result = await parseBinaryTarget(path, process.cwd(), architecture); + const result = await parseBinaryTarget(path, { + cwd: process.cwd(), + hostArchitecture: architecture, + }); if (result.ok) return { name: "target", @@ -336,19 +345,21 @@ export const systemDoctorHost = ( detail: path, }; const error = result.error; + const targetError = + error instanceof BinaryTargetError ? error : undefined; return { name: "target", ok: false, classification: - error.constraint === "directory_requires_file" + targetError?.constraint === "directory_requires_file" ? "unsupported_target" : "config_drift", detail: path, details: { - reason: error.reason, - ...(error.constraint === undefined + reason: targetError?.reason ?? error.message, + ...(targetError?.constraint === undefined ? {} - : { constraint: error.constraint }), + : { constraint: targetError.constraint }), }, remediation: analysisErrorRemediationAction(error), }; diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index fe8c8f650..a126df57c 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -29,7 +29,9 @@ export class AndroidAnalysisService { const request = androidRequestSchema.safeParse({ operation, input }); if (!request.success) return err(new AnalysisInputError(operation, { cause: request.error })); - const target = await parseBinaryTarget(request.data.input.path); + const target = await parseBinaryTarget(request.data.input.path, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); if (!target.ok) return target; const executed = await this.provider.execute( target.value, diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index cde06c29d..1a7f635bc 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -65,13 +65,15 @@ export const resolveSessionOpen = async ( input: ResolveSessionOpenInput, ): Promise> => { const { path, options } = input; - const parsed = await parseBinaryTarget( - path, - process.cwd(), - process.arch, - options.targetKind, - options.formatHint, - ); + const parsed = await parseBinaryTarget(path, { + ...(options.targetKind === undefined + ? {} + : { targetKind: options.targetKind }), + ...(options.formatHint === undefined + ? {} + : { formatHint: options.formatHint }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); if (!parsed.ok) return parsed; return resolveSessionTarget({ ...input, target: parsed.value }); }; diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 6505c42f2..69ff26fab 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -226,12 +226,16 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => const directory = await createTestTempDirectory("rea-target-"); await writeFile(join(directory, "sample.hop"), "database"); await writeFile(join(directory, "text"), "hello"); - const database = await parseBinaryTarget("sample.hop", directory); + const database = await parseBinaryTarget("sample.hop", { cwd: directory }); expect(database.ok && database.value.sha256).toBe( "3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8", ); - expect((await parseBinaryTarget("text", directory)).ok).toBe(false); - expect((await parseBinaryTarget("missing", directory)).ok).toBe(false); + expect((await parseBinaryTarget("text", { cwd: directory })).ok).toBe( + false, + ); + expect((await parseBinaryTarget("missing", { cwd: directory })).ok).toBe( + false, + ); }); it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( @@ -242,7 +246,10 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => await writeFile(path, thinMach(0xcffaedfe, 0x0100000c)); await chmod(path, 0); try { - const result = await parseBinaryTarget(path, directory, "arm64"); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error).toMatchObject({ path, systemCode: "EACCES" }); @@ -275,7 +282,10 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => join(programs, "Example & Tool"), thinMach(0xfeedfacf, 0x0100000c), ); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(programs, "Example & Tool")), format: "mach-o", @@ -300,7 +310,14 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => await mkdir(join(contents, "MacOS"), { recursive: true }); if (plist !== undefined) await writeFile(join(contents, "Info.plist"), plist); - expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + expect( + ( + await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }) + ).ok, + ).toBe(false); }); it.skipIf(process.platform === "win32")( @@ -318,7 +335,14 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => "CFBundleExecutableEscaping", ); await symlink(outside, join(programs, "Escaping")); - expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + expect( + ( + await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }) + ).ok, + ).toBe(false); }, ); }); @@ -327,12 +351,11 @@ describe("binary target I/O: explicit kinds and executable headers", () => { it("honors an explicit database kind without relying on the file suffix", async () => { const directory = await createTestTempDirectory("rea-target-"); await writeFile(join(directory, "saved-analysis"), "database"); - const result = await parseBinaryTarget( - "saved-analysis", - directory, - "arm64", - "database", - ); + const result = await parseBinaryTarget("saved-analysis", { + cwd: directory, + hostArchitecture: "arm64", + targetKind: "database", + }); expect(result.ok && result.value).toMatchObject({ kind: "database", format: "analysis-database", @@ -343,7 +366,10 @@ describe("binary target I/O: explicit kinds and executable headers", () => { const directory = await createTestTempDirectory("rea-target-"); const path = join(directory, "delayed.exe"); await writeFile(path, pe(0x8664, 8192)); - const result = await parseBinaryTarget(path, directory, "x64"); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + }); expect(result.ok && result.value).toMatchObject({ format: "pe", architecture: "x86_64", @@ -354,7 +380,10 @@ describe("binary target I/O: explicit kinds and executable headers", () => { const directory = await createTestTempDirectory("rea-target-"); const path = join(directory, "truncated"); await writeFile(path, thinMach(0xcffaedfe, 0x0100000c).subarray(0, 12)); - const result = await parseBinaryTarget(path, directory, "arm64"); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok).toBe(false); expect(result.ok ? undefined : result.error).toMatchObject({ path, @@ -369,7 +398,10 @@ describe("binary target I/O: explicit kinds and executable headers", () => { const header = thinMach(0xcffaedfe, 0x0100000c); header.writeUInt32LE(8192, 20); await writeFile(path, Buffer.concat([header, Buffer.alloc(8192)])); - const result = await parseBinaryTarget(path, directory, "arm64"); + const result = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ format: "mach-o", architecture: "arm64", @@ -391,7 +423,10 @@ describe("app executable filename fidelity", () => { `CFBundleExecutable${name}`, ); await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(executable), format: "mach-o", @@ -416,7 +451,10 @@ describe("app executable filename fidelity", () => { `CFBundleExecutable${encoded}`, ); await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(executable), format: "mach-o", @@ -449,7 +487,10 @@ describe("app executable filename fidelity", () => { join(contents, "MacOS", "Inner"), thinMach(0xfeedfacf, 0x0100000c), ); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(executable), }); @@ -469,7 +510,10 @@ describe("app executable filename fidelity", () => { `${entry}CFBundleExecutableApp`, ); await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(executable), }); @@ -495,7 +539,10 @@ describe("app executable filename fidelity", () => { plist, ]); await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(executable), format: "mach-o", @@ -515,7 +562,10 @@ describe("iOS-style app bundle targets", () => { await mkdir(app); await writeFile(join(app, "Info.plist"), plist("Flat")); await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c)); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(app, "Flat")), bundleInfoPlist: join(await realpath(app), "Info.plist"), @@ -535,7 +585,10 @@ describe("iOS-style app bundle targets", () => { thinMach(0xfeedfacf, 0x0100000c), ); await symlink("bin/real", join(app, "Linked")); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(app, "bin", "real")), bundleInfoPlist: join(await realpath(app), "Info.plist"), @@ -556,7 +609,10 @@ describe("iOS-style app bundle targets", () => { join(contents, "MacOS", "Linked"), thinMach(0xfeedfacf, 0x0100000c), ); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(contents, "MacOS", "Linked")), }); @@ -572,7 +628,10 @@ describe("iOS-style app bundle targets", () => { await mkdir(join(wrapper, "Locked.app"), { recursive: true }); await chmod(wrapper, 0o000); try { - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); if (result.ok) throw new Error("Expected a permission denial"); expect(result.error.message).toContain("permission denied"); } finally { @@ -591,14 +650,24 @@ describe("iOS-style app bundle targets", () => { await writeFile(join(wrapped, "Info.plist"), plist("Phone")); await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c)); await symlink("Wrapper/Phone.app", join(app, "WrappedBundle")); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(wrapped, "Phone")), format: "mach-o", }); await mkdir(join(app, "Wrapper", "Second.app")); - expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + expect( + ( + await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }) + ).ok, + ).toBe(false); }, ); @@ -612,7 +681,10 @@ describe("iOS-style app bundle targets", () => { await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c)); await writeFile(join(app, "Info.plist"), plist("Escaping")); await symlink(outside, join(app, "Escaping")); - const result = await parseBinaryTarget(app, directory, "arm64"); + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); if (result.ok) throw new Error("Expected an escaping program file"); expect(result.error.message).toContain("leaves the bundle root"); }, diff --git a/tests/boundary/filesystem/dosComTarget.test.ts b/tests/boundary/filesystem/dosComTarget.test.ts index ce5738b3d..7bd3eae7b 100644 --- a/tests/boundary/filesystem/dosComTarget.test.ts +++ b/tests/boundary/filesystem/dosComTarget.test.ts @@ -11,13 +11,11 @@ describe("DOS COM admission", () => { const path = join(directory, "fixture.js"); const bytes = Buffer.from("b83412c3", "hex"); await writeFile(path, bytes); - const target = await parseBinaryTarget( - path, - directory, - "arm64", - undefined, - "dos-com", - ); + const target = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "arm64", + formatHint: "dos-com", + }); expect(target).toMatchObject({ ok: true, value: { @@ -38,7 +36,11 @@ describe("DOS COM admission", () => { const path = join(directory, "fixture.com"); await writeFile(path, Buffer.alloc(length)); expect( - await parseBinaryTarget(path, directory, "x64", undefined, "dos-com"), + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + formatHint: "dos-com", + }), ).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } }); }); it("rejects contradictory target kind", async () => { @@ -46,7 +48,12 @@ describe("DOS COM admission", () => { const path = join(directory, "fixture.com"); await writeFile(path, Buffer.from("c3", "hex")); expect( - await parseBinaryTarget(path, directory, "x64", "database", "dos-com"), + await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + targetKind: "database", + formatHint: "dos-com", + }), ).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } }); }); }); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 6c90e1728..5d79199bb 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1,5 +1,5 @@ import { expect, it as test, vi } from "vitest"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, readFile, truncate, writeFile } from "node:fs/promises"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { JadxProvider } from "../../../src/android/JadxProvider.js"; @@ -17,6 +17,30 @@ import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; const it = test.skipIf(process.platform === "win32"); +it("cancels Android admission while hashing the selected APK", async () => { + const { service, apk, launches, provider } = await setup(); + await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + await truncate(apk, 1024 * 1024 * 1024); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 100); + try { + const result = await service.execute( + "inspect_android_package", + { path: apk }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(launches).toHaveLength(0); + } finally { + clearTimeout(timer); + await provider.close(); + await verifyCleanup(launches); + } +}); + it("rejects a copied JAR that differs from its admitted session identity", async () => { const { jar } = await setup(); const admitted = await hashAndroidFile(jar);