Files
reverse-engineering-anything/tests/boundary/filesystem/keyedArchiveSelection.test.ts
T
김준호 066a23475c fix(artifacts): classify bundle executables by their own name (#7365)
* fix(artifacts): classify bundle executables by their own name

A directory bundle is selected for its executable, so the executable's
canonical name classifies it; the bundle directory spelling is not an
artifact alias. Bundle facts now reach artifact, archive and database
targets so the admitted-source digest binds to the executable.

Fixes #7362

* fix(artifacts): share the artifact identity across every artifact route

The trailing-archive fallback also carries the bundle facts now, so a
bundle whose executable is a script stub with an appended ZIP keeps its
digest bound to the executable.

* fix(artifacts): name the classification spelling at the resolver boundary

The resolver already knows whether a bundle directory or a file was
selected, so it hands the executable or the caller's spelling to the
reader instead of inferring it from the Info.plist. Every admitted route
now shares one identity, and the .app bundle operations accept a bundle
whatever its executable admitted as.

* fix(artifacts): route bundle keyed archive reads by bundle facts

A bundle whose executable is a plist now carries bundleInfoPlist, so
inspect_keyed_archive must read bundle-relative archives instead of the
standalone route that compares the selected bundle directory with the
executable.

* refactor(artifacts): build the selected ESP flash identity from the shared owner

The selected ESP flash route from main built path, source path and digest
inline. Use the resolver's shared identity so it carries the same facts as
every other admitted target.
2026-11-04 19:42:04 +08:00

453 lines
15 KiB
TypeScript

import { createHash } from "node:crypto";
import {
chmod,
mkdir,
readdir,
readFile,
truncate,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { expect, it } from "vitest";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const archive = build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "value"],
$top: { root: { CF$UID: 1 }, other: { CF$UID: 1 } },
});
const bundle = async () => {
const root = await createTestTempDirectory("rea-keyed-selection-");
await mkdir(join(root, "Contents", "Resources"), { recursive: true });
await writeFile(
join(root, "Contents", "Resources", "Model.plist"),
Buffer.from(archive),
);
return root;
};
const inspect = (bundlePath: string, parameters: Record<string, unknown>) =>
inspectBundleKeyedArchive({
bundlePath,
targetSha256: "a".repeat(64),
parameters,
platform: "darwin",
});
it.each([
[".", "invalid_format", "relative to the active app bundle"],
["Contents/../Model.plist", "invalid_format", "canonical relative"],
["Contents//Model.plist", "invalid_format", "canonical relative"],
["Contents/Resources", "invalid_value", "selects a directory"],
["Contents/Resources/Missing.plist", "invalid_value", "No regular file"],
] as const)(
"reports caller-selected archive path %j as invalid input",
async (path, reason, message) => {
const root = await bundle();
const rejected = await inspect(root, { path }).catch(
(cause: unknown) => cause,
);
expect(rejected).toBeInstanceOf(AnalysisInputError);
expect(rejected).toMatchObject({
issues: [
{
path: ["path"],
reason,
message: expect.stringContaining(message),
},
],
});
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"rejects a selected unreadable directory before descending into it",
async () => {
const root = await bundle();
const selected = join(root, "Contents", "Resources");
await chmod(selected, 0);
try {
await expect(readdir(selected)).rejects.toMatchObject({ code: "EACCES" });
await expect(
inspect(root, { path: "Contents/Resources" }),
).rejects.toMatchObject({
issues: [
{
path: ["path"],
reason: "invalid_value",
message: expect.stringContaining("selects a directory"),
},
],
});
} finally {
await chmod(selected, 0o700);
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"inspects an active standalone archive without opening unrelated directories",
async () => {
const root = await createTestTempDirectory("rea-keyed-standalone-scope-");
const path = join(root, "Model.plist");
const bytes = Buffer.from(archive);
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
const blocked = join(root, "unrelated");
await mkdir(blocked);
await chmod(blocked, 0);
try {
await expect(readdir(blocked)).rejects.toMatchObject({ code: "EACCES" });
const result = await new ArtifactProvider(process.env, "darwin")
.createClient({ path, sha256, kind: "artifact", format: "plist" })
.execute("inspect_keyed_archive", {});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
archive_path: "Model.plist",
archive_sha256: sha256,
});
} finally {
await chmod(blocked, 0o700);
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports the selected oversized archive limit despite an unreadable sibling",
async () => {
const root = await createTestTempDirectory("rea-keyed-size-scope-");
const path = join(root, "Large.plist");
await writeFile(path, archive);
await truncate(path, 64 * 1024 * 1024 + 1);
const blocked = join(root, "unrelated");
await mkdir(blocked);
await chmod(blocked, 0);
try {
await expect(readdir(blocked)).rejects.toMatchObject({ code: "EACCES" });
await expect(
inspect(root, { path: "Large.plist" }),
).rejects.toMatchObject({
reason: "limit",
message: "Keyed archive exceeds 64 MiB",
});
} finally {
await chmod(blocked, 0o700);
}
},
);
it("reports an absent named root with the archive's available roots", async () => {
const root = await bundle();
await expect(
inspect(root, { path: "Contents/Resources/Model.plist", root: "missing" }),
).rejects.toMatchObject({
issues: [
{
path: ["root"],
reason: "invalid_value",
message: expect.stringContaining("missing"),
expected: ["root", "other"],
},
],
});
});
it.each(["UID", "CF$UID"])(
"keeps a sole scalar %s root free of false references through the file reader",
async (rootName) => {
const root = await bundle();
await writeFile(
join(root, "Contents", "Resources", "Scalar.plist"),
Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null"],
$top: { [rootName]: 1 },
}),
),
);
const result = await inspect(root, {
path: "Contents/Resources/Scalar.plist",
});
expect(result.roots).toEqual({ [rootName]: 1 });
expect(result.references).toEqual([]);
expect(result.total_references).toBe(0);
},
);
it.each([
[
"plain plist",
Buffer.from(build({ plain: true })),
"it has no $archiver key",
"inspect_plist",
],
[
"plist with another archiver",
Buffer.from(build({ $archiver: "NSArchiver", $objects: [] })),
'its $archiver is "NSArchiver"',
"inspect_plist",
],
[
"compiled NIBArchive",
Buffer.concat([Buffer.from("NIBArchive"), Buffer.alloc(40)]),
"compiled NIBArchive",
"decode_interface_builder",
],
])(
"reports a selected %s as an unsupported target with the workflow that reads it",
async (_label, bytes, reason, workflow) => {
const root = await bundle();
await writeFile(join(root, "Contents", "Resources", "Other.nib"), bytes);
const rejected = await inspect(root, {
path: "Contents/Resources/Other.nib",
}).catch((cause: unknown) => cause);
expect(rejected).toBeInstanceOf(AnalysisUnsupportedTargetError);
expect(projectAnalysisError(asAnalysisError(rejected))).toMatchObject({
code: "unsupported_target",
message: expect.stringContaining(reason),
remediation: { action: expect.stringContaining(workflow) },
details: {
operation: "inspect_keyed_archive",
path: join(root, "Contents", "Resources", "Other.nib"),
},
});
},
);
it.each([
["darwin", "Inspect an ordinary property list with inspect_plist"],
[
"linux",
"inspect_plist, which reads ordinary property lists, requires a macOS host",
],
] as const)(
"names the property-list workflow available on a %s host",
async (platform, action) => {
const root = await createTestTempDirectory("rea-keyed-kind-");
const path = join(root, "Defaults.plist");
const bytes = Buffer.from(build({ plain: true }));
await writeFile(path, bytes);
const result = await new ArtifactProvider(process.env, platform)
.createClient({
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
kind: "artifact",
format: "plist",
})
.execute("inspect_keyed_archive", {});
if (result.ok) throw new Error("Expected an unsupported target");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
remediation: { action: expect.stringContaining(action) },
details: { path },
});
},
);
it("keeps a damaged NSKeyedArchiver archive a format failure", async () => {
const root = await bundle();
await writeFile(
join(root, "Contents", "Resources", "Damaged.plist"),
Buffer.from(build({ $archiver: "NSKeyedArchiver", $top: {} })),
);
const rejected = await inspect(root, {
path: "Contents/Resources/Damaged.plist",
}).catch((cause: unknown) => cause);
expect(rejected).toBeInstanceOf(ArtifactReaderFailure);
expect(rejected).toMatchObject({
reason: "format",
message: expect.stringContaining("$objects array"),
});
});
it("selects a decomposed bundle path by its normalized spelling and reports the raw identity", async () => {
const root = await bundle();
const rawPath = `Contents/Resources/Caf\u0065\u0301.plist`;
const bytes = Buffer.from(archive);
await writeFile(join(root, rawPath), bytes);
const result = await inspect(root, {
path: rawPath.normalize("NFC"),
});
expect(result.archive_path).toBe(rawPath);
expect(result.archive_sha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
});
it("selects normalized intermediate directories while preserving their raw spelling", async () => {
const root = await createTestTempDirectory("rea-keyed-unicode-ancestors-");
const directory = "Cafe\u0301/Neste\u0301";
const rawPath = `${directory}/Model.plist`;
await mkdir(join(root, directory), { recursive: true });
await writeFile(join(root, rawPath), archive);
const result = await inspect(root, { path: rawPath.normalize("NFC") });
expect(result.archive_path).toBe(rawPath);
});
it.skipIf(process.platform === "darwin" || process.platform === "win32")(
"rejects equivalent archive paths beneath distinct Unicode-equivalent directories",
async () => {
const root = await createTestTempDirectory("rea-keyed-unicode-branches-");
for (const directory of ["Caf\u00e9", "Cafe\u0301"]) {
await mkdir(join(root, directory));
await writeFile(join(root, directory, "Model.plist"), archive);
}
await expect(
inspect(root, { path: "Caf\u00e9/Model.plist" }),
).rejects.toMatchObject({
issues: [
{
path: ["path"],
reason: "invalid_value",
message: expect.stringContaining("Unicode-equivalent"),
},
],
});
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a read-denied selected archive as host access denial",
async () => {
const root = await bundle();
const selected = join(root, "Contents", "Resources", "Model.plist");
await chmod(selected, 0);
try {
await expect(readFile(selected)).rejects.toMatchObject({
code: "EACCES",
});
const rejected = await inspect(root, {
path: "Contents/Resources/Model.plist",
}).catch((cause: unknown) => cause);
expect(rejected).toBeInstanceOf(AnalysisAccessDeniedError);
expect(rejected).toMatchObject({
operation: "inspect_keyed_archive",
path: selected,
systemCode: "EACCES",
});
const projected = projectAnalysisError(
rejected as AnalysisAccessDeniedError,
);
expect(projected).toMatchObject({
code: "access_denied",
category: "unavailable",
retryable: false,
details: {
path: selected,
system_code: "EACCES",
boundary: "filesystem-read",
},
});
expect(projected.remediation.action).toContain("read access");
} finally {
await chmod(selected, 0o700);
}
},
);
it("keeps an archive readable while preserving a known non-finite real", async () => {
const root = await bundle();
const bytes = Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", Number.NaN],
$top: { root: { CF$UID: 1 } },
}),
);
await writeFile(
join(root, "Contents", "Resources", "NonFinite.plist"),
bytes,
);
const result = await inspect(root, {
path: "Contents/Resources/NonFinite.plist",
});
expect(result.objects).toContainEqual(
expect.objectContaining({
id: 1,
kind: "scalar",
status: "observed",
value: { $plist_type: "real", value: "nan" },
}),
);
expect(result.limitations).toContain(
'1 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.',
);
});
const asAnalysisError = (value: unknown): AnalysisError => {
if (!(value instanceof AnalysisError)) throw value;
return value;
};
it.skipIf(process.platform === "win32")(
"reads bundle-relative archives from a bundle whose executable is a plist",
async () => {
const root = await createTestTempDirectory("rea-keyed-plist-bundle-");
const bundlePath = join(root, "Fixture.app");
await mkdir(join(bundlePath, "Contents", "Resources"), { recursive: true });
await writeFile(
join(bundlePath, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Settings.plist</string></dict></plist>",
);
await writeFile(
join(bundlePath, "Settings.plist"),
build({ setting: "value" }),
);
await writeFile(
join(bundlePath, "Contents", "Resources", "Model.plist"),
Buffer.from(archive),
);
const session = composeBinarySession(
new AnalysisProviderRegistry([], "auto", () => true),
[new ArtifactProvider(process.env, "darwin")],
);
try {
const opened = await session.open(bundlePath);
if (!opened.ok) throw opened.error;
expect(session.activeTarget()).toMatchObject({
kind: "artifact",
format: "plist",
bundleInfoPlist: join(bundlePath, "Info.plist"),
});
const inspected = await session.execute("inspect_keyed_archive", {
path: "Contents/Resources/Model.plist",
});
if (!inspected.ok) throw inspected.error;
expect(inspected.value.result).toMatchObject({
archive_path: "Contents/Resources/Model.plist",
objects: expect.arrayContaining([
expect.objectContaining({ value: "value" }),
]),
});
} finally {
await session.close();
}
},
);