diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 673a84d37..a9e339be5 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -22,7 +22,11 @@ export class DirectoryArtifactReader implements ArtifactReader { this.#rootPromise = realpath(root); } - async *entries(signal?: AbortSignal): AsyncIterable { + /** Enumerate entries, optionally restricting descent while retaining directories. */ + async *entries( + signal?: AbortSignal, + descendInto?: (path: string) => boolean, + ): AsyncIterable { 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, diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index fefc6d97a..ec50ab4cf 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -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; diff --git a/tests/boundary/filesystem/keyedArchiveSelection.test.ts b/tests/boundary/filesystem/keyedArchiveSelection.test.ts index 7b3e54292..68f24d5b3 100644 --- a/tests/boundary/filesystem/keyedArchiveSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveSelection.test.ts @@ -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(