diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index a1c9ed6def..7a9c5fc3a0 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -83,9 +83,11 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and coverage and an explicit limit, rather than reported as missing. - `decoded_file_count` is capped at 100,000 files; the cap is reported in the limitations when hit. -- Apktool's own decode failures (`AndrolibException`, directory traversal - refusals) surface as `unsupported_target` with apktool's reason preserved - verbatim. +- An ordinary nonzero decode exit surfaces as `unsupported_target` naming + the caller-selected APK, with Apktool's diagnostic and captured output. + Launcher startup, ownership preparation, signal termination and incomplete + output collection remain provider failures; they do not establish that the + selected APK is unsupported. ## Real verification diff --git a/src/apktool/ApktoolCommandOutput.ts b/src/apktool/ApktoolCommandOutput.ts index 3dd0e47e0a..4e00fb1115 100644 --- a/src/apktool/ApktoolCommandOutput.ts +++ b/src/apktool/ApktoolCommandOutput.ts @@ -1,8 +1,10 @@ +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; /** Project the command owner's bounded output without losing examined byte counts. */ -export const capturedApktoolCommandOutput = ( +const capturedApktoolCommandOutput = ( output: ProviderProcessSnapshot, ): AnalysisCapturedOutput => ({ stdout: output.stdout.text, @@ -14,3 +16,16 @@ export const capturedApktoolCommandOutput = ( exit_code: output.exitCode ?? null, signal: output.signal ?? null, }); + +/** The command owner selects command output or preparation output, once. */ +export const capturedApktoolFailureOutput = ( + cause: unknown, +): AnalysisCapturedOutput | undefined => { + if (cause instanceof OwnedCommandFailure) + return cause.snapshot === null + ? cause.preparationFailure?.capturedOutput + : capturedApktoolCommandOutput(cause.snapshot); + return cause instanceof DarwinProcessOwnershipInspectionError + ? cause.capturedOutput + : undefined; +}; diff --git a/src/apktool/ApktoolCommandRecovery.test.ts b/src/apktool/ApktoolCommandRecovery.test.ts index 1c1ea3c1fe..661176302f 100644 --- a/src/apktool/ApktoolCommandRecovery.test.ts +++ b/src/apktool/ApktoolCommandRecovery.test.ts @@ -1,3 +1,6 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { afterEach, expect, it, vi } from "vitest"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -376,3 +379,99 @@ it("preserves an opaque cancellation reason before acquiring a command", async ( await client.close(); } }); + +it.each([ + "refusal", + "spawn", + "collection", + "signal", + "incomplete", + "preparation", +] as const)( + "keeps target identity separate from a %s command outcome", + async (kind) => { + const root = await createTestTempDirectory("rea-apktool-attribution-"); + const path = join(root, "caller-selected.apk"); + await writeFile(path, "authored input"); + const client = provider(); + const output = { + stdout: { text: "authored stdout", bytes: 15, observedBytes: 15 }, + stderr: { text: "authored decode refusal", bytes: 23, observedBytes: 23 }, + exitCode: 1, + signal: null, + diagnosticTruncated: false, + } satisfies Awaited>; + const compiler = { + stdout: "compiler output", + stderr: "compiler diagnostic", + truncated: false, + stdout_bytes: 15, + stderr_bytes: 19, + exit_code: 1, + signal: null, + }; + const failure = + kind === "preparation" + ? new DarwinProcessOwnershipInspectionError( + "authored preparation failure", + { capturedOutput: compiler }, + ) + : new OwnedCommandFailure( + "process", + "authored command failure", + { + ...output, + ...(kind === "signal" + ? { exitCode: null, signal: "SIGTERM" } + : {}), + ...(kind === "incomplete" ? { outputIncomplete: true } : {}), + }, + null, + { + ...(kind === "spawn" + ? { processError: new Error("authored startup failure") } + : {}), + ...(kind === "collection" ? { collectionFailure: true } : {}), + }, + ); + vi.mocked(runOwnedCommand) + .mockImplementationOnce(async (_spawn, limits) => { + limits.observeCompleted?.(version); + return version; + }) + .mockRejectedValueOnce(failure); + try { + const result = await client.execute({ + operation: "decode_android_resources", + input: { path, include_strings: true }, + }); + if (result.ok) throw new Error("Expected authored failure"); + if (kind === "refusal") { + expect(result.error).toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + path, + }); + expect(result.error.message).toContain(path); + expect(result.error.message).not.toContain("/authored/apktool"); + } else + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { + target_path: path, + selected_command: "/authored/apktool", + }, + }); + expect(result.error.capturedOutput).toMatchObject( + kind === "preparation" + ? compiler + : { stdout: output.stdout.text, stderr: output.stderr.text }, + ); + expect(result.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path, + }); + } finally { + await client.close(); + } + }, +); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 43723e8735..a8f3e03df7 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -1,4 +1,4 @@ -import { capturedApktoolCommandOutput } from "./ApktoolCommandOutput.js"; +import { capturedApktoolFailureOutput } from "./ApktoolCommandOutput.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { ok, err, type Result } from "../domain/result.js"; import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js"; @@ -429,10 +429,7 @@ const versionProbeFailure = ( : cause instanceof DarwinProcessOwnershipInspectionError ? cause : undefined; - const output = - cause instanceof OwnedCommandFailure && cause.snapshot !== null - ? capturedApktoolCommandOutput(cause.snapshot) - : preparation?.capturedOutput; + const output = capturedApktoolFailureOutput(cause); if (cause instanceof OwnedCommandFailure && cause.reason === "cancelled") return new AnalysisCancelledError("inspect_apktool_client", { cause: cause.cancellation?.reason, diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 3edff9dcc1..5a932a6302 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -391,7 +391,12 @@ it("reports apktool's own refusal for undecodable targets", async () => { operation: "decode_android_resources", input: { path: apk.path, include_strings: true }, }); - expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected).toMatchObject({ + code: "unsupported_target", + details: { path: apk.path }, + }); + expect(projected.message).toContain(apk.path); + expect(projected.message).not.toContain(command); expect(projected.message).toContain("Could not decode"); }); @@ -858,3 +863,55 @@ it("joins a late workspace allocation during shutdown and removes its owned root await (closing ?? provider.close()); } }); + +it.each(["first-selected.apk", "second-selected.apk"])( + "attributes a decode refusal to %s without substituting the launcher", + async (name) => { + const { command, root } = await writeApktoolStub( + decodeStub({ failDecode: true }), + ); + const path = join(root, name); + await writeFile(path, "authored target"); + const provider = providerOver(command); + try { + const projected = await refusedBy(provider, { + operation: "decode_android_resources", + input: { path, include_strings: true }, + }); + expect(projected).toMatchObject({ + code: "unsupported_target", + details: { path, captured_output: { exit_code: 1 } }, + }); + expect(projected.message).toContain(path); + expect(projected.message).not.toContain(command); + } finally { + await provider.close(); + } + }, +); + +it("reports launcher disappearance after version admission as a provider failure", async () => { + const { command, root } = await writeApktoolStub( + () => + `if [ "$1" = "--version" ]; then rm -- "$0"; ${VERSION_BLOCK}; exit 0; fi`, + ); + const apk = await apkFixture(root); + const provider = providerOver(command); + try { + const result = await provider.execute({ + operation: "decode_android_resources", + input: { path: apk.path, include_strings: true }, + }); + if (result.ok) throw new Error("Expected missing launcher"); + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { target_path: apk.path, selected_command: command }, + }); + expect(projectAnalysisError(result.error).code).not.toBe( + "unsupported_target", + ); + expect(result.error.cause).toBeDefined(); + } finally { + await provider.close(); + } +}); diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index d4a748a108..0ed7c919d9 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -12,7 +12,7 @@ import { artifactFileCancellationCause, } from "../artifacts/ArtifactFileOperation.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -import { capturedApktoolCommandOutput } from "./ApktoolCommandOutput.js"; +import { capturedApktoolFailureOutput } from "./ApktoolCommandOutput.js"; import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; @@ -41,6 +41,7 @@ import { } from "../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { OwnedCommandFailure, @@ -97,6 +98,7 @@ interface DecodeLimits { const runApktool = async ( operation: string, + targetPath: string, selection: ApktoolCommandSelection, arguments_: readonly string[], limits: DecodeLimits, @@ -147,7 +149,7 @@ const runApktool = async ( return err( commands.retainFailure( cause, - apktoolCommandFailure(cause, operation, selection, limits), + apktoolCommandFailure(cause, operation, targetPath, selection, limits), operation, root, ), @@ -158,17 +160,22 @@ const runApktool = async ( const apktoolCommandFailure = ( cause: unknown, operation: string, + targetPath: string, selection: ApktoolCommandSelection, limits: DecodeLimits, ): AnalysisError => { + const capturedOutput = capturedApktoolFailureOutput(cause); if (!(cause instanceof OwnedCommandFailure)) - return new AnalysisProtocolError(`apktool failed for ${operation}`, { + return new ProviderAdapterError("apktool", operation, { cause, + diagnostics: { + selected_command: selection.command, + target_path: targetPath, + }, + ...(capturedOutput === undefined ? {} : { capturedOutput }), }); const output = cause.snapshot; - const capturedOutput = - output === null ? undefined : capturedApktoolCommandOutput(output); if (cause.reason === "cancelled") return new AnalysisCancelledError(operation, { cause: cause.cancellation?.reason, @@ -190,9 +197,32 @@ const apktoolCommandFailure = ( ...(capturedOutput === undefined ? {} : { capturedOutput }), }, ); + // A physical launcher/capture failure does not establish target refusal. + // Only a fully collected ordinary nonzero decode exit carries that meaning. + if ( + cause.processError !== undefined || + cause.collectionFailure || + cause.preparationFailure !== undefined || + output === null || + output.exitCode === null || + output.exitCode === undefined || + output.exitCode === 0 || + output.signal !== null || + output.outputIncomplete === true || + output.diagnosticTruncated === true + ) + return new ProviderAdapterError("apktool", operation, { + cause, + userMessage: `The selected Apktool command failed while decoding ${targetPath}`, + diagnostics: { + selected_command: selection.command, + target_path: targetPath, + }, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }); return new AnalysisUnsupportedTargetError( operation, - selection.command, + targetPath, output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) || `apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`, { @@ -503,6 +533,7 @@ export class ApktoolProvider { await mkdir(framework); const decoded = await runApktool( operation, + input.path, selection, [ "d",