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:
parent
02ef2b1c41
commit
c8fd2f152b
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue