mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(javascript): keep selected-input filesystem failures typed
A selected directory that is readable but not searchable passed the R_OK selection check, then every entry lstat failed and the result became a generic artifact I/O failure. The selection owner now checks search permission for directories and reads the selection metadata inside its classifier, so EACCES/EPERM from realpath, lstat or access is a host access denial with the selected path and system code. Host filesystem failures that no narrower owner classifies no longer drop their facts: EMFILE/ENFILE become a retryable capacity constraint, and other errno failures keep artifact I/O with the host message and system_code, syscall and path diagnostics. Fixes #6116 Fixes #5991 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a4bbab3c3
commit
abd99a4ce7
3 files changed
+203
-20
No files matched your search
@@ -7,7 +7,10 @@ import {
|
||||
type JavaScriptApplicationAnalysisResult,
|
||||
parseOwnedJavaScriptApplicationAnalysisSteps,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
@@ -149,13 +152,8 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
typeof cause.code === "string" &&
|
||||
FILESYSTEM_ERROR_CODES.has(cause.code)
|
||||
)
|
||||
return err(new ArtifactOperationError(OPERATION, "io"));
|
||||
const filesystemFailure = hostFilesystemFailure(cause);
|
||||
if (filesystemFailure !== undefined) return err(filesystemFailure);
|
||||
return err(
|
||||
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
|
||||
cause,
|
||||
@@ -208,6 +206,43 @@ const describeResultSchemaFailure = (cause: z.ZodError): string => {
|
||||
const escapePointerPart = (part: string): string =>
|
||||
part.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
|
||||
/**
|
||||
* Project a host filesystem failure that no narrower owner classified. Selected
|
||||
* input denial is classified at selection; this failure may concern any path,
|
||||
* including private workspace files, so it keeps the host's own facts rather
|
||||
* than claiming a selected-read denial.
|
||||
*/
|
||||
const hostFilesystemFailure = (cause: unknown): AnalysisError | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
|
||||
return undefined;
|
||||
const syscall: unknown = Reflect.get(cause, "syscall");
|
||||
const path: unknown = Reflect.get(cause, "path");
|
||||
const facts = {
|
||||
system_code: code,
|
||||
...(typeof syscall === "string" ? { syscall } : {}),
|
||||
...(typeof path === "string" ? { path } : {}),
|
||||
};
|
||||
if (code === "EMFILE" || code === "ENFILE")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
`Host file-descriptor limit reached (${code})${typeof path === "string" ? ` while accessing ${path}` : ""}`,
|
||||
facts,
|
||||
{
|
||||
cause,
|
||||
retryable: true,
|
||||
remediationAction:
|
||||
"Close unused files or raise the open-file limit for the process or host running REA, then retry.",
|
||||
},
|
||||
);
|
||||
return new ArtifactOperationError(OPERATION, "io", undefined, cause.message, {
|
||||
cause,
|
||||
diagnostics: facts,
|
||||
});
|
||||
};
|
||||
|
||||
const FILESYSTEM_ERROR_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants } from "node:fs";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { access, lstat, realpath } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
@@ -91,8 +91,8 @@ export const reconstructJavaScriptArtifact = async (
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
const path = await resolveSelectedInput(input.input_path);
|
||||
const format = await resolveFormat(path, input);
|
||||
const { path, metadata } = await resolveSelectedInput(input.input_path);
|
||||
const format = selectedFormat(path, metadata, input);
|
||||
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
||||
resourceScope: scope,
|
||||
signal,
|
||||
@@ -346,13 +346,21 @@ const OPERATION = "analyze_javascript_application";
|
||||
/**
|
||||
* Resolve the caller-selected input. A missing, looping, overlong, or
|
||||
* unreadable selection is a caller or host-resolution failure, not a damaged
|
||||
* artifact.
|
||||
* artifact. A selected directory must also be searchable: without search
|
||||
* permission its entries can be listed but not inspected, so no entry is
|
||||
* readable. Other host failures propagate with their errno facts intact.
|
||||
*/
|
||||
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
||||
const resolveSelectedInput = async (
|
||||
inputPath: string,
|
||||
): Promise<{ readonly path: string; readonly metadata: Stats }> => {
|
||||
try {
|
||||
const path = await realpath(inputPath);
|
||||
await access(path, constants.R_OK);
|
||||
return path;
|
||||
const metadata = await lstat(path);
|
||||
await access(
|
||||
path,
|
||||
metadata.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK,
|
||||
);
|
||||
return { path, metadata };
|
||||
} catch (cause: unknown) {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||
@@ -389,11 +397,11 @@ const selectedInputResolutionMessage = (
|
||||
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
|
||||
|
||||
/** The selected input's kind, checked against the caller's requested format. */
|
||||
const resolveFormat = async (
|
||||
const selectedFormat = (
|
||||
path: string,
|
||||
metadata: Stats,
|
||||
input: JavaScriptArtifactReconstructionInput,
|
||||
): Promise<"asar" | "directory"> => {
|
||||
const metadata = await lstat(path);
|
||||
): "asar" | "directory" => {
|
||||
const observed = metadata.isDirectory()
|
||||
? "directory"
|
||||
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { promises as filesystem } from "node:fs";
|
||||
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -168,3 +170,141 @@ describe("JavaScript application input selection", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("JavaScript application selected directory search permission", () => {
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"reports a readable but unsearchable selected directory as a host access denial",
|
||||
async () => {
|
||||
const { directory } = await application();
|
||||
await chmod(directory, 0o400);
|
||||
try {
|
||||
expect(await analyzeFailure({ input_path: directory })).toMatchObject({
|
||||
code: "access_denied",
|
||||
category: "unavailable",
|
||||
details: {
|
||||
path: directory,
|
||||
system_code: "EACCES",
|
||||
boundary: "filesystem-read",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
expect(
|
||||
(await analyzeJavaScriptApplication({ input_path: directory })).ok,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("JavaScript application host filesystem failures", () => {
|
||||
const inject = async (
|
||||
method: "access" | "lstat" | "realpath",
|
||||
selected: string,
|
||||
failure: Error,
|
||||
) => {
|
||||
const original = filesystem[method] as (
|
||||
...args: unknown[]
|
||||
) => Promise<unknown>;
|
||||
let injected = 0;
|
||||
const spy = vi.spyOn(filesystem, method).mockImplementation((async (
|
||||
...args: unknown[]
|
||||
) => {
|
||||
if (String(args[0]) === selected) {
|
||||
injected += 1;
|
||||
throw failure;
|
||||
}
|
||||
return original(...args);
|
||||
}) as never);
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
const error = await analyzeFailure({ input_path: selected });
|
||||
expect(injected).toBe(1);
|
||||
return error;
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
syncBuiltinESMExports();
|
||||
}
|
||||
};
|
||||
|
||||
const hostFailure = (code: string, syscall: string, path: string) =>
|
||||
Object.assign(new Error(`${code}: host failure, ${syscall} '${path}'`), {
|
||||
code,
|
||||
syscall,
|
||||
path,
|
||||
});
|
||||
|
||||
it("keeps the errno, syscall and path of a host I/O failure", async () => {
|
||||
const { directory } = await application();
|
||||
const selected = await realpath(directory);
|
||||
const failure = hostFailure("EIO", "realpath", selected);
|
||||
|
||||
expect(await inject("realpath", selected, failure)).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
category: "execution_failure",
|
||||
details: {
|
||||
operation: "analyze_javascript_application",
|
||||
reason: "io",
|
||||
detail: failure.message,
|
||||
diagnostics: {
|
||||
system_code: "EIO",
|
||||
syscall: "realpath",
|
||||
path: selected,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports descriptor exhaustion as a retryable resource constraint", async () => {
|
||||
const { directory } = await application();
|
||||
const selected = await realpath(directory);
|
||||
|
||||
expect(
|
||||
await inject(
|
||||
"access",
|
||||
selected,
|
||||
hostFailure("EMFILE", "access", selected),
|
||||
),
|
||||
).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
category: "resource_constraint",
|
||||
retryable: true,
|
||||
details: {
|
||||
operation: "analyze_javascript_application",
|
||||
resource: "capacity",
|
||||
reported_limits: {
|
||||
system_code: "EMFILE",
|
||||
syscall: "access",
|
||||
path: selected,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a denied selected metadata read as a host access denial", async () => {
|
||||
const { directory } = await application();
|
||||
const selected = await realpath(directory);
|
||||
|
||||
expect(
|
||||
await inject("lstat", selected, hostFailure("EACCES", "lstat", selected)),
|
||||
).toMatchObject({
|
||||
code: "access_denied",
|
||||
details: { path: selected, system_code: "EACCES" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-errno failure an execution failure", async () => {
|
||||
const { directory } = await application();
|
||||
const selected = await realpath(directory);
|
||||
|
||||
expect(
|
||||
await inject("lstat", selected, new Error("unexpected internal failure")),
|
||||
).toMatchObject({
|
||||
code: "execution_failure",
|
||||
category: "execution_failure",
|
||||
details: {
|
||||
diagnostics: { error_message: "unexpected internal failure" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user