Files
rea/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts
53e0943223 fix(javascript): preserve path reasons, module coverage, and property provenance (#5651)
* fix(javascript): preserve selected path host resolution reasons

Classify ELOOP and ENAMETOOLONG while resolving the caller-selected input so the path and host code stay on the invalid-request issue.

Refs #5623

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): count summary coverage in module units

Use the module population for both examined and total counts. Non-module graph nodes remain in the application coverage section.

Refs #5464

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): connect direct property reads to return and argument flow

Record the unwrapped value expression and alias the object projection's read occurrence so a direct member read stays in backward provenance.

Refs #5585, #5648

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): count return wrappers against expression depth

Evaluate the original return expression so erased TypeScript and Flow wrappers stay inside the expression-depth budget. Keep the unwrapped node for identity and value ranges.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): describe ELOOP as unresolved symlink traversal

A host can return ELOOP for a cycle or for a chain past its traversal limit. Keep the selected path and code without asserting that a cycle exists.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(javascript): skip symlink traversal fixtures on Windows

Keep the overlong-name case portable. Windows does not produce ELOOP for these fixtures, matching the other path-failure tests.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): describe ENAMETOOLONG as a path or component limit

realpath reports that code for a long component and for a long full path. Keep the selected path and host code without claiming only a filename was too long.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(javascript): format return-depth and object-read changes

oxfmt rejected the return-depth test and the object-read index. No behavior change.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(ida): expect unchanged invalid requests to be nonretryable

The shared projector no longer marks invalid_request retryable. This lookup assertion was not updated with that contract.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 03:45:10 +08:00

171 lines
5.6 KiB
TypeScript

import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
const analyzeFailure = async (input: Record<string, unknown>) => {
const result = await analyzeJavaScriptApplication(input);
if (result.ok) throw new Error("Expected the selection to be refused");
return projectAnalysisError(result.error);
};
const application = async () => {
const root = await createTestTempDirectory("rea-javascript-selection-");
const directory = join(root, "app");
await mkdir(directory);
await writeFile(join(directory, "index.js"), "module.exports = 1;\n");
return { root, directory };
};
describe("JavaScript application input selection", () => {
it("preserves the selected path and host reason for an overlong name", async () => {
const { root } = await application();
const longPath = join(root, "x".repeat(300));
expect(await analyzeFailure({ input_path: longPath })).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["input_path"],
reason: "invalid_value",
message: expect.stringContaining(
`Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${longPath}`,
),
},
],
},
});
});
it.skipIf(process.platform === "win32")(
"preserves the selected path when symlink traversal loops or exceeds the host limit",
async () => {
const { root } = await application();
const loopPath = join(root, "loop-a");
await symlink("loop-b", loopPath);
await symlink("loop-a", join(root, "loop-b"));
await writeFile(join(root, "chain-end"), "");
let target = "chain-end";
for (let index = 59; index >= 0; index -= 1) {
const name = `chain-${String(index)}`;
await symlink(target, join(root, name));
target = name;
}
const chainPath = join(root, "chain-0");
const loopError = await analyzeFailure({ input_path: loopPath });
const chainError = await analyzeFailure({ input_path: chainPath });
const eloopMessage = (path: string) =>
`Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${path}`;
expect(loopError).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["input_path"],
reason: "invalid_value",
message: expect.stringContaining(eloopMessage(loopPath)),
},
],
},
});
expect(chainError).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["input_path"],
reason: "invalid_value",
message: expect.stringContaining(eloopMessage(chainPath)),
},
],
},
});
expect(loopError).not.toEqual(chainError);
},
);
it("reports a missing input path as invalid input, not an artifact read failure", async () => {
const { root } = await application();
const missing = join(root, "missing");
expect(await analyzeFailure({ input_path: missing })).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
path: ["input_path"],
reason: "invalid_value",
message: expect.stringContaining(missing),
},
],
},
});
});
it("reports a file that is neither a directory nor an .asar as an unsupported target", async () => {
const { root } = await application();
const binary = join(root, "program");
await writeFile(binary, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]));
expect(await analyzeFailure({ input_path: binary })).toMatchObject({
code: "unsupported_target",
category: "unsupported_target",
remediation: { action: expect.stringContaining(".asar") },
details: {
operation: "analyze_javascript_application",
path: binary,
},
});
});
it.each([
["a directory", "directory", "asar"],
["an .asar file", "asar", "directory"],
] as const)(
"reports %s selected with a contradicting format as an invalid request",
async (_label, observed, format) => {
const { root, directory } = await application();
const selected =
observed === "directory" ? directory : join(root, "app.asar");
if (observed === "asar") await writeFile(selected, "not inspected");
expect(
await analyzeFailure({ input_path: selected, format }),
).toMatchObject({
code: "invalid_request",
details: {
issues: [
{
path: ["format"],
reason: "invalid_value",
expected: ["auto", observed],
},
],
},
});
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable selected directory as a host access denial",
async () => {
const { directory } = await application();
await chmod(directory, 0o000);
onTestFinished(() => chmod(directory, 0o700));
expect(await analyzeFailure({ input_path: directory })).toMatchObject({
code: "access_denied",
details: { path: directory, system_code: "EACCES" },
});
},
);
});