diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index fb2ce9aac7..e5a95c5365 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -148,6 +148,11 @@ existing analysis families directly, for example - Cancellation during a package pull preserves caller-selected output. The error includes partial observations identifying completed artifacts and the interrupted local path; remaining APKs were not attempted. +- Bugreports collect into isolated staging and publish with exclusive creation; + filename collisions select the next numeric suffix. If publication fails, the + completed archive remains in staging. The error's partial observation gives + its recoverable `local_path`, the original `adb_reported_path`, and an unknown + digest status. Later operations and shutdown preserve this retained evidence. - Failed package acquisition rollback retains its directory identity and retries cleanup before later work and during shutdown. Cleanup refuses a replacement directory. Terminal shutdown stops admission and drains active operations. diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 4d6c13001e..5f36917e4a 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -1086,10 +1086,10 @@ it("rejects mistyped intent extras before touching the device", async () => { it("collects a device bugreport archive with its digest", async () => { const { binary, root } = await writeAdbStub( - (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then - printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}" - printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n' + printf 'bugreport-bytes' > "$4/bugreport test.zip" + printf 'Bug report copied to %s/bugreport test.zip\\n' "$4" exit 0 fi exit 1`, @@ -1103,7 +1103,7 @@ exit 1`, local_path: join(root, "bugreport test.zip"), bytes: 15, sha256: digest, - adb_reported_path: join(root, "bugreport test.zip"), + adb_reported_path: expect.stringContaining(".rea-bugreport-"), }); }); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 2a64930a56..bb2f49ce03 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2,14 +2,16 @@ import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { access, + copyFile, lstat, + mkdtemp, mkdir, rmdir, rm, stat, writeFile, } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, dirname, extname, join, resolve } from "node:path"; import type { ExecutionOptions, @@ -20,7 +22,10 @@ import { createAnalysisExecution, type AnalysisExecution, } from "../application/AnalysisProvider.js"; -import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; +import type { + AdbAcquiredFilePartialObservation, + AdbRequest, +} from "../domain/adb/adbDeviceAnalysis.js"; import { AnalysisArtifactChangedError, AnalysisCancelledError, @@ -305,30 +310,56 @@ const refuseExistingTarget = async ( return undefined; }; +/** Publish an ADB-produced archive without replacing an existing caller file. */ +const publishBugreport = async ( + source: string, + directory: string, +): Promise => { + const fileName = basename(source); + const extension = extname(fileName); + const stem = + extension === "" ? fileName : fileName.slice(0, -extension.length); + for (let suffix = 0; ; suffix += 1) { + const destination = join( + directory, + suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`, + ); + try { + await copyFile(source, destination, fsConstants.COPYFILE_EXCL); + return destination; + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "EEXIST") + continue; + throw cause; + } + } +}; + export interface AdbProviderOptions { readonly environment?: Readonly>; } -interface PendingPackageDirectoryCleanup { +interface PendingOutputDirectoryCleanup { + readonly operation: "pull_adb_package" | "collect_adb_bugreport"; readonly path: string; - readonly identity: PackageDirectoryIdentity | undefined; + readonly identity: OutputDirectoryIdentity | undefined; readonly primary: AnalysisError; readonly remove: () => Promise; } -interface PackageDirectoryIdentity { +interface OutputDirectoryIdentity { readonly dev: number; readonly ino: number; readonly mode: number; } -const removeOwnedPackageDirectory = async ( +const removeOwnedOutputDirectory = async ( path: string, - identity: PackageDirectoryIdentity | undefined, + identity: OutputDirectoryIdentity | undefined, ): Promise => { if (identity === undefined) throw new Error( - `Cannot verify the created package directory identity; refusing to remove ${path}`, + `Cannot verify the created output directory identity; refusing to remove ${path}`, ); let current: Awaited>; try { @@ -346,7 +377,7 @@ const removeOwnedPackageDirectory = async ( current.mode !== identity.mode ) throw new Error( - `The created package directory was replaced or changed; refusing to remove ${path}`, + `The created output directory was replaced or changed; refusing to remove ${path}`, ); await rm(path, { recursive: true, force: true }); }; @@ -363,7 +394,7 @@ const reservePackageDestinations = async ( ): Promise => { const reservations: { readonly path: string; - readonly identity: PackageDirectoryIdentity; + readonly identity: OutputDirectoryIdentity; }[] = []; for (const entry of entries) { const path = join(packageDirectory, entry.file_name); @@ -424,12 +455,12 @@ const reservePackageDestinations = async ( */ export class AdbProvider { private readonly environment: Readonly>; - readonly #pendingPackageDirectoryCleanup = - new Set(); + readonly #pendingOutputDirectoryCleanup = + new Set(); readonly #activeOperations = new Set>(); #closing = false; #closePromise: Promise | undefined; - #packageCleanupPromise: Promise | undefined; + #outputCleanupPromise: Promise | undefined; constructor(options: AdbProviderOptions = {}) { this.environment = options.environment ?? process.env; @@ -462,13 +493,13 @@ export class AdbProvider { } } - /** Retry removal of package directories retained after failed rollback. */ + /** Retry removal of owned output directories retained after failed cleanup. */ close(): Promise { this.#closing = true; if (this.#closePromise !== undefined) return this.#closePromise; const closing = (async () => { await Promise.all(this.#activeOperations); - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); })().finally(() => { if (this.#closePromise === closing) this.#closePromise = undefined; }); @@ -476,23 +507,23 @@ export class AdbProvider { return this.#closePromise; } - #retryPackageDirectoryCleanup(): Promise { - if (this.#packageCleanupPromise !== undefined) - return this.#packageCleanupPromise; - const cleanup = this.#performPackageDirectoryCleanup().finally(() => { - if (this.#packageCleanupPromise === cleanup) - this.#packageCleanupPromise = undefined; + #retryOutputDirectoryCleanup(): Promise { + if (this.#outputCleanupPromise !== undefined) + return this.#outputCleanupPromise; + const cleanup = this.#performOutputDirectoryCleanup().finally(() => { + if (this.#outputCleanupPromise === cleanup) + this.#outputCleanupPromise = undefined; }); - this.#packageCleanupPromise = cleanup; + this.#outputCleanupPromise = cleanup; return cleanup; } - async #performPackageDirectoryCleanup(): Promise { + async #performOutputDirectoryCleanup(): Promise { const failures: AnalysisError[] = []; - for (const owner of this.#pendingPackageDirectoryCleanup) { + for (const owner of this.#pendingOutputDirectoryCleanup) { try { await owner.remove(); - this.#pendingPackageDirectoryCleanup.delete(owner); + this.#pendingOutputDirectoryCleanup.delete(owner); } catch (cause: unknown) { const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, @@ -500,13 +531,13 @@ export class AdbProvider { { reason: cause instanceof Error ? cause.message : String(cause), }, - { operation: "pull_adb_package", cause }, + { operation: owner.operation, cause }, ); failures.push( analysisErrorWithCleanupFailure( owner.primary, cleanup, - "pull_adb_package", + owner.operation, ), ); } @@ -515,20 +546,22 @@ export class AdbProvider { if (failures.length > 1) throw new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map(({ path }) => path), + [...this.#pendingOutputDirectoryCleanup].map(({ path }) => path), { - reason: - "Failed package output directories remain after cleanup retry", + reason: "Failed ADB output directories remain after cleanup retry", + operations: [...this.#pendingOutputDirectoryCleanup].map( + ({ operation }) => operation, + ), failures: failures.map(({ message, cleanupResources }) => ({ message, resources: [...cleanupResources], })), }, { - operation: "pull_adb_package", + operation: "close_binary", cause: new AggregateError( failures, - "Multiple ADB package rollback cleanups failed", + "Multiple ADB output directory cleanups failed", ), }, ); @@ -546,16 +579,16 @@ export class AdbProvider { }); this.#activeOperations.add(active); try { - if (this.#pendingPackageDirectoryCleanup.size > 0) { + if (this.#pendingOutputDirectoryCleanup.size > 0) { try { - await this.#retryPackageDirectoryCleanup(); + await this.#retryOutputDirectoryCleanup(); } catch (cause: unknown) { return err( cause instanceof AnalysisError ? cause : new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, - [...this.#pendingPackageDirectoryCleanup].map( + [...this.#pendingOutputDirectoryCleanup].map( ({ path }) => path, ), { @@ -1245,7 +1278,7 @@ export class AdbProvider { if (!directory.ok) return directory; const packageDirectory = join(directory.value, packageName); let packageDirectoryCreated = false; - let packageDirectoryIdentity: PackageDirectoryIdentity | undefined; + let packageDirectoryIdentity: OutputDirectoryIdentity | undefined; try { if ( await lstat(packageDirectory).then( @@ -1275,17 +1308,18 @@ export class AdbProvider { { cause }, ); if (!packageDirectoryCreated) return err(primary); - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), }; - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -1299,12 +1333,13 @@ export class AdbProvider { const failOwnedPackage = async ( primary: AnalysisError, ): Promise> => { - const owner: PendingPackageDirectoryCleanup = { + const owner: PendingOutputDirectoryCleanup = { + operation, path: packageDirectory, identity: packageDirectoryIdentity, primary, remove: () => - removeOwnedPackageDirectory( + removeOwnedOutputDirectory( packageDirectory, packageDirectoryIdentity, ), @@ -1312,7 +1347,7 @@ export class AdbProvider { try { await owner.remove(); } catch (cause: unknown) { - this.#pendingPackageDirectoryCleanup.add(owner); + this.#pendingOutputDirectoryCleanup.add(owner); const cleanup = new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, [packageDirectory], @@ -2084,48 +2119,71 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const collected = await runAdb( - operation, - input.serial, - selection.binary, - adbArguments(input.serial, ["bugreport", directory.value]), - { - timeoutMs: BUGREPORT_TIMEOUT_MS, - maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, - }, - signal, - ); - if (!collected.ok) return collected; - const reportedPath = parseBugreportCopiedPath(collected.value.stdout); - if (reportedPath === null) - return err( - new AnalysisProtocolError( - "adb bugreport did not report an archive path; the build may use an unsupported format", - ), - ); + let stagingDirectory: string; + let stagingIdentity: OutputDirectoryIdentity | undefined; + let retainedObservation: AdbAcquiredFilePartialObservation | undefined; + let publishedPath: string | undefined; try { - const digest = await hashAdbFile(reportedPath, operation, signal); - return ok( - createAnalysisExecution( - { - client, - serial: input.serial, - local_path: reportedPath, - bytes: digest.bytes, - sha256: digest.sha256, - adb_reported_path: reportedPath, - }, - provider, - { - limitations: [ - "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", - ], - }, - ), + stagingDirectory = await mkdtemp( + join(directory.value, ".rea-bugreport-"), ); } catch (cause) { - if (cause instanceof AnalysisCancelledError) { - cause.retainPartialObservation({ + return err( + new AnalysisOutputError( + operation, + "The bugreport staging directory could not be created", + { cause }, + ), + ); + } + const collectedResult = await (async (): Promise< + Result + > => { + let reportedPath: string | null = null; + try { + const created = await lstat(stagingDirectory); + if (!created.isDirectory() || created.isSymbolicLink()) + throw new Error( + `The created bugreport staging output is not a regular directory: ${stagingDirectory}`, + ); + stagingIdentity = { + dev: created.dev, + ino: created.ino, + mode: created.mode, + }; + const collected = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["bugreport", stagingDirectory]), + { + timeoutMs: BUGREPORT_TIMEOUT_MS, + maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!collected.ok) return collected; + reportedPath = parseBugreportCopiedPath(collected.value.stdout); + if (reportedPath === null) + return err( + new AnalysisProtocolError( + "adb bugreport did not report an archive path; the build may use an unsupported format", + ), + ); + if (dirname(resolve(reportedPath)) !== stagingDirectory) + return err( + new AnalysisProtocolError( + `adb bugreport reported an archive outside its staging directory: ${reportedPath}`, + ), + ); + const archive = await lstat(reportedPath); + if (!archive.isFile() || archive.isSymbolicLink()) + return err( + new AnalysisProtocolError( + `adb bugreport did not report a regular archive file: ${reportedPath}`, + ), + ); + retainedObservation = { provider_id: "adb", operation, result: { @@ -2135,16 +2193,94 @@ export class AdbProvider { adb_reported_path: reportedPath, digest_status: "unknown", }, - }); - return err(cause); - } - return err( - new AnalysisOutputError( + }; + try { + publishedPath = await publishBugreport(reportedPath, directory.value); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be published: ${reportedPath}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + retainedObservation = { + ...retainedObservation, + result: { ...retainedObservation.result, local_path: publishedPath }, + }; + const digest = await hashAdbFile(publishedPath, operation, signal); + const report = { + client, + serial: input.serial, + local_path: publishedPath, + bytes: digest.bytes, + sha256: digest.sha256, + adb_reported_path: reportedPath, + }; + retainedObservation = { + provider_id: "adb", + operation, + result: report, + }; + return ok( + createAnalysisExecution(report, provider, { + limitations: [ + "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", + ], + }), + ); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) { + if (retainedObservation !== undefined) + cause.retainPartialObservation(retainedObservation); + return err(cause); + } + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`, + { cause, partialObservation: retainedObservation }, + ), + ); + } + })(); + // A completed acquisition becomes caller evidence even if publication fails. + // Its staging directory must not enter automatic cleanup or cleanup retries. + if (retainedObservation !== undefined && publishedPath === undefined) + return collectedResult; + try { + await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity); + } catch (cause) { + const primary = collectedResult.ok + ? new AnalysisOutputError( + operation, + "The archive was published, but its staging directory could not be removed", + ) + : collectedResult.error; + if (retainedObservation !== undefined) + primary.retainPartialObservation(retainedObservation); + this.#pendingOutputDirectoryCleanup.add({ + operation, + path: stagingDirectory, + identity: stagingIdentity, + primary, + remove: () => + removeOwnedOutputDirectory(stagingDirectory, stagingIdentity), + }); + return err( + analysisErrorWithCleanupFailure( + primary, + new ProviderCleanupError( + ADB_PROVIDER_IDENTITY.id, + [stagingDirectory], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation, cause }, + ), operation, - `The reported bugreport archive could not be read back: ${reportedPath}`, - { cause }, ), ); } + return collectedResult; } } diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index fd956dde98..11198df5e7 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -709,7 +709,7 @@ export type AdbPackagePullPartialObservation = { readonly result: z.infer; }; -/** A completed acquisition whose digest read was cancelled before verification. */ +/** Acquired file facts retained when publication, digest verification or cleanup fails. */ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; @@ -727,13 +727,15 @@ export type AdbAcquiredFilePartialObservation = | { readonly provider_id: "adb"; readonly operation: "collect_adb_bugreport"; - readonly result: { - readonly client: z.infer< - typeof adbResultSchemas.collect_adb_bugreport - >["client"]; - readonly serial: string; - readonly local_path: string; - readonly adb_reported_path: string; - readonly digest_status: "unknown"; - }; + readonly result: + | z.infer + | { + readonly client: z.infer< + typeof adbResultSchemas.collect_adb_bugreport + >["client"]; + readonly serial: string; + readonly local_path: string; + readonly adb_reported_path: string; + readonly digest_status: "unknown"; + }; }; diff --git a/tests/boundary/providers/adb/adbBugreportPublication.test.ts b/tests/boundary/providers/adb/adbBugreportPublication.test.ts new file mode 100644 index 0000000000..b99799127e --- /dev/null +++ b/tests/boundary/providers/adb/adbBugreportPublication.test.ts @@ -0,0 +1,266 @@ +import { createHash } from "node:crypto"; +import { + copyFile, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; + +import { AdbProvider } from "../../../../src/adb/AdbProvider.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { + execFileBytes, + execFileOutput, +} from "../../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +// Exercise real filesystem publication; the Android process reply is simulated. +vi.mock( + "../../../../src/process/ExecFileOutput.js", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../../src/process/ExecFileOutput.js") + >()), + execFileBytes: vi.fn(), + execFileOutput: vi.fn(), + }), +); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyFile: vi.fn(actual.copyFile), + mkdtemp: vi.fn(actual.mkdtemp), + rm: vi.fn(actual.rm), + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +const fixture = async (duringCollection?: (root: string) => Promise) => { + const root = await createTestTempDirectory("rea-adb-bugreport-"); + const binary = join(root, "adb"); + let reportedPath = ""; + vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => { + expect(args).toEqual(["version"]); + return { + stdout: `Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`, + stderr: "", + }; + }); + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + expect(args.slice(0, 3)).toEqual(["-s", "device", "bugreport"]); + const staging = args[3]!; + reportedPath = join(staging, "bugreport test.zip"); + await writeFile(reportedPath, "bugreport-bytes"); + await duringCollection?.(root); + return { + stdout: Buffer.from(`Bug report copied to ${reportedPath}\n`), + stderr: Buffer.alloc(0), + }; + }); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + return { + root, + provider, + reportedPath: () => reportedPath, + request: { + operation: "collect_adb_bugreport" as const, + input: { serial: "device", output_directory: root }, + }, + }; +}; + +test.each(["before", "during"] as const)( + "preserves a same-name caller archive created %s collection", + async (when) => { + const existing = async (root: string) => { + await writeFile(join(root, "bugreport test.zip"), "existing evidence"); + await writeFile(join(root, "bugreport test (1).zip"), "other evidence"); + }; + const f = await fixture(when === "during" ? existing : undefined); + if (when === "before") await existing(f.root); + const result = await f.provider.execute(f.request); + if (!result.ok) throw result.error; + const localPath = join(f.root, "bugreport test (2).zip"); + expect(result.value.result).toMatchObject({ + local_path: localPath, + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "existing evidence", + ); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "other evidence", + ); + expect(await readFile(localPath, "utf8")).toBe("bugreport-bytes"); + expect((await readdir(f.root)).sort()).toEqual([ + "bugreport test (1).zip", + "bugreport test (2).zip", + "bugreport test.zip", + ]); + await f.provider.close(); + }, +); + +test("retains a completed archive after failed publication and later provider work", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + const publicationFailure = Object.assign(new Error("no space left"), { + code: "ENOSPC", + }); + vi.mocked(copyFile).mockRejectedValueOnce(publicationFailure); + const failed = await f.provider.execute(f.request); + if (failed.ok) throw new Error("expected publication failure"); + const retainedPath = f.reportedPath(); + expect(failed.error._tag).toBe("AnalysisOutputError"); + expect(failed.error.cause).toBe(publicationFailure); + expect( + projectAnalysisError(failed.error).details?.partial_observation, + ).toMatchObject({ + provider_id: "adb", + operation: f.request.operation, + result: { + serial: "device", + local_path: retainedPath, + adb_reported_path: retainedPath, + digest_status: "unknown", + }, + }); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + + // New work and terminal cleanup must not reclaim the retained acquisition. + const next = await f.provider.execute(f.request); + if (!next.ok) throw next.error; + expect(next.value.result).toMatchObject({ + local_path: join(f.root, "bugreport test (1).zip"), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }); + await f.provider.close(); + expect(await readFile(retainedPath, "utf8")).toBe("bugreport-bytes"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readFile(join(f.root, "bugreport test (1).zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect((await readdir(f.root)).sort()).toEqual( + [ + basename(dirname(retainedPath)), + "bugreport test.zip", + "bugreport test (1).zip", + ].sort(), + ); +}); + +test("retains final and producer paths when digest verification is cancelled", async () => { + const f = await fixture(); + const controller = new AbortController(); + const actualCopy = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(copyFile).mockImplementationOnce(async (...args) => { + await actualCopy.copyFile(...args); + controller.abort(); + }); + const result = await f.provider.execute(f.request, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + digest_status: "unknown", + }, + }); + expect(await readFile(join(f.root, "bugreport test.zip"), "utf8")).toBe( + "bugreport-bytes", + ); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("returns staging allocation failures without invoking device collection", async () => { + const f = await fixture(); + vi.mocked(mkdtemp).mockRejectedValueOnce(new Error("staging denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected output failure"); + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(execFileBytes).not.toHaveBeenCalled(); + await f.provider.close(); +}); + +test("retains published evidence and retries failed staging cleanup", async () => { + const f = await fixture(); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error.cleanupResources).toContain(dirname(f.reportedPath())); + expect(result.error.partialObservation).toMatchObject({ + operation: f.request.operation, + result: { + local_path: join(f.root, "bugreport test.zip"), + adb_reported_path: f.reportedPath(), + bytes: 15, + sha256: createHash("sha256").update("bugreport-bytes").digest("hex"), + }, + }); + await f.provider.close(); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); +}); + +test("refuses an archive reported outside the owned staging directory", async () => { + const f = await fixture(); + const existing = join(f.root, "bugreport test.zip"); + await writeFile(existing, "existing evidence"); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from(`Bug report copied to ${existing}\n`), + stderr: Buffer.alloc(0), + }); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol refusal"); + expect(result.error._tag).toBe("AnalysisProtocolError"); + expect(await readFile(existing, "utf8")).toBe("existing evidence"); + expect(await readdir(f.root)).toEqual(["bugreport test.zip"]); + await f.provider.close(); +}); + +test("preserves the primary protocol failure when staging cleanup also fails", async () => { + const f = await fixture(); + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.from("unsupported completion format\n"), + stderr: Buffer.alloc(0), + }); + vi.mocked(rm).mockRejectedValueOnce(new Error("cleanup denied")); + const result = await f.provider.execute(f.request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected protocol and cleanup failure"); + expect(result.error.cause).toMatchObject({ + errors: [ + { + _tag: "AnalysisProtocolError", + message: expect.stringContaining("did not report an archive path"), + }, + expect.any(Error), + ], + }); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toHaveLength(1); + await f.provider.close(); + expect(await readdir(f.root)).toEqual([]); +});