mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(apple): report a read-denied selected archive as host access denial (#6479)
* fix(apple): report a read-denied selected archive as host access denial inspect-keyed-archive reported a host read refusal on the caller-selected bundle entry as artifact_operation_failed/execution_failure with reason io, leaving EACCES or EPERM only inside message text. The caller selected that entry, so the refusal is a selection outcome with its own typed classification, system code and read-access recovery. DirectoryArtifactReader now marks a refused entry open with DirectoryArtifactEntryAccessDenied, which retains the host system code and the selected entry path. KeyedArchiveReader, which owns the caller's selection, maps it to the shared AnalysisAccessDeniedError, and artifact failure translation keeps that typed error instead of replacing it with a generic io failure. Other readers and other reader failures are unchanged; every existing reason keeps its current projection. Adds the access_denied projection coverage plus a real host refusal through the production reader and a non-permission control. * fix(apple): carry cleanup ownership and cover raw refusals for a denied selection Addresses the two review findings on the denied-selection classification: - The typed host access denial now carries the reader's cleanup observation, so a denied open that left a resource owned does not project as fully cleaned. - Adds coverage that starts from raw host EACCES/EPERM/EIO refusals with the real DirectoryArtifactReader open path, so the marker itself is exercised rather than constructed by the test. EIO stays an artifact failure.
This commit is contained in:
1 parent
6c1b7f86d1
commit
eab61d02a1
6 files changed
+552
-13
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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<void>> => {
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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<typeof import("node:fs/promises")>()),
|
||||
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<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const { root, path } = await bundle();
|
||||
mockOpen.mockImplementation(
|
||||
async (file: Parameters<typeof actual.open>[0], ...rest: never[]) => {
|
||||
if (String(file) === path) {
|
||||
throw Object.assign(new Error(`${systemCode}: permission denied`), {
|
||||
code: systemCode,
|
||||
});
|
||||
}
|
||||
return actual.open(file, ...rest) as Promise<FileHandle>;
|
||||
},
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user