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.
This commit is contained in:
morluto
2026-10-12 12:35:01 +08:00
parent 1c64fe3eff
commit 6fb3a345fe
18 changed files with 680 additions and 50 deletions
+6
View File
@@ -126,6 +126,12 @@ describe("parseManifestPackage", () => {
it("reports null when no package attribute exists", () => {
expect(parseManifestPackage("<manifest>\n</manifest>\n")).toBeNull();
});
it("rejects a well-formed document with the wrong root element", () => {
expect(() => parseManifestPackage("<resources/>")).toThrowError(
new TypeError("AndroidManifest.xml root must be <manifest>"),
);
});
});
describe("parseDecodedStrings", () => {
+2 -1
View File
@@ -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 <manifest>");
return root.getAttribute("package") || null;
};
+62 -11
View File
@@ -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<Record<string, unknown>> => {
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<string, unknown>;
};
@@ -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: `<manifest package="com.rea.large"></manifest>${" ".repeat(512 * 1024 + 1)}`,
},
{
name: "a retained prefix ending before the root element closes",
manifest: `<manifest package="com.rea.large">${" ".repeat(512 * 1024 + 1)}</manifest>`,
},
])("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 }),
+3 -1
View File
@@ -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"
@@ -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(() =>
@@ -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 &&
@@ -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"),
+34
View File
@@ -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({
+23 -14
View File
@@ -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. */
+57
View File
@@ -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(
+10 -12
View File
@@ -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, {
+115
View File
@@ -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",
);
});
});
+12 -5
View File
@@ -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<typeof parseOtoolLoadCommands>,
architectures: ReturnType<typeof parseLipoArchitectures>,
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,
+212
View File
@@ -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<typeof parseOtoolLoadCommands>["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<typeof parseOtoolLoadCommands>["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]
: []),
],
};
};
+38
View File
@@ -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: [] },
+3 -2
View File
@@ -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);
@@ -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(
{},
+1 -1
View File
@@ -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"