mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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(
|
||||
{},
|
||||
|
||||
Vendored
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user