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"