diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 942325b394..1a47797197 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -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", diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 999bdbf57c..33f21594e1 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -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 => { +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") diff --git a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts index e0af6b86da..2f20c545d7 100644 --- a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts @@ -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; + 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" }, + }, + }); + }); +});