From ac545adae21893828d20aec31f098f6533e2ed0d Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:13:58 +0800 Subject: [PATCH] fix(apk): classify fixed inspection allowances as nonretryable (#7784) --- docs/apk-signature.md | 7 ++ .../ApkSignatureAdmission.test.ts | 18 +++- src/apkSignature/apkSignatureFailures.test.ts | 66 +++++++++++++++ src/apkSignature/apkSignatureFailures.ts | 21 +++++ .../apkSignatureLimitsCliMcp.test.ts | 82 +++++++++++++++++++ 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/apkSignature/apkSignatureFailures.test.ts create mode 100644 tests/acceptance/apkSignatureLimitsCliMcp.test.ts diff --git a/docs/apk-signature.md b/docs/apk-signature.md index 035d7224a9..d2fe6d8e80 100644 --- a/docs/apk-signature.md +++ b/docs/apk-signature.md @@ -239,6 +239,13 @@ serialized size of the report together with its limitations (which repeat archive names) is then checked against an 8 MiB output budget (the exact check is authoritative); an over-budget report is refused, not truncated. +Signing blocks admit at most 4096 ID/value pairs; at most 4096 signing-relevant +META-INF entries are retained. These fixed counts, signer/lineage counts and the +report byte budget return nonretryable `resource_constraint` errors naming the +effective allowance. This release has no runtime override for them. Waiting for +an active scan cannot raise a fixed count, and complete CLI JSON cannot raise the +report budget. Occupied scan-queue capacity remains retryable after a scan finishes. + The whole-file reader allocates the selected snapshot and bounded read scratch; total process memory exceeds the selected file's size. It also computes the complete file SHA-256, so inspection is not header-only. Content digests are recomputed diff --git a/src/apkSignature/ApkSignatureAdmission.test.ts b/src/apkSignature/ApkSignatureAdmission.test.ts index 0c99c91aae..e5d481d709 100644 --- a/src/apkSignature/ApkSignatureAdmission.test.ts +++ b/src/apkSignature/ApkSignatureAdmission.test.ts @@ -1,3 +1,5 @@ +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { expect, it } from "vitest"; import { apkSignatureAdmission } from "./ApkSignatureAdmission.js"; import { APK_SIGNATURE_LIMITS } from "./ApkSignatureRelease.js"; @@ -21,9 +23,19 @@ it("bounds process-wide scans and waiting requests, cancels queued work and reco () => apkSignatureAdmission.acquire(), ); try { - await expect(apkSignatureAdmission.acquire()).rejects.toMatchObject({ - _tag: "AnalysisResourceConstraintError", - resource: "capacity", + const refused = await apkSignatureAdmission + .acquire() + .catch((cause: unknown) => { + if (!(cause instanceof AnalysisResourceConstraintError)) throw cause; + return projectAnalysisError(cause); + }); + expect(refused).toMatchObject({ + code: "resource_constraint", + retryable: true, + remediation: { + action: + "Wait for an active analysis to complete, then retry within the reported admission limits.", + }, }); expect(granted).toBe(false); controller.abort(reason); diff --git a/src/apkSignature/apkSignatureFailures.test.ts b/src/apkSignature/apkSignatureFailures.test.ts new file mode 100644 index 0000000000..3216b81158 --- /dev/null +++ b/src/apkSignature/apkSignatureFailures.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from "vitest"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + apkOutputBudgetError, + mapApkBlockError, + mapApkSchemeError, + mapCentralDirectoryError, + v1SignerCapacityError, +} from "./apkSignatureFailures.js"; +import { APK_SIGNATURE_LIMITS } from "./ApkSignatureRelease.js"; + +it.each([ + [ + "block pairs", + mapApkBlockError( + { kind: "budget-exceeded", resource: "signing-block-pairs", limit: 4096 }, + "/selected.apk", + ), + "max_signing_block_pairs", + 4096, + ], + [ + "scheme signers", + mapApkSchemeError( + { kind: "budget-exceeded", resource: "signers", limit: 32 }, + "v2", + ), + "max_signers", + 32, + ], + [ + "v1 entries", + mapCentralDirectoryError({ + kind: "budget-exceeded", + resource: "v1-signature-entries", + limit: 4096, + }), + "max_v1_signature_entries", + 4096, + ], + ["v1 signers", v1SignerCapacityError(32), "max_v1_signers", 32], + [ + "report bytes", + apkOutputBudgetError(APK_SIGNATURE_LIMITS.outputBytes + 1), + "max_output_bytes", + APK_SIGNATURE_LIMITS.outputBytes, + ], +] as const)( + "reports %s as a deterministic fixed allowance", + (_name, error, key, limit) => { + const projected = projectAnalysisError(error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { reported_limits: { [key]: limit } }, + }); + expect(projected.remediation.action).toContain(String(limit)); + expect(projected.remediation.action).toContain("no runtime override"); + expect(projected.remediation.action).toContain( + "Repeating the unchanged request fails the same way", + ); + expect(projected.remediation.action).not.toContain( + "Wait for an active analysis to complete", + ); + }, +); diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index eec744d4c8..659506ecc9 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -1,3 +1,4 @@ +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { artifactFilePrimaryFacts, @@ -66,6 +67,10 @@ export const mapApkBlockError = ( "capacity", `APK Signing Block holds more ${error.resource} than the ${String(error.limit)} limit.`, { max_signing_block_pairs: error.limit }, + fixedAllowanceOptions( + `The APK Signing Block allowance of ${String(error.limit)} pairs (this release has no runtime override)`, + "count", + ), ); } }; @@ -84,6 +89,10 @@ export const mapApkSchemeError = ( "capacity", `APK Signature Scheme ${scheme} holds more ${error.resource} than the ${String(error.limit)} limit.`, { [`max_${error.resource.replace(/-/g, "_")}`]: error.limit }, + fixedAllowanceOptions( + `The APK Signature Scheme ${scheme} allowance of ${String(error.limit)} ${error.resource} (this release has no runtime override)`, + "count", + ), ); /** A central directory that cannot be walked leaves v1 presence unknown: refuse. */ @@ -96,6 +105,10 @@ export const mapCentralDirectoryError = ( "capacity", `The central directory holds more than ${String(error.limit)} META-INF v1 signing entries.`, { max_v1_signature_entries: error.limit }, + fixedAllowanceOptions( + `The APK v1 allowance of ${String(error.limit)} META-INF signing entries (this release has no runtime override)`, + "count", + ), ) : inputError( `Malformed ZIP central directory at byte ${String(error.offset)}: ${error.detail}.`, @@ -108,6 +121,10 @@ export const v1SignerCapacityError = (limit: number): AnalysisError => "capacity", `The archive pairs more than ${String(limit)} v1 signature blocks with .SF files.`, { max_v1_signers: limit }, + fixedAllowanceOptions( + `The APK v1 allowance of ${String(limit)} signers (this release has no runtime override)`, + "count", + ), ); /** The decoded result would exceed the inline output budget. */ @@ -117,6 +134,10 @@ export const apkOutputBudgetError = (projectedBytes: number): AnalysisError => "capacity", `The decoded signing report (${String(projectedBytes)} bytes) exceeds the ${String(APK_SIGNATURE_LIMITS.outputBytes)}-byte output budget.`, { max_output_bytes: APK_SIGNATURE_LIMITS.outputBytes }, + fixedAllowanceOptions( + `The decoded APK signing report allowance of ${String(APK_SIGNATURE_LIMITS.outputBytes)} bytes (this release has no runtime override)`, + "report", + ), ); /** diff --git a/tests/acceptance/apkSignatureLimitsCliMcp.test.ts b/tests/acceptance/apkSignatureLimitsCliMcp.test.ts new file mode 100644 index 0000000000..ef85387154 --- /dev/null +++ b/tests/acceptance/apkSignatureLimitsCliMcp.test.ts @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../src/domain/evidence.js"; +import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; +import { apkSignatureSummaryViewResultSchema } from "../../src/domain/analysisView/analysisView.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { + withSigningBlock, + zipWith, +} from "../fixtures/apkSignature/v1SignatureBlock.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "keeps fixed APK pair refusals nonretryable through CLI and MCP, then accepts a valid input", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apk-pair-limit-"); + const archive = await zipWith([ + { name: "AndroidManifest.xml", data: Uint8Array.of(3, 0, 8, 0) }, + ]); + const paths = new Map(); + const digests = new Map(); + for (const count of [1, 4096, 4097]) { + const path = join(root, `pairs-${count}.apk`); + const bytes = withSigningBlock( + archive, + Array.from({ length: count }, () => ({ + id: 0xffffffff, + value: new Uint8Array(), + })), + ); + await writeFile(path, bytes); + digests.set(count, createHash("sha256").update(bytes).digest("hex")); + paths.set(count, path); + } + const expected = { + code: "resource_constraint", + category: "resource_constraint", + retryable: false, + details: { + operation: "inspect_apk_signature", + resource: "capacity", + reported_limits: { max_signing_block_pairs: 4096 }, + }, + }; + for (let attempt = 0; attempt < 2; attempt++) { + const reply = await cli.run({ + arguments: ["inspect-apk-signature", paths.get(4097)!, "--json"], + }); + expect(reply.exitCode, reply.stderr).toBe(1); + const error = analysisErrorProjectionSchema.parse(reply.json); + expect(error).toMatchObject(expected); + expect(error.remediation.action).toContain("4096"); + expect(error.remediation.action).toContain("no runtime override"); + expect(error.remediation.action).toContain( + "Waiting for an active analysis to finish does not raise it", + ); + } + const { call } = await connectLocalToolsMcp({ + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }); + const rejected = await call("inspect_apk_signature", { + path: paths.get(4097), + }); + expect(parseMcpToolError(rejected).error).toMatchObject(expected); + for (const count of [4096, 1]) { + const path = paths.get(count)!; + const reply = await call("inspect_apk_signature", { path }); + expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); + const report = apkSignatureSummaryViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ).summary; + expect(report.artifact.path).toBe(path); + expect(report.artifact.sha256).toBe(digests.get(count)); + expect(report.posture.strongest_scheme).toBe("none"); + } + }, +);