diff --git a/scripts/lib/wabt-verifier-timeouts.mjs b/scripts/lib/wabt-verifier-timeouts.mjs new file mode 100644 index 0000000000..4ee3ab9548 --- /dev/null +++ b/scripts/lib/wabt-verifier-timeouts.mjs @@ -0,0 +1,4 @@ +// A single inspection can include native admission plus several WABT commands. +// The aggregate verifier covers seven complete fixtures and their failure paths. +export const WABT_OPERATION_TIMEOUT_MS = 4 * 60_000; +export const WABT_WORKFLOW_TIMEOUT_MS = 15 * 60_000; diff --git a/scripts/verify-packaged-wasm-artifact.mjs b/scripts/verify-packaged-wasm-artifact.mjs index d425ef9246..95bdc138ac 100644 --- a/scripts/verify-packaged-wasm-artifact.mjs +++ b/scripts/verify-packaged-wasm-artifact.mjs @@ -3,6 +3,7 @@ import { join } from "node:path"; import { mkdir } from "node:fs/promises"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { WABT_WORKFLOW_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs"; import { verifyPackagePack } from "./verify-package-pack.mjs"; const root = await PrivateRuntimeRoot.create({ @@ -49,7 +50,11 @@ try { const verified = await exec( process.execPath, ["scripts/verify-wasm-artifact.mjs", entrypoint, "--require-tools"], - { env: environment, timeout: 180_000, maxBuffer: 4 * 1024 * 1024 }, + { + env: environment, + timeout: WABT_WORKFLOW_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }, ); process.stdout.write(verified.stdout); } finally { diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 2361c98ac2..289073c9ae 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -83,8 +83,11 @@ const FIXTURE_SOURCE = String.raw` #include #include static const char rea_kuna_utf8[] __attribute__((used)) = "_φ( °-°)/ REA unicode marker"; +/* The leading NUL unit ends any UTF-16 run that bytes before this array would + start: the UTF-8 marker's last byte and NUL decode as the unit 'r', so on + layouts where it precedes this array Kuna reports "rREA wide marker". */ static const unsigned short rea_kuna_wide[] __attribute__((used)) = { - 'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0 + 0,'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0 }; #include __attribute__((noinline)) unsigned rea_checksum(const unsigned char *p, size_t n) { diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index c05952118f..9bc5436aff 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -24,6 +24,7 @@ import { requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { WABT_OPERATION_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs"; const directory = process.env.REA_WABT_BIN_DIRECTORY; if (!isAbsolute(directory ?? "")) { @@ -76,6 +77,8 @@ try { throw cause; } const client = new Client({ name: "wabt-artifact-verifier", version: "1" }); +const callTool = (request) => + client.callTool(request, { timeout: WABT_OPERATION_TIMEOUT_MS }); const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], @@ -128,7 +131,7 @@ try { TMP: childTempRoot, TEMP: childTempRoot, }, - timeout: 120_000, + timeout: WABT_OPERATION_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, }, ) @@ -238,7 +241,7 @@ try { const cliValue = await inspectCli(input); assert.equal(cliValue.category, undefined, JSON.stringify(cliValue)); const cli = parseEvidence(cliValue); - const reply = await client.callTool({ + const reply = await callTool({ name: "inspect_wasm_artifact", arguments: input, }); @@ -332,7 +335,7 @@ try { }, ); } - const bundleReply = await client.callTool({ + const bundleReply = await callTool({ name: "get_evidence_bundle", arguments: {}, }); @@ -362,7 +365,7 @@ try { JSON.stringify(cliError), ); const error = requireMcpToolError( - await client.callTool({ + await callTool({ name: "inspect_wasm_artifact", arguments: invalidInput, }), @@ -370,7 +373,7 @@ try { assert.equal(error.category, "invalid_input"); const partial = error.details.partial_observation; assert.equal(partial.kind, "retained-evidence"); - const failedBundleReply = await client.callTool({ + const failedBundleReply = await callTool({ name: "get_evidence_bundle", arguments: {}, }); diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 32760e0820..c8837f969e 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -84,6 +84,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { recordOmittedArchive(entry.path, "max_documents"); unreportedOmittedArchives += selection.unreportedOmitted; for (const entry of selection.selected) { + // Plist representation reserved for this document before decoding. + let plistReservation = 0; try { const remainingInputBytes = MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes; @@ -132,6 +134,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { decodedReservation.estimatedBytes, "plist representation exceeds the aggregate Interface Builder decode budget", ); + plistReservation = decodedReservation.estimatedBytes; binaryPrototypeKeyObserved = decodedReservation.binaryPrototypeKeyObserved; } @@ -165,6 +168,9 @@ export const analyzeInterfaceBuilderBundle = async (input: { } catch (cause: unknown) { if (input.signal?.aborted === true) throw cause; if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { + // A document rejected while decoding, e.g. beyond the shared depth + // limit, retains no representation; later documents keep its share. + decodeBudget.release(plistReservation); omitted += 1; recordOmittedArchive(entry.path, cause.reason); continue; @@ -700,6 +706,24 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; +/** + * The shared parser bounds plist value nesting before its recursive decode and + * reports the limit as a RangeError; that is an omitted archive, not a + * malformed one. + */ +const parseXmlWithinDecodeBudget = (text: string) => { + try { + return parseXmlPropertyList(text); + } catch (cause: unknown) { + if (cause instanceof RangeError) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + cause.message, + ); + throw cause; + } +}; + /** Project decoded plist data and dates while retaining omitted-key coverage. */ const decodePlist = ( bytes: Buffer, @@ -716,7 +740,7 @@ const decodePlist = ( value: parseBinary(bytes), omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, } - : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); + : parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes)); const projected = projectPlistValue(value); return { value: projected.value, diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index 5d143f52a2..2eb11bf117 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -49,6 +49,14 @@ export class InterfaceBuilderDecodeBudget { this.#remainingBytes -= bytes; } + /** Return a reservation whose representation was rejected and not retained. */ + release(bytes: number): void { + this.#remainingBytes = Math.min( + this.#initialBytes, + this.#remainingBytes + bytes, + ); + } + /** Reserve an upper bound for one JSON-serialized string occurrence. */ reserveJsonStringOccurrence(value: string): void { // JSON escaping uses at most six ASCII bytes per UTF-16 code unit, plus @@ -117,7 +125,8 @@ const estimateXmlPlistExpansion = ( const xml = xmlText ?? decodeXmlPlistText(bytes); const decodedSourceBytes = xml.length * 2; let elementCount = 0; - let xmlDepth = 0; + // Value nesting is bounded by the shared plist parser, which counts plist + // value depth; raw element depth would also count the envelope and keys. for (const token of xmlPreflightTokens(xml)) { if (token.doctype) { // Only a declaration outside opaque text can introduce an internal @@ -130,15 +139,6 @@ const estimateXmlPlistExpansion = ( continue; } elementCount += 1; - if (token.closing) xmlDepth -= 1; - else if (!token.selfClosing) { - xmlDepth += 1; - if (xmlDepth > 128) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - "XML plist nesting exceeds the aggregate Interface Builder decode budget", - ); - } if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes) return maxBytes + 1; } @@ -179,13 +179,7 @@ function* xmlPreflightTokens(xml: string) { } if (end === xml.length) return; cursor = end + 1; - if (doctype || element) - yield { - doctype, - internalSubset, - closing: xml[start + 1] === "/", - selfClosing: xml[end - 1] === "/", - }; + if (doctype || element) yield { doctype, internalSubset }; } } diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 2b9b0bd817..64b928900a 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -121,6 +121,43 @@ describe("source-map artifact relationships", () => { ); }, ); + + it("keeps a directory named like a source map contained by its parent", () => { + const archive = createOccurrence(entry("app", "directory"), "app", null); + archive.artifact_id = contentId("1"); + const script = createOccurrence( + entry("app/main.js", "file"), + "app/main.js", + archive.occurrence_id, + ); + script.artifact_id = contentId("2"); + const directory = createOccurrence( + entry("app/main.js.map", "directory"), + "app/main.js.map", + archive.occurrence_id, + ); + directory.artifact_id = contentId("3"); + + const { edges, limitations } = createArtifactEdges(contentId("4"), [ + archive, + script, + directory, + ]); + + expect(directory).toMatchObject({ + artifact_kind: "container", + artifact_format: "directory", + }); + expect( + edges.find( + ({ occurrence_id }) => occurrence_id === directory.occurrence_id, + ), + ).toMatchObject({ + relation: "contains", + parent_artifact_id: contentId("1"), + }); + expect(limitations).toEqual([]); + }); }); const fat = (wide = false): Buffer => diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 654a402446..4d80479151 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -295,7 +295,9 @@ export const createArtifactEdges = ( const edges: ArtifactEdge[] = []; const limitations: string[] = []; for (const occurrence of occurrences) { - const isSourceMap = occurrence.logical_path.toLowerCase().endsWith(".map"); + // Use the occurrence's own classification: a directory named `x.map` is + // a container, not a source map of its sibling. + const isSourceMap = occurrence.artifact_kind === "source-map"; const sourcePath = isSourceMap ? occurrence.logical_path.slice(0, -".map".length) : undefined; @@ -337,8 +339,10 @@ export const createArtifactEdges = ( relation: occurrence.entry_kind === "slice" ? ("slice-of" as const) - : isSourceMap && mappedSource === undefined - ? ("contains" as const) + : isSourceMap + ? mappedSource === undefined + ? ("contains" as const) + : ("maps-source" as const) : relationFor(occurrence.logical_path), occurrence_id: occurrence.occurrence_id, }; @@ -587,11 +591,8 @@ const classifyFatArtifact = ( return identity.state === "mach-o" ? "mach-o-universal" : "unknown"; }; -const relationFor = (path: string): ArtifactEdge["relation"] => { - const lower = path.toLowerCase(); - if (lower.endsWith(".map")) return "maps-source"; - if (lower.includes(".framework/")) return "embeds"; - return "contains"; -}; +/** Relation of a non-source-map occurrence to its parent. */ +const relationFor = (path: string): ArtifactEdge["relation"] => + path.toLowerCase().includes(".framework/") ? "embeds" : "contains"; const depth = (path: string): number => path.split("/").length; diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 84a8ea493e..cffcd8db93 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -156,6 +156,90 @@ describe("compiled Interface Builder graph projection", () => { }); }); +describe("keyed archive objects with unresolved classes", () => { + it("keeps an archived object whose class reference cannot be resolved", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 99 }, label: "Unresolved object" }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + { $class: { UID: 2 }, "NS.string": "ok" }, + "plain scalar", + ], + $top: { root: { UID: 3 } }, + }); + // The class descriptor and the scalar are not archived objects. + expect(parsed.objects).toMatchObject([ + { + id: "1", + kind: "other", + class_name: null, + name: "Unresolved object", + attributes: { label: "Unresolved object", $class: { UID: 99 } }, + }, + { id: "3", kind: "view", class_name: "UIView" }, + ]); + expect(parsed.objects[1]?.attributes).not.toHaveProperty("$class"); + expect(parsed.objectCount).toBe(2); + }); + + it("keeps an unresolved hierarchy root's label and coverage in the graph", () => { + const result = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw: { + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 99 }, label: "Unresolved object" }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + { $class: { UID: 2 }, "NS.string": "ok" }, + ], + $top: { root: { UID: 1 } }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const objectNodes = result.graph.nodes.filter( + ({ attributes }) => attributes.interface_builder_object_id !== undefined, + ); + expect(objectNodes).toMatchObject([ + { + kind: "unknown", + name: "Unresolved object", + attributes: { interface_builder_object_id: "1", class_name: null }, + }, + { kind: "view", attributes: { interface_builder_object_id: "3" } }, + ]); + expect(result.documents[0]?.object_count).toBe(2); + expect( + result.graph.edges.some( + ({ relation, to, resolution }) => + relation === "contains" && + to === objectNodes[0]?.id && + resolution === "observed", + ), + ).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "objects:Main.nib", + status: "complete", + examined: 2, + omitted: 0, + }), + ); + }); +}); + describe("Interface Builder parser bounds", () => { it("reports connections discarded by the parser's hard ceiling", () => { const connections = Array.from({ length: 40_001 }, (_, index) => ({ diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 3c0e977494..e6145a0d27 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -327,11 +327,16 @@ const parseKeyedArchive = ( if (index === 0) continue; const resolved = record(resolve(raw)); const className = firstString(resolved.className); - if (className === null) continue; + // An archived object names its class with its own `$class` reference; + // when that reference cannot be resolved the object is still observed, + // with an unknown class. Scalars and class descriptors are not objects. + if (className === null && !Object.hasOwn(record(raw), "$class")) continue; objectCount += 1; const fields = Object.fromEntries( Object.entries(resolved).filter(([key]) => key !== "className"), ); + // Keep the unresolvable class reference itself, e.g. a dangling UID. + if (className === null) fields.$class = record(raw).$class; const authoredId = firstString(fields.objectID, fields["object-id"]); const objectId = authoredId ?? @@ -357,6 +362,7 @@ const parseKeyedArchive = ( }), ); if ( + className !== null && /(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test( className, ) diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index d4608b11f2..6b534ef6e0 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCapabilityUnavailableError, AnalysisCancelledError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; @@ -19,6 +18,7 @@ import { } from "./KunaProjectFiles.js"; import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** The exact producing range for one facet, including a failed C block. */ export interface KunaProjectFileSpan { @@ -134,8 +134,7 @@ export class KunaProjectReader { const span = index.value.code.get(address); if (span === undefined) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, `Kuna's export lists no code at ${address}; import slots and data entries have no listing.`, ), diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index ac5c0bc5f8..c2a103431a 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -8,10 +8,7 @@ import type { ProviderAvailability, ProviderTargetSupport, } from "../application/AnalysisProvider.js"; -import { - AnalysisCancelledError, - AnalysisCapabilityUnavailableError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { createAnalysisProfile, type AnalysisProfileCommitment, @@ -33,6 +30,7 @@ import { import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js"; import { KunaRunSlots } from "./KunaRunSlots.js"; import { KunaSessionClient } from "./KunaSessionClient.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** Architectures REA verified Kuna on: x86-64 and AArch64 ELF and Mach-O, plus ARM Thumb. */ const VERIFIED_ARCHITECTURES: ReadonlySet = new Set([ @@ -169,14 +167,7 @@ export class KunaProvider implements AnalysisProviderCandidate { ...(options?.signal === undefined ? {} : { signal: options.signal }), }); if (!installation.ok) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - "open_binary", - installation.error.reason, - { userMessage: installation.error.reason }, - ), - ); + return err(kunaUnavailable("open_binary", installation.error.reason)); const { value } = installation; return ok({ profile: createAnalysisProfile( @@ -218,13 +209,7 @@ export class KunaProvider implements AnalysisProviderCandidate { if (!configuration.ok) return { execute: async (operation) => - err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - operation, - configuration.error.reason, - ), - ), + err(kunaUnavailable(operation, configuration.error.reason)), close: async () => ok(null), }; return new KunaSessionClient(target, profile, { diff --git a/src/kuna/KunaReferenceQueries.ts b/src/kuna/KunaReferenceQueries.ts index ffc8b45b4d..44256f705e 100644 --- a/src/kuna/KunaReferenceQueries.ts +++ b/src/kuna/KunaReferenceQueries.ts @@ -1,7 +1,6 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -21,6 +20,7 @@ import { } from "./KunaReferenceFiles.js"; import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** Charge raw JSON bytes conservatively; retained projections are smaller. */ const XREF_CACHE_BYTES = 16 * 1024 * 1024; @@ -158,8 +158,7 @@ export class KunaReferenceQueries { const node = graph.nodes.get(address); if (node === undefined) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, `Kuna's graph has no function node at ${address}; its relationships are unknown.`, ), diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 07254050a2..e8a44a7627 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -69,6 +69,7 @@ import { KunaProjectReader, type KunaProjectFileSpan, } from "./KunaProjectReader.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** How often a request waiting on the project export reports that it still waits. */ const WAIT_PROGRESS_INTERVAL_MS = 10_000; @@ -278,13 +279,7 @@ export class KunaSessionClient implements AnalysisClient { options: ExecutionOptions = {}, ): Promise> { if (this.#closing) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - operation, - "This Kuna session is closed.", - ), - ); + return err(kunaUnavailable(operation, "This Kuna session is closed.")); return this.#own( this.#execute(operation, parameters, { ...options, @@ -312,8 +307,7 @@ export class KunaSessionClient implements AnalysisClient { return err(new AnalysisCancelledError(operation)); if (operation !== "health" && !isKunaOperation(operation)) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, "Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.", ), @@ -1229,14 +1223,7 @@ export class KunaSessionClient implements AnalysisClient { this.#installation ??= inspectKunaInstallation(this.#environment).then( (inspected): Result => { if (!inspected.ok) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - "health", - inspected.error.reason, - { userMessage: inspected.error.reason }, - ), - ); + return err(kunaUnavailable("health", inspected.error.reason)); const committed = this.#profile?.parameters["kuna_sha256"]; if (committed !== undefined && committed !== inspected.value.sha256) return err( diff --git a/src/kuna/KunaUnavailable.ts b/src/kuna/KunaUnavailable.ts new file mode 100644 index 0000000000..514c7c486c --- /dev/null +++ b/src/kuna/KunaUnavailable.ts @@ -0,0 +1,18 @@ +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; +import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; + +/** + * A Kuna capability gap whose reason is what the caller needs to read. Without + * a user message the projection shows only "choose another analysis or + * target", which hides facts such as "no code at this address". + */ +export const kunaUnavailable = ( + operation: string, + reason: string, + options: AnalysisErrorOptions = {}, +): AnalysisCapabilityUnavailableError => + new AnalysisCapabilityUnavailableError(KUNA_PROVIDER_ID, operation, reason, { + ...options, + userMessage: reason, + }); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1b2954b7b6..a418c8f0bd 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -4,9 +4,10 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { buildBinary } from "plist"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js"; +import { InterfaceBuilderDecodeBudget } from "../../../src/artifacts/apple/InterfaceBuilderDecodeBudget.js"; import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; import { xcodeIbtoolAvailable } from "../../fixtures/ibtool.js"; @@ -907,6 +908,57 @@ describe("aggregate Interface Builder archive retention budget", () => { }); }); +describe("Interface Builder XML plist depth", () => { + it("bounds XML archives by plist value depth, not element depth", async () => { + const root = await createTestTempDirectory("rea-ib-depth-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // The root dict is value depth 0, so the leaf sits at `nested + 1`. The + // plist envelope and keys add XML elements but not value depth. + const document = (nested: number) => + `${"child".repeat(nested)}leaf1${"".repeat(nested)}`; + await writeFile(join(resources, "AtLimit.nib"), document(127)); + await writeFile(join(resources, "BeyondLimit.nib"), document(128)); + + const release = vi.spyOn(InterfaceBuilderDecodeBudget.prototype, "release"); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const released = release.mock.calls.map(([bytes]) => bytes); + release.mockRestore(); + + // The rejected archive returns its reservation to later documents. + expect(released).toHaveLength(1); + expect(released[0]).toBeGreaterThan(0); + expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([ + "Contents/Resources/AtLimit.nib", + ]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "aggregate_decode_budget_exhausted", + examined: 2, + omitted: 1, + }), + ); + expect(result.limitations).toContain( + "Contents/Resources/BeyondLimit.nib: omitted because aggregate decode budget exhausted.", + ); + }); + + it("restores released representation bytes up to the initial budget", () => { + const budget = new InterfaceBuilderDecodeBudget(100); + budget.reserve(60, "reserve"); + budget.release(60); + expect(budget.remainingBytes).toBe(100); + budget.release(10); + expect(budget.remainingBytes).toBe(100); + }); +}); + describe("bounded Interface Builder archive decoding", () => { it("counts malformed archives against the document limit", async () => { const root = await createTestTempDirectory("rea-ib-test-"); diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index 30aec2b7a1..426f18eb8a 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -80,3 +80,41 @@ it("reports a graph beyond REA's buffer once per session instead of rebuilding i expect(await client.close()).toEqual({ ok: true, value: null }); } }); + +it("says why Kuna cannot answer instead of a generic unavailable message", async () => { + const { open } = await session("rea-kuna-unavailable-reasons-"); + const client = open(); + try { + const cases = [ + { + operation: "procedure_assembly", + parameters: { procedure: "0x2000" }, + message: + "Kuna's export lists no code at 0x2000; import slots and data entries have no listing.", + }, + { + operation: "procedure_callers", + parameters: { procedure: "0x1004" }, + message: + "Kuna's graph has no function node at 0x1004; its relationships are unknown.", + }, + { + operation: "list_segments", + parameters: {}, + message: + "Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.", + }, + ] as const; + for (const { operation, parameters, message } of cases) { + const result = await client.execute(operation, parameters); + if (result.ok) throw new Error(`${operation} unexpectedly succeeded`); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + message, + retryable: false, + }); + } + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +});