From 6fb3a345fe952b8e8f59221aec993acc37ccbf31 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:35:01 +0800 Subject: [PATCH] fix(analysis): preserve producer identity and partial evidence Reconcile Mach-O UUID observations without hiding malformed or conflicting sources. Require source-map identity digests to be supported by consistent observations. Keep truncated Android manifests as partial raw evidence, validate WABT section framing, and reject missing JEB inventories or error-marked success replies. Consolidate native UUID parsing and simplify capacity and malformed-input fixtures. Focused source and public workflow checks pass, including native arm64e inspection and real WABT framing probes; a successful real JEB GUI project opening remains unverified. --- src/apktool/ApktoolDecodeOutput.test.ts | 6 + src/apktool/ApktoolDecodeOutput.ts | 3 +- src/apktool/ApktoolProvider.test.ts | 73 +++++- src/apktool/ApktoolProvider.ts | 4 +- .../javascriptApplicationGraph.test.ts | 77 +++++++ .../javascript/javascriptApplicationGraph.ts | 21 ++ .../sourceToBundleComparison.test.ts | 3 +- src/jeb/JebMcpConnection.test.ts | 34 +++ src/jeb/JebMcpConnection.ts | 37 +-- src/jeb/JebProvider.test.ts | 57 +++++ src/jeb/JebProvider.ts | 22 +- src/native/NativeMachoInspection.test.ts | 115 ++++++++++ src/native/NativeMachoInspection.ts | 17 +- src/native/parsers/machoUuid.ts | 212 ++++++++++++++++++ src/wasm/WabtOutput.test.ts | 38 ++++ src/wasm/WabtOutput.ts | 5 +- .../native/nativeSelectedArchitecture.test.ts | 4 +- tests/fixtures/nativeCommands.ts | 2 +- 18 files changed, 680 insertions(+), 50 deletions(-) create mode 100644 src/native/parsers/machoUuid.ts diff --git a/src/apktool/ApktoolDecodeOutput.test.ts b/src/apktool/ApktoolDecodeOutput.test.ts index fb258b2217..6ee69ec185 100644 --- a/src/apktool/ApktoolDecodeOutput.test.ts +++ b/src/apktool/ApktoolDecodeOutput.test.ts @@ -126,6 +126,12 @@ describe("parseManifestPackage", () => { it("reports null when no package attribute exists", () => { expect(parseManifestPackage("\n\n")).toBeNull(); }); + + it("rejects a well-formed document with the wrong root element", () => { + expect(() => parseManifestPackage("")).toThrowError( + new TypeError("AndroidManifest.xml root must be "), + ); + }); }); describe("parseDecodedStrings", () => { diff --git a/src/apktool/ApktoolDecodeOutput.ts b/src/apktool/ApktoolDecodeOutput.ts index 05bb54acb2..05c5f8af16 100644 --- a/src/apktool/ApktoolDecodeOutput.ts +++ b/src/apktool/ApktoolDecodeOutput.ts @@ -59,7 +59,8 @@ export const parseApktoolYml = (yml: string): ApktoolMetadata => { export const parseManifestPackage = (manifest: string): string | null => { const document = parseXml(manifest, "AndroidManifest.xml"); const root = document.documentElement; - if (root === null || root.nodeName !== "manifest") return null; + if (root === null || root.nodeName !== "manifest") + throw new TypeError("AndroidManifest.xml root must be "); return root.getAttribute("package") || null; }; diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 3cd86b72ab..5b2a32114d 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -1,7 +1,14 @@ import { createHash } from "node:crypto"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readFile, + rm, + truncate, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; -import { expect, onTestFinished, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import { ApktoolProvider } from "./ApktoolProvider.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -69,7 +76,7 @@ const evidenceResult = async ( request: ApktoolRequest, ): Promise> => { const result = await provider.execute(request); - if (!result.ok) throw new Error(result.error.message); + if (!result.ok) throw result.error; return result.value.result as Record; }; @@ -89,21 +96,31 @@ const decodeStub = ( readonly withStrings?: boolean; readonly withGerman?: boolean; readonly failDecode?: boolean; + readonly manifestFile?: string; + readonly stringsFile?: string; readonly stringsXml?: string; } = {}, ): ((root: string) => string) => { const withStrings = options.withStrings ?? true; const withGerman = options.withGerman ?? true; const stringsXml = options.stringsXml ?? REAL_STRINGS; + const manifestWrite = + options.manifestFile === undefined + ? `printf '%s\\n' '${REAL_MANIFEST.replace(/'/gu, "'\\''")}' > "$5/AndroidManifest.xml"` + : `cp '${options.manifestFile}' "$5/AndroidManifest.xml"`; + const stringsWrite = + options.stringsFile === undefined + ? `printf '%s\\n' '${stringsXml.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"` + : `cp '${options.stringsFile}' "$5/res/values/strings.xml"`; return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi if [ "$1" = "d" ]; then ${ options.failDecode === true ? `printf 'Exception in thread "main" brut.androlib.AndrolibException: Could not decode the APK\\n' >&2; exit 1;` : `printf '%s\\n' '${REAL_YML.replace(/'/gu, "'\\''")}' > "$5/apktool.yml" - printf '%s\\n' '${REAL_MANIFEST.replace(/'/gu, "'\\''")}' > "$5/AndroidManifest.xml" + ${manifestWrite} mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""} - ${withStrings ? `printf '%s\\n' '${stringsXml.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"` : ""} + ${withStrings ? stringsWrite : ""} ${withGerman ? `printf '%s\\n' '${DE_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values-de/strings.xml"` : ""} printf 'I: Using Apktool on stub\\n' exit 0` @@ -326,18 +343,16 @@ it("uses an owned APK snapshot and framework cache and resolves regional locales it("marks an oversized strings document partial without claiming it is missing", async () => { const { command, root } = await writeApktoolStub((root) => - decodeStub()(root).replace( - " printf 'I: Using Apktool on stub", - ' dd if=/dev/zero of="$5/res/values/strings.xml" bs=1048576 count=9 2>/dev/null\n printf \'I: Using Apktool on stub', - ), + decodeStub({ stringsFile: join(root, "large-strings.xml") })(root), ); + await writeFile(join(root, "large-strings.xml"), ""); + await truncate(join(root, "large-strings.xml"), 9 * 1024 * 1024); const apk = await apkFixture(root); const result = await providerOver(command).execute({ operation: "decode_android_resources", input: { path: apk.path, include_strings: true }, }); - expect(result.ok).toBe(true); - if (!result.ok) return; + if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ coverage: "partial", strings: [], @@ -346,6 +361,42 @@ it("marks an oversized strings document partial without claiming it is missing", expect(result.value.limitations.join(" ")).not.toContain("No default"); }); +describe("truncated manifest prefixes", () => { + it.each([ + { + name: "a retained prefix ending after the root element", + manifest: `${" ".repeat(512 * 1024 + 1)}`, + }, + { + name: "a retained prefix ending before the root element closes", + manifest: `${" ".repeat(512 * 1024 + 1)}`, + }, + ])("retains $name without parsing its package", async ({ manifest }) => { + const { command, root } = await writeApktoolStub((root) => + decodeStub({ manifestFile: join(root, "large-AndroidManifest.xml") })( + root, + ), + ); + await writeFile(join(root, "large-AndroidManifest.xml"), manifest); + const apk = await apkFixture(root); + + const result = await providerOver(command).execute({ + operation: "decode_android_resources", + input: { path: apk.path, include_strings: false }, + }); + if (!result.ok) throw result.error; + + expect(result.value.result).toMatchObject({ + coverage: "partial", + manifest: manifest.slice(0, 512 * 1024), + metadata: { package_name: null, version_name: "1.2.3" }, + }); + expect(result.value.limitations.join(" ")).toContain( + "manifest is truncated", + ); + }); +}); + it("retains failed workspace cleanup and retries it on close", async () => { const { command, root } = await writeApktoolStub( decodeStub({ failDecode: true }), diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index eaf21c3e63..682e2f2ea5 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -649,7 +649,9 @@ export class ApktoolProvider { ); const manifest = manifestRead.text; const packageName = - manifest === null ? null : parseManifestPackage(manifest); + manifest === null || manifestRead.truncated + ? null + : parseManifestPackage(manifest); const stringsDirectory = input.locale === undefined ? "values" diff --git a/src/domain/javascript/javascriptApplicationGraph.test.ts b/src/domain/javascript/javascriptApplicationGraph.test.ts index 58f1279284..5e396a5452 100644 --- a/src/domain/javascript/javascriptApplicationGraph.test.ts +++ b/src/domain/javascript/javascriptApplicationGraph.test.ts @@ -513,7 +513,84 @@ describe("JavaScript Application Graph: source maps and observations", () => { ), ).toThrow(/Source-map observation/u); }); +}); +describe("JavaScript Application Graph: source-map digest consistency", () => { + it("rejects source-map observations that contradict a known original digest", () => { + const sourceMapNode = ( + identityDigest: string | null, + observedDigest: unknown, + contentAvailable?: boolean, + ) => + createJavaScriptApplicationNode({ + kind: "source-module", + identity: { + strategy: "source-map-original", + source_root: null, + stability: "source-map-exact", + source_map_sha256: APPLICATION_GRAPH_DIGESTS.asar, + original_source: "src/editor.ts", + source_sha256: identityDigest, + }, + observations: [ + { + label: null, + source_map_reference: resolveJavaScriptSourceMapReference( + "src/editor.ts", + null, + "dist/app.js.map", + ), + properties: { + ...(contentAvailable === undefined + ? {} + : { content_available: contentAvailable }), + ...(observedDigest === undefined + ? {} + : { content_sha256: observedDigest }), + }, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/app.js.map", + "ast-static-analysis", + ), + }, + ], + }); + + expect(() => + graphForNode(sourceMapNode("2".repeat(64), "3".repeat(64))), + ).toThrow(/Source-map observation/u); + expect(() => graphForNode(sourceMapNode(null, "3".repeat(64)))).toThrow( + /Source-map observation/u, + ); + expect(() => graphForNode(sourceMapNode(null, 7))).toThrow( + /Source-map observation/u, + ); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), "3".repeat(64))), + ).not.toThrow(); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), "3".repeat(64), false)), + ).toThrow(/Source-map observation/u); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), null, false)), + ).toThrow(/Source-map observation/u); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), null, true)), + ).toThrow(/Source-map observation/u); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), undefined)), + ).toThrow(/Source-map observation/u); + expect(() => graphForNode(sourceMapNode(null, null, true))).toThrow( + /Source-map observation/u, + ); + expect(() => + graphForNode(sourceMapNode("3".repeat(64), "3".repeat(64), undefined)), + ).not.toThrow(); + }); +}); + +describe("JavaScript Application Graph: commitments and schemas", () => { it("rejects unknown fields and stale commitments", () => { const graph = buildSyntheticJavaScriptApplicationGraph(); expect(() => diff --git a/src/domain/javascript/javascriptApplicationGraph.ts b/src/domain/javascript/javascriptApplicationGraph.ts index f4b53503b5..22504a12b8 100644 --- a/src/domain/javascript/javascriptApplicationGraph.ts +++ b/src/domain/javascript/javascriptApplicationGraph.ts @@ -172,7 +172,28 @@ const sourceMapObservationMatchesIdentity = ( observation: ApplicationNode["observations"][number], ): boolean => { const { evidence, source_map_reference: reference } = observation; + const sourceDigests = [ + observation.properties.content_sha256, + // Older graph producers used `source_sha256` for the same source-map fact. + observation.properties.source_sha256, + ]; + const hasKnownSourceDigest = identity.source_sha256 !== null; + const sourceDigestMatches = + sourceDigests.every( + (digest) => + digest === undefined || + digest === null || + digest === identity.source_sha256, + ) && + (!hasKnownSourceDigest || sourceDigests.includes(identity.source_sha256)); + const contentAvailabilityMatches = + (observation.properties.content_available !== true || + hasKnownSourceDigest) && + (observation.properties.content_available !== false || + !hasKnownSourceDigest); return ( + sourceDigestMatches && + contentAvailabilityMatches && reference !== null && evidence.state === "observed" && evidence.artifact.available && diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts index f6179d9013..f488ee598d 100644 --- a/src/domain/javascript/sourceToBundleComparison.test.ts +++ b/src/domain/javascript/sourceToBundleComparison.test.ts @@ -159,7 +159,8 @@ describe("source to bundle digest changes", () => { "dist/main.js.map", ), properties: { - source_sha256: HASH.unchanged, + content_available: true, + content_sha256: HASH.unchanged, source: "src/removed.ts", }, evidence: artifactEvidence(HASH.artifact, "dist/main.js.map"), diff --git a/src/jeb/JebMcpConnection.test.ts b/src/jeb/JebMcpConnection.test.ts index 23e3c9778f..284f04f07d 100644 --- a/src/jeb/JebMcpConnection.test.ts +++ b/src/jeb/JebMcpConnection.test.ts @@ -23,6 +23,40 @@ describe("decodeJebToolResult", () => { }); }); + it.each([ + [ + "structured content", + { + isError: true, + structuredContent: { + success: true, + input_files: [], + units: [], + }, + }, + ], + [ + "JSON text", + { + isError: true, + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + ] as const)("rejects error-marked success in %s", (_source, result) => { + expect(() => decodeJebToolResult(result)).toThrow(AnalysisProtocolError); + }); + + it("preserves an explicit JEB failure envelope returned as JSON text", () => { + expect( + decodeJebToolResult({ + isError: true, + content: [ + { type: "text", text: '{"success":false,"message":"missing"}' }, + ], + }), + ).toEqual({ success: false, message: "missing" }); + }); + it("decodes a single JSON text block", () => { expect( decodeJebToolResult({ diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index ece06007b0..137d0f32d7 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -38,26 +38,35 @@ export const decodeJebToolResult = (input: unknown): JsonValue => { const blocks = result.content.flatMap((block) => block.type === "text" && block.text !== undefined ? [block.text] : [], ); + let decoded: JsonValue; if (result.structuredContent !== undefined) { - // isError envelopes carry JEB's {success:false, message} refusal semantics; - // decode them so the provider can preserve the engine's own reason. - return jsonValueSchema.parse(result.structuredContent); - } - if (blocks.length !== 1) - throw new AnalysisProtocolError( - "JEB MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.", - ); - try { - return jsonValueSchema.parse(JSON.parse(blocks[0] ?? "null")); - } catch { - if (result.isError === true) + decoded = jsonValueSchema.parse(result.structuredContent); + } else { + if (blocks.length !== 1) throw new AnalysisProtocolError( - `JEB MCP tool failed: ${blocks[0] ?? "unexplained engine failure"}`, + "JEB MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.", ); - return blocks[0] ?? null; + const text = blocks[0] ?? "null"; + try { + decoded = jsonValueSchema.parse(JSON.parse(text)); + } catch { + decoded = text; + } } + if (result.isError === true && !isJebFailureEnvelope(decoded)) + throw new AnalysisProtocolError( + `JEB MCP tool failed: ${blocks[0] ?? "error-marked result without a JEB failure envelope"}`, + { cause: decoded }, + ); + return decoded; }; +const isJebFailureEnvelope = (value: JsonValue): boolean => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value.success === false; + export type JebMcpConnectionFactory = (endpoint: URL) => JebMcpConnection; /** Serve JEB requests from a running engine client over streamable HTTP. */ diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 677b7ccb66..c6bd8c6ec2 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -165,6 +165,63 @@ describe("JebProvider boundary", () => { }); }); +describe("JebProvider open project inventories", () => { + it.each(["input_files", "units"] as const)( + "rejects an omitted %s inventory from a successful project response", + async (omitted) => { + const project = { + success: true, + name: "empty-project", + creation_datetime: "2026-10-12T00:00:00", + input_files: [], + units: [], + }; + const response = { ...project }; + delete response[omitted]; + const provider = providerWith( + scriptedConnection({ + get_client_information: { success: true, version: "5.48.0" }, + open_project: response, + }), + ); + + const result = await provider.execute({ + operation: "open_jeb_project", + input: { path: "probe.apk" }, + }); + + if (result.ok) throw new Error("Expected an omitted inventory failure"); + expect(result.error).toBeInstanceOf(AnalysisProtocolError); + }, + ); + + it("preserves explicitly empty project inventories", async () => { + const provider = providerWith( + scriptedConnection({ + get_client_information: { success: true, version: "5.48.0" }, + open_project: { + success: true, + name: "empty-project", + creation_datetime: "2026-10-12T00:00:00", + input_files: [], + units: [], + }, + }), + ); + + const result = await provider.execute({ + operation: "open_jeb_project", + input: { path: "probe.apk" }, + }); + + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + input_files: [], + units: [], + }); + }); +}); + describe("JebProvider target and endpoint selection", () => { it("keeps the implicitly selected unit unknown when unit_path is omitted", async () => { const provider = providerWith( diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 9d9797d3a3..bf62598a5d 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -69,16 +69,14 @@ const openProject = z success: z.literal(true), name: z.string().nullish(), creation_datetime: z.string().nullish(), - input_files: z - .array( - z.object({ - file_name: z.string(), - file_size: z.number().int().nonnegative(), - contents_sha256_hash: z.string(), - }), - ) - .optional(), - units: z.array(unitEntry).optional(), + input_files: z.array( + z.object({ + file_name: z.string(), + file_size: z.number().int().nonnegative(), + contents_sha256_hash: z.string(), + }), + ), + units: z.array(unitEntry), }) .passthrough(); const listUnits = z @@ -292,8 +290,8 @@ export class JebProvider implements JebAnalysisPort { engine, project_name: opened.name ?? null, creation_datetime: opened.creation_datetime ?? null, - input_files: opened.input_files ?? [], - units: opened.units ?? [], + input_files: opened.input_files, + units: opened.units, }); return ok( createAnalysisExecution(result, this.#identity, { diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 939f610995..4ed533c2e8 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -4,6 +4,11 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; import { inspectNativeMacho } from "./NativeMachoInspection.js"; +import { + NativeFixtureRunner, + nativeMachoTarget, + withOtoolMachHeader, +} from "../../tests/fixtures/nativeCommands.js"; const capture = (tool: string, stdout: string): NativeCommandCapture => ({ tool, @@ -71,3 +76,113 @@ describe("Mach-O tool architecture selection", () => { ]); }); }); + +describe("Mach-O UUID reconciliation", () => { + const validUuid = "01234567-89AB-CDEF-0123-456789ABCDEF"; + + const runInspection = async ( + dwarfdump: string, + otoolUuids: readonly string[] = [validUuid], + targetPath = "/fixture/app", + ) => { + const runner = new NativeFixtureRunner({ + dwarfdump, + otool: withOtoolMachHeader( + otoolUuids + .map( + (uuid, index) => + `Load command ${index}\n cmd LC_UUID\n cmdsize 24\n uuid ${uuid}`, + ) + .join("\n"), + ), + }); + const result = await inspectNativeMacho({ + target: nativeMachoTarget(targetPath), + run: (tool, args) => runner.run(tool, args), + invocation: (capture) => ({ + tool: capture.tool, + command: [capture.executable, ...capture.arguments], + tool_version: capture.toolVersion, + version_reason: capture.versionReason, + executable_sha256: capture.executableSha256, + exit: { code: capture.exitCode, signal: capture.signal }, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + }), + }); + return result; + }; + + it("keeps a valid otool UUID when dwarfdump is malformed and reports the limitation", async () => { + const result = await runInspection("UUID: A (arm64) /fixture/app\n"); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: validUuid }); + expect(result.value.limitations).toContain( + 'dwarfdump --uuid returned malformed UUID "A"; using the unambiguous otool LC_UUID.', + ); + }); + + it("does not choose between conflicting valid UUIDs", async () => { + const result = await runInspection( + `UUID: FEDCBA98-7654-3210-FEDC-BA9876543210 (arm64) /fixture/app\n`, + ); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: null }); + expect(result.value.limitations).toContain( + `dwarfdump --uuid (FEDCBA98-7654-3210-FEDC-BA9876543210) conflicts with otool LC_UUID (${validUuid}); the image UUID is unknown.`, + ); + }); + + it("accepts matching UUIDs with different case and preserves dwarfdump spelling", async () => { + const lower = validUuid.toLowerCase(); + const result = await runInspection(`UUID: ${lower} (arm64) /fixture/app\n`); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: lower }); + }); + + it("uses a valid dwarfdump UUID when otool has no UUID observation", async () => { + const result = await runInspection( + `UUID: ${validUuid} (arm64) /fixture/app\n`, + [], + ); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: validUuid }); + }); + + it("frames a path containing a newline and UUID-like text as one dwarfdump row", async () => { + const path = + "/fixture/\nUUID: FEDCBA98-7654-3210-FEDC-BA9876543210 (arm64) fake"; + const result = await runInspection( + `UUID: ${validUuid} (arm64) ${path}\n`, + [validUuid], + path, + ); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: validUuid }); + }); + + it("keeps a conflict between duplicate valid LC_UUID commands unknown despite dwarfdump", async () => { + const result = await runInspection( + `UUID: ${validUuid} (arm64) /fixture/app\n`, + [validUuid, "FEDCBA98-7654-3210-FEDC-BA9876543210"], + "/fixture/app", + ); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: null }); + expect(result.value.limitations).toContain( + `otool returned conflicting LC_UUID values ${JSON.stringify(validUuid)} and ${JSON.stringify("FEDCBA98-7654-3210-FEDC-BA9876543210")}`, + ); + }); + + it("keeps conflicting duplicate dwarfdump rows unknown despite otool", async () => { + const conflictingUuid = "FEDCBA98-7654-3210-FEDC-BA9876543210"; + const result = await runInspection( + `UUID: ${validUuid} (arm64) /fixture/app\nUUID: ${conflictingUuid} (arm64) /fixture/app\n`, + ); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ uuid: null }); + expect(result.value.limitations).toContain( + "dwarfdump --uuid returned conflicting UUIDs for selected architecture arm64", + ); + }); +}); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 9980fc241b..2ec6cd4a93 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -13,6 +13,7 @@ import { parseDyldSymbols } from "./parsers/dyldInfo.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; import { parseOtoolLoadCommands } from "./parsers/otool.js"; +import { reconcileMachoUuid } from "./parsers/machoUuid.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; interface NativeMachoObservation { @@ -50,7 +51,6 @@ const REQUIRED_SLICE_COMMANDS = [ const OPTIONAL_VTOOL_COMMAND = ["vtool", ["-show-build"]] as const; const VTOOL_UNAVAILABLE_LIMITATION = "vtool is unavailable; build metadata is normalized from otool only."; - /** Inspect one Mach-O with the required native commands and normalized output. */ export const inspectNativeMacho = async ( context: NativeMachoInspectionContext, @@ -148,6 +148,7 @@ export const inspectNativeMacho = async ( limitations, load, architectures, + context.target.path, { name: selectedArchitectureName, container_file_offset: containerOffset ?? null, @@ -178,6 +179,7 @@ const normalizeMacho = ( additionalLimitations: readonly string[], load: ReturnType, architectures: ReturnType, + artifactPath: string, selectedArchitecture: { readonly name: string | null; readonly container_file_offset: number | null; @@ -200,9 +202,13 @@ const normalizeMacho = ( new Set(nmExports.map(({ name }) => name)), ); const exports = uniqueSymbols([...dyldExports, ...nmExports]); - const uuid = - /UUID:\s*([A-Fa-f0-9-]+)/u.exec(byTool("dwarfdump").stdout)?.[1] ?? - load.uuid; + const dwarfdump = byTool("dwarfdump"); + const uuid = reconcileMachoUuid( + load.commands, + dwarfdump.stdout, + artifactPath, + selectedArchitecture.name, + ); const provenance = captures.map(toInvocation); // A thread-state command carries the entrypoint in architecture-specific // register state, so an absent decoded offset means unknown, not absent. @@ -222,6 +228,7 @@ const normalizeMacho = ( ] : []), ...additionalLimitations, + ...uuid.limitations, ...(threadEntrypoint ? [threadEntrypointLimitation] : []), ...(imageBase === null && /^\s*offset\s+symbol\s*$/mu.test(sliceOutput(byTool("dyld_info", 1))) @@ -237,7 +244,7 @@ const normalizeMacho = ( word_size: parseWordSize(byTool("file").stdout), file_type: load.fileType, flags: load.flags, - uuid: uuid ?? null, + uuid: uuid.value, entrypoints: covered( load.entrypoints, !threadEntrypoint, diff --git a/src/native/parsers/machoUuid.ts b/src/native/parsers/machoUuid.ts new file mode 100644 index 0000000000..a555d119d8 --- /dev/null +++ b/src/native/parsers/machoUuid.ts @@ -0,0 +1,212 @@ +import type { parseOtoolLoadCommands } from "./otool.js"; + +const MACHO_UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu; + +type UuidObservation = + | { readonly kind: "absent" } + | { readonly kind: "valid"; readonly value: string } + | { readonly kind: "invalid" | "conflict"; readonly reason: string }; + +/** Reconcile the selected slice's UUID observations from otool and dwarfdump. */ +export const reconcileMachoUuid = ( + commands: ReturnType["commands"], + dwarfdumpOutput: string, + artifactPath: string, + selectedArchitecture: string | null, +): { + readonly value: string | null; + readonly limitations: readonly string[]; +} => + reconcileObservations( + loadCommandUuid(commands), + parseDwarfdumpUuid(dwarfdumpOutput, artifactPath, selectedArchitecture), + ); + +const parseDwarfdumpUuid = ( + output: string, + artifactPath: string, + selectedArchitecture: string | null, +): UuidObservation => { + const parsed = parseDwarfdumpRows(output, artifactPath); + return parsed.kind === "invalid" + ? parsed + : selectDwarfdumpRow(parsed.rows, selectedArchitecture); +}; + +interface DwarfdumpUuidRow { + readonly value: string; + readonly architecture: string; +} + +type DwarfdumpRows = + | { readonly kind: "rows"; readonly rows: readonly DwarfdumpUuidRow[] } + | { readonly kind: "invalid"; readonly reason: string }; + +const parseDwarfdumpRows = ( + output: string, + artifactPath: string, +): DwarfdumpRows => { + if (output.trim().length === 0) return { kind: "rows", rows: [] }; + + // Frame the documented row header, then consume the exact operand path. + // Paths may contain newlines and text that resembles another UUID row. + const header = /UUID:[ \t]*([^\s]+)[ \t]+\(([^)\r\n]+)\) /uy; + const rows: DwarfdumpUuidRow[] = []; + let offset = 0; + while (offset < output.length) { + if (output.startsWith("\r\n", offset)) { + offset += 2; + continue; + } + if (output[offset] === "\n") { + offset += 1; + continue; + } + header.lastIndex = offset; + const match = header.exec(output); + if (match === null) + return { + kind: "invalid", + reason: "dwarfdump --uuid output contains an unrecognized row", + }; + const value = match[1] ?? ""; + const architecture = match[2] ?? ""; + offset = header.lastIndex; + if (!output.startsWith(artifactPath, offset)) + return { + kind: "invalid", + reason: `dwarfdump --uuid row does not match the selected artifact path (UUID ${JSON.stringify(value)})`, + }; + offset += artifactPath.length; + if (offset < output.length && output.startsWith("\r\n", offset)) + offset += 2; + else if (output[offset] === "\n") offset += 1; + else if (offset !== output.length) + return { + kind: "invalid", + reason: `dwarfdump --uuid returned malformed UUID row ${JSON.stringify(value)}`, + }; + rows.push({ value, architecture }); + } + + return { kind: "rows", rows }; +}; + +const selectDwarfdumpRow = ( + rows: readonly DwarfdumpUuidRow[], + selectedArchitecture: string | null, +): UuidObservation => { + if (rows.length === 0) return { kind: "absent" }; + const selectedRows = + selectedArchitecture === null + ? rows + : rows.filter( + ({ architecture }) => architecture === selectedArchitecture, + ); + const validRows = selectedRows.filter(({ value }) => MACHO_UUID.test(value)); + const distinctValid = new Set( + validRows.map(({ value }) => value.toLowerCase()), + ); + if (distinctValid.size > 1) + return { + kind: "conflict", + reason: `dwarfdump --uuid returned conflicting UUIDs for selected architecture ${selectedArchitecture ?? "unknown"}`, + }; + if (selectedRows.length !== 1) + return { + kind: "invalid", + reason: + selectedRows.length > 1 + ? `dwarfdump --uuid returned multiple rows for selected architecture ${selectedArchitecture}` + : `dwarfdump --uuid returned no row for selected architecture ${selectedArchitecture}`, + }; + const [row] = selectedRows; + if (row === undefined) return { kind: "absent" }; + if (!MACHO_UUID.test(row.value)) + return { + kind: "invalid", + reason: `dwarfdump --uuid returned malformed UUID ${JSON.stringify(row.value)}`, + }; + return { kind: "valid", value: row.value }; +}; + +const loadCommandUuid = ( + commands: ReturnType["commands"], +): UuidObservation => { + const loadCommands = commands.filter(({ kind }) => kind === "LC_UUID"); + if (loadCommands.length === 0) return { kind: "absent" }; + if (loadCommands.length > 1) { + const observed = loadCommands + .map(({ fields }) => fields.uuid) + .filter( + (value): value is string => + typeof value === "string" && MACHO_UUID.test(value), + ); + const distinct = new Set(observed.map((value) => value.toLowerCase())); + return { + kind: distinct.size > 1 ? "conflict" : "invalid", + reason: + distinct.size > 1 + ? `otool returned conflicting LC_UUID values ${observed.map((value) => JSON.stringify(value)).join(" and ")}` + : `otool returned ${String(loadCommands.length)} LC_UUID commands`, + }; + } + const value = loadCommands[0]?.fields.uuid; + return typeof value === "string" && MACHO_UUID.test(value) + ? { kind: "valid", value } + : { + kind: "invalid", + reason: `otool LC_UUID contains malformed UUID ${JSON.stringify(value ?? null)}`, + }; +}; + +const reconcileObservations = ( + loadUuid: UuidObservation, + dwarfdump: UuidObservation, +): { + readonly value: string | null; + readonly limitations: readonly string[]; +} => { + if (dwarfdump.kind === "valid" && loadUuid.kind === "valid") { + if (dwarfdump.value.toLowerCase() === loadUuid.value.toLowerCase()) + return { value: dwarfdump.value, limitations: [] }; + return { + value: null, + limitations: [ + `dwarfdump --uuid (${dwarfdump.value}) conflicts with otool LC_UUID (${loadUuid.value}); the image UUID is unknown.`, + ], + }; + } + if (dwarfdump.kind === "valid" && loadUuid.kind === "conflict") + return { value: null, limitations: [loadUuid.reason] }; + if (loadUuid.kind === "valid" && dwarfdump.kind === "conflict") + return { value: null, limitations: [dwarfdump.reason] }; + if (dwarfdump.kind === "valid") + return { + value: dwarfdump.value, + limitations: + loadUuid.kind === "invalid" + ? [`${loadUuid.reason}; using the unambiguous dwarfdump UUID.`] + : [], + }; + if (loadUuid.kind === "valid") + return { + value: loadUuid.value, + limitations: + dwarfdump.kind === "invalid" + ? [`${dwarfdump.reason}; using the unambiguous otool LC_UUID.`] + : [], + }; + + return { + value: null, + limitations: [ + ...(dwarfdump.kind === "invalid" || dwarfdump.kind === "conflict" + ? [dwarfdump.reason] + : []), + ...(loadUuid.kind === "invalid" || loadUuid.kind === "conflict" + ? [loadUuid.reason] + : []), + ], + }; +}; diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index 655dd5dab2..8c58fc4751 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -138,6 +138,44 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad ), ).toThrow(/header/u); }); +it("bounds section header bytes while accepting a five-byte size LEB", () => { + const paddedCustomDump = [ + "", + "module.wasm:\tfile format wasm 0x1", + "", + "Sections:", + "", + ' Custom start=0x0000000e end=0x0000000f (size=0x00000001) ""', + "", + "Section Details:", + "", + "Custom:", + ' - name: ""', + "", + ].join("\n"); + expect(parseWabtOutput(paddedCustomDump, 15, [""]).sections).toMatchObject([ + { kind: "Custom", start: 14, end: 15, bytes: 1 }, + ]); + + for (const gap of [0, 1, 7]) { + const start = 11 + gap; + const malformed = [ + "", + "module.wasm:\tfile format wasm 0x1", + "", + "Sections:", + "", + " Type start=0x0000000a end=0x0000000b (size=0x00000001) count: 0", + ` Custom start=0x${start.toString(16).padStart(8, "0")} end=0x${(start + 1).toString(16).padStart(8, "0")} (size=0x00000001) "\"`, + "", + "Section Details:", + "", + ].join("\n"); + expect(() => parseWabtOutput(malformed, start + 1, [""])).toThrow( + /ranges/u, + ); + } +}); it.each([ { displayed: [], decoded: ["named"] }, { displayed: ["named"], decoded: [] }, diff --git a/src/wasm/WabtOutput.ts b/src/wasm/WabtOutput.ts index 23883510de..db5dd610a4 100644 --- a/src/wasm/WabtOutput.ts +++ b/src/wasm/WabtOutput.ts @@ -49,11 +49,12 @@ export const parseWabtOutput = ( bytes: Number.parseInt(match[4] ?? "", 16), description, }; + const sectionHeaderBytes = section.start - (sections.at(-1)?.end ?? 8); if ( section.end > artifactBytes || section.end - section.start !== section.bytes || - section.start < 8 || - section.start < (sections.at(-1)?.end ?? 8) + sectionHeaderBytes < 2 || + sectionHeaderBytes > 6 ) throw new Error("WABT section ranges do not match selected artifact."); sections.push(section); diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index 0fd9e6f171..006b130033 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -15,7 +15,7 @@ it("does not attribute the first universal slice's UUID and segments to the sele const universal = new NativeFixtureRunner({ "otool:-h": `${singleSlice}\n${singleSlice}`, dwarfdump: - "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (x86_64) fixture\nUUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n", + "UUID: AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA (x86_64) /owned/universal\nUUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) /owned/universal\n", }); const client = new NativeMacOSProvider( {}, @@ -73,7 +73,7 @@ it.each([ .join("\n"); const fixtures = new NativeFixtureRunner({ lipo, - dwarfdump: `UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (${dyldArchitecture}) fixture\n`, + dwarfdump: `UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (${dyldArchitecture}) /owned/universal\n`, }); const client = new NativeMacOSProvider( {}, diff --git a/tests/fixtures/nativeCommands.ts b/tests/fixtures/nativeCommands.ts index 5945d79adf..34af395f93 100644 --- a/tests/fixtures/nativeCommands.ts +++ b/tests/fixtures/nativeCommands.ts @@ -38,7 +38,7 @@ const outputFor = async ( arguments_[0] === "-imports" ? "dyld-imports.txt" : "dyld-exports.txt", ); if (tool === "dwarfdump") - return "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n"; + return `UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) ${arguments_.at(-1)}\n`; if (tool === "file") return arguments_.at(-1)?.endsWith(".plist") === true ? "XML 1.0 document text\n"