diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 3127cebba5..029043a57c 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -14,6 +14,7 @@ import { sameArtifactSourceIdentity, type ArtifactEntry, type ArtifactReader, + type ArtifactReaderFailureOptions, type ArtifactTraversalOmission, type ArtifactTraversalOptions, } from "./ArtifactReader.js"; @@ -67,6 +68,23 @@ export class DirectoryArtifactTraversalLimit extends ArtifactReaderFailure { */ export class DirectoryArtifactRootFailure extends ArtifactReaderFailure {} +/** + * A selected child entry could not be opened because the host denied read + * access. The consumer that owns the caller's selection classifies it; + * `systemCode` and `entryPath` retain what the host actually reported so the + * classification never has to be re-derived from message text. + */ +export class DirectoryArtifactEntryAccessDenied extends ArtifactReaderFailure { + constructor( + readonly systemCode: "EACCES" | "EPERM", + readonly entryPath: string, + message: string, + options?: ArtifactReaderFailureOptions, + ) { + super("io", message, options); + } +} + /** Non-symlink-following directory reader. */ export class DirectoryArtifactReader implements ArtifactReader { readonly format = "directory" as const; @@ -288,7 +306,7 @@ export class DirectoryArtifactReader implements ArtifactReader { entry.adapterKey, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, ).catch((cause: unknown) => { - throw directoryIoFailure("open entry", entry.adapterKey, cause); + throw entryOpenFailure(entry, cause); }); const owner = new OwnedFileHandle(fileHandle); this.#files.set(owner, entry.adapterKey); @@ -528,21 +546,47 @@ const directoryIoFailure = ( Failure: typeof ArtifactReaderFailure = ArtifactReaderFailure, ): ArtifactReaderFailure => { if (cause instanceof ArtifactReaderFailure) return cause; - const code = - typeof cause === "object" && - cause !== null && - "code" in cause && - typeof cause.code === "string" - ? ` (${cause.code})` - : ""; + const code = hostErrorCode(cause); + const suffix = code === undefined ? "" : ` (${code})`; const detail = failureMessage(cause); return new Failure( "io", - `Could not ${operation} at ${path}${code}: ${detail}`, - { cause }, + `Could not ${operation} at ${path}${suffix}: ${detail}`, + { + cause, + }, ); }; +/** The host errno a lower-level failure reported, when it reported one. */ +const hostErrorCode = (cause: unknown): string | undefined => + typeof cause === "object" && + cause !== null && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : undefined; + +/** + * Report a refused entry open as the host's read-access denial so the consumer + * that owns the caller's selection can classify it without re-reading text. + */ +const entryOpenFailure = ( + entry: ArtifactEntry, + cause: unknown, +): ArtifactReaderFailure => { + const failure = directoryIoFailure("open entry", entry.adapterKey, cause); + const code = hostErrorCode(cause); + return code === "EACCES" || code === "EPERM" + ? new DirectoryArtifactEntryAccessDenied( + code, + entry.path, + failure.message, + { cause }, + ) + : failure; +}; + const failureMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); diff --git a/src/artifacts/apple/KeyedArchiveHostDenial.test.ts b/src/artifacts/apple/KeyedArchiveHostDenial.test.ts new file mode 100644 index 0000000000..79a2d7445a --- /dev/null +++ b/src/artifacts/apple/KeyedArchiveHostDenial.test.ts @@ -0,0 +1,110 @@ +import { execFile } from "node:child_process"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { build } from "plist"; +import { expect, it } from "vitest"; + +import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js"; +import { ArtifactProvider } from "../ArtifactProvider.js"; +import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +const run = promisify(execFile); +const isRoot = process.getuid?.() === 0; + +/** + * Make the selected entry unreadable for this process using the host's own + * mechanism, so the refusal is a real filesystem error and not a simulated one. + */ +const denyRead = async (path: string): Promise<() => Promise> => { + if (process.platform !== "win32") { + await chmod(path, 0); + return () => chmod(path, 0o700); + } + const user = process.env.USERNAME ?? ""; + await run("icacls", [path, "/deny", `${user}:R`]); + return async () => { + await run("icacls", [path, "/remove:d", user]).catch(() => undefined); + }; +}; + +it.skipIf(isRoot)( + "reports a real host read refusal on the selected archive as access denial", + async () => { + const root = await createTestTempDirectory("rea-keyed-host-denial-"); + await mkdir(join(root, "Contents", "Resources"), { recursive: true }); + const selected = join(root, "Contents", "Resources", "Model.plist"); + await writeFile( + selected, + Buffer.from( + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, + }), + ), + ); + + const restore = await denyRead(selected); + try { + // Establish that the host, not this fixture, refuses the read. + const refusal = await readFile(selected).catch((cause: unknown) => cause); + expect(refusal).toMatchObject({ + code: process.platform === "win32" ? "EPERM" : "EACCES", + }); + const systemCode = + refusal instanceof Error && "code" in refusal + ? String(refusal.code) + : undefined; + + const rejected = await inspectBundleKeyedArchive({ + bundlePath: root, + targetSha256: "a".repeat(64), + parameters: { path: "Contents/Resources/Model.plist" }, + platform: "darwin", + }).catch((cause: unknown) => cause); + + expect(rejected).toBeInstanceOf(AnalysisAccessDeniedError); + if (!(rejected instanceof AnalysisAccessDeniedError)) + throw new Error("expected a host access denial"); + expect(rejected.systemCode).toBe(systemCode); + expect(rejected.path).toBe(selected); + expect(projectAnalysisError(rejected)).toMatchObject({ + code: "access_denied", + category: "unavailable", + retryable: false, + details: { + path: selected, + system_code: systemCode, + boundary: "filesystem-read", + }, + remediation: { action: expect.stringContaining("read access") }, + }); + + // The provider translate step must keep the typed error, not replace it + // with a generic artifact io failure. + const executed = await new ArtifactProvider(process.env, "darwin") + .createClient({ + path: selected, + sha256: "a".repeat(64), + kind: "artifact", + format: "plist", + }) + .execute("inspect_keyed_archive", {}); + expect(executed.ok).toBe(false); + if (executed.ok) throw new Error("expected a denied read"); + expect(executed.error).toBeInstanceOf(AnalysisAccessDeniedError); + expect(projectAnalysisError(executed.error)).toMatchObject({ + code: "access_denied", + category: "unavailable", + details: { system_code: systemCode, boundary: "filesystem-read" }, + }); + } finally { + await restore(); + } + }, +); diff --git a/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts b/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts new file mode 100644 index 0000000000..460c2734eb --- /dev/null +++ b/src/artifacts/apple/KeyedArchiveRawRefusal.test.ts @@ -0,0 +1,113 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect, it, vi } from "vitest"; + +import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; +import { DirectoryArtifactEntryAccessDenied } from "../DirectoryArtifactReader.js"; +import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +/** + * The reader still opens real descriptors; only the host result for the + * selected entry is replaced, so the raw-error classification runs for real. + */ +const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() })); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + open: mockOpen, +})); + +const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, +}); +const selectedPath = "Contents/Resources/Model.plist"; + +const bundle = async () => { + const root = await createTestTempDirectory("rea-keyed-raw-denial-"); + await mkdir(join(root, "Contents", "Resources"), { recursive: true }); + const path = join(root, "Contents", "Resources", "Model.plist"); + await writeFile(path, Buffer.from(archive)); + return { root, path }; +}; + +const inspect = (bundlePath: string) => + inspectBundleKeyedArchive({ + bundlePath, + targetSha256: "a".repeat(64), + parameters: { path: selectedPath }, + platform: "darwin", + }); + +/** Reject the selected entry's open with the host error under test. */ +const hostOpenRefusal = async (systemCode: string) => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + const { root, path } = await bundle(); + mockOpen.mockImplementation( + async (file: Parameters[0], ...rest: never[]) => { + if (String(file) === path) { + throw Object.assign(new Error(`${systemCode}: permission denied`), { + code: systemCode, + }); + } + return actual.open(file, ...rest) as Promise; + }, + ); + return root; +}; + +it.each(["EACCES", "EPERM"] as const)( + "classifies the reader's raw %s refusal as a typed host access denial", + async (systemCode) => { + const root = await hostOpenRefusal(systemCode); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(AnalysisAccessDeniedError); + if (!(rejected instanceof AnalysisAccessDeniedError)) + throw new Error("expected a host access denial"); + expect(rejected.systemCode).toBe(systemCode); + expect(rejected.path).toBe(join(root, selectedPath)); + expect(projectAnalysisError(rejected)).toMatchObject({ + code: "access_denied", + category: "unavailable", + details: { system_code: systemCode, boundary: "filesystem-read" }, + }); + } finally { + mockOpen.mockReset(); + } + }, +); + +it("keeps the reader's raw EIO refusal an artifact failure", async () => { + const root = await hostOpenRefusal("EIO"); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).not.toBeInstanceOf(AnalysisAccessDeniedError); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); + if (!(rejected instanceof ArtifactReaderFailure)) + throw new Error("expected a reader failure"); + expect(rejected.reason).toBe("io"); + expect( + projectAnalysisError( + translateArtifactFailure("inspect_keyed_archive", rejected), + ), + ).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "io" }, + }); + } finally { + mockOpen.mockReset(); + } +}); diff --git a/src/artifacts/apple/KeyedArchiveReadFailure.test.ts b/src/artifacts/apple/KeyedArchiveReadFailure.test.ts new file mode 100644 index 0000000000..46a1a1733d --- /dev/null +++ b/src/artifacts/apple/KeyedArchiveReadFailure.test.ts @@ -0,0 +1,186 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { describe, expect, it, vi } from "vitest"; + +import { + ArtifactReaderFailure, + type ArtifactEntry, +} from "../ArtifactReader.js"; +import { + DirectoryArtifactEntryAccessDenied, + DirectoryArtifactReader, + DirectoryArtifactRootFailure, +} from "../DirectoryArtifactReader.js"; +import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js"; +import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, +}); +const selectedPath = "Contents/Resources/Model.plist"; + +const bundle = async () => { + const root = await createTestTempDirectory("rea-keyed-access-denied-"); + 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) => + inspectBundleKeyedArchive({ + bundlePath, + targetSha256: "a".repeat(64), + parameters: { path: selectedPath }, + platform: "darwin", + }); + +/** Replace the selected entry's real open with the refusal under test. */ +const refuseSelectedOpen = (refusal: (entry: ArtifactEntry) => Error) => { + const open = DirectoryArtifactReader.prototype.open; + return vi + .spyOn(DirectoryArtifactReader.prototype, "open") + .mockImplementation(function ( + this: DirectoryArtifactReader, + entry: ArtifactEntry, + signal?: AbortSignal, + ) { + if (entry.path === selectedPath) return Promise.reject(refusal(entry)); + return open.call(this, entry, signal); + }); +}; + +it.each(["EACCES", "EPERM"] as const)( + "reports a refused selected archive open as host access denial (%s)", + async (systemCode) => { + const root = await bundle(); + const spy = refuseSelectedOpen( + (entry) => + new DirectoryArtifactEntryAccessDenied( + systemCode, + entry.path, + `Could not open entry at ${entry.adapterKey} (${systemCode}): permission denied`, + ), + ); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(AnalysisAccessDeniedError); + if (!(rejected instanceof AnalysisAccessDeniedError)) + throw new Error("expected a host access denial"); + expect(rejected.operation).toBe("inspect_keyed_archive"); + expect(rejected.path).toBe(join(root, selectedPath)); + expect(rejected.systemCode).toBe(systemCode); + + const projected = projectAnalysisError(rejected); + expect(projected).toMatchObject({ + code: "access_denied", + category: "unavailable", + retryable: false, + details: { + path: join(root, selectedPath), + system_code: systemCode, + boundary: "filesystem-read", + }, + }); + expect(projected.remediation.action).toContain("read access"); + } finally { + spy.mockRestore(); + } + }, +); + +it("keeps a non-permission reader failure classified as an artifact failure", async () => { + const root = await bundle(); + const spy = refuseSelectedOpen( + () => + new ArtifactReaderFailure( + "io", + "Could not open entry (EIO): device error", + ), + ); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).not.toBeInstanceOf(AnalysisAccessDeniedError); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); + expect(rejected).toMatchObject({ reason: "io" }); + } finally { + spy.mockRestore(); + } +}); + +it("leaves a refused bundle root to the reader's own caller", async () => { + const root = await bundle(); + const spy = refuseSelectedOpen( + () => + new DirectoryArtifactRootFailure( + "path", + "Artifact root must be a directory", + ), + ); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(DirectoryArtifactRootFailure); + } finally { + spy.mockRestore(); + } +}); + +it("still reads a permitted archive through the production reader", async () => { + const root = await bundle(); + const result = await inspect(root); + expect(result.archive_path).toBe(selectedPath); + expect(result.objects).toContainEqual( + expect.objectContaining({ kind: "scalar", value: "leaf" }), + ); +}); + +describe("directory reader entry refusal", () => { + it("retains the host system code and the selected entry path", () => { + const failure = new DirectoryArtifactEntryAccessDenied( + "EPERM", + selectedPath, + "Could not open entry", + ); + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + expect(failure.reason).toBe("io"); + expect(failure.systemCode).toBe("EPERM"); + expect(failure.entryPath).toBe(selectedPath); + }); + + it("carries an unresolved cleanup owner into the typed denial", async () => { + const root = await bundle(); + const cleanup = { + reason: "close failed after the refused open", + resources: [join(root, selectedPath)], + }; + const spy = refuseSelectedOpen( + (entry) => + new DirectoryArtifactEntryAccessDenied( + "EACCES", + entry.path, + "Could not open entry (EACCES): permission denied", + { cleanup }, + ), + ); + try { + const rejected = await inspect(root).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(AnalysisAccessDeniedError); + if (!(rejected instanceof AnalysisAccessDeniedError)) + throw new Error("expected a host access denial"); + // A denial that left a resource owned must not project as fully cleaned. + expect(rejected.cleanupIncomplete).toBe(true); + expect(rejected.cleanupResources).toContain(join(root, selectedPath)); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 0857cbd183..bbfee2a1c3 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { parseBinary } from "plist"; import { z } from "zod"; import { + AnalysisAccessDeniedError, AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; @@ -28,9 +29,13 @@ import { keyedArchiveResultSchema, projectKeyedArchive, } from "../../domain/apple/keyedArchive.js"; -import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; +import { + DirectoryArtifactReader, + DirectoryArtifactEntryAccessDenied, +} from "../DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; +import { bindArtifactFileCleanup } from "../ArtifactFileOperation.js"; import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; import { @@ -216,7 +221,11 @@ export const inspectBundleKeyedArchive = async (input: { "invalid_value", `No regular file exists at ${selected.path} in the active app bundle.`, ); - const stream = await reader.open(entry, input.signal); + const stream = await reader + .open(entry, input.signal) + .catch((cause: unknown) => { + throw selectedArchiveReadFailure(input.bundlePath, entry, cause); + }); const chunks: Buffer[] = []; let size = 0; for await (const chunk of stream) { @@ -284,3 +293,33 @@ const archivePathError = ( new AnalysisInputError("inspect_keyed_archive", undefined, [ { path: ["path"], reason, message }, ]); + +/** + * The caller selected this entry, so a refused open is a selection outcome: + * preserve the host's read-access denial and its system code instead of + * reporting a generic artifact execution failure. + */ +const selectedArchiveReadFailure = ( + bundlePath: string, + entry: ArtifactEntry, + cause: unknown, +): unknown => { + if (!(cause instanceof DirectoryArtifactEntryAccessDenied)) return cause; + const error = new AnalysisAccessDeniedError( + "inspect_keyed_archive", + join(bundlePath, entry.path), + cause.systemCode, + { + cause, + // The cleanup observation, not just the leases, makes the projection + // report an unresolved resource; binding leases alone would claim the + // denied open left nothing owned. + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + ...(cause.partialObservation === undefined + ? {} + : { partialObservation: cause.partialObservation }), + }, + ); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/tests/boundary/filesystem/keyedArchiveSelection.test.ts b/tests/boundary/filesystem/keyedArchiveSelection.test.ts index 37195e4ea5..a5214b541a 100644 --- a/tests/boundary/filesystem/keyedArchiveSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveSelection.test.ts @@ -1,5 +1,12 @@ import { createHash } from "node:crypto"; -import { chmod, mkdir, readdir, truncate, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + readdir, + readFile, + truncate, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { build } from "plist"; @@ -10,6 +17,7 @@ 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"; @@ -318,6 +326,45 @@ it.skipIf(process.platform === "darwin" || process.platform === "win32")( }, ); +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(