fix(apple): scope keyed archive traversal to the selection

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
This commit is contained in:
Rudy Celekli 2026-10-09 19:15:24 -04:00 committed by Ysopwnable
parent 02ef2b1c41
commit c8fd2f152b
3 changed files with 129 additions and 11 deletions

View File

@ -22,7 +22,11 @@ export class DirectoryArtifactReader implements ArtifactReader {
this.#rootPromise = realpath(root);
}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
/** Enumerate entries, optionally restricting descent while retaining directories. */
async *entries(
signal?: AbortSignal,
descendInto?: (path: string) => boolean,
): AsyncIterable<ArtifactEntry> {
const root = await this.#rootPromise.catch((cause: unknown) => {
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
});
@ -66,7 +70,8 @@ export class DirectoryArtifactReader implements ArtifactReader {
? "file"
: undefined;
if (kind === undefined) continue;
if (kind === "directory") directories.push(absolute);
if (kind === "directory" && (descendInto?.(path) ?? true))
directories.push(absolute);
yield {
path,
kind,

View File

@ -148,8 +148,20 @@ export const inspectBundleKeyedArchive = async (input: {
// Match inventory spelling while retaining the raw path for I/O and evidence.
const wanted = selected.path.normalize("NFC");
const matches: ArtifactEntry[] = [];
for await (const entry of reader.entries(input.signal)) {
for await (const entry of reader.entries(input.signal, (directory) =>
wanted.startsWith(`${directory.normalize("NFC")}/`),
)) {
if (entry.path.normalize("NFC") !== wanted) continue;
if (entry.kind !== "file")
throw archivePathError(
"invalid_value",
`Archive path selects a ${entry.kind}, not a regular file: ${selected.path}`,
);
if ((entry.declaredSize ?? 0) > MAX_BYTES)
throw new ArtifactReaderFailure(
"limit",
"Keyed archive exceeds 64 MiB",
);
matches.push(entry);
}
if (matches.length > 1)
@ -163,13 +175,6 @@ export const inspectBundleKeyedArchive = async (input: {
"invalid_value",
`No regular file exists at ${selected.path} in the active app bundle.`,
);
if (entry.kind !== "file")
throw archivePathError(
"invalid_value",
`Archive path selects a ${entry.kind}, not a regular file: ${selected.path}`,
);
if ((entry.declaredSize ?? 0) > MAX_BYTES)
throw new ArtifactReaderFailure("limit", "Keyed archive exceeds 64 MiB");
const stream = await reader.open(entry, input.signal);
const chunks: Buffer[] = [];
let size = 0;

View File

@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, readdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
@ -67,6 +67,82 @@ it.each([
},
);
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(
@ -184,6 +260,38 @@ it("selects a decomposed bundle path by its normalized spelling and reports the
);
});
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("keeps an archive readable when a non-finite real is an explicit unknown", async () => {
const root = await bundle();
const bytes = Buffer.from(