Files
rea/tests/boundary/cli/callerPathSymlinkParentCli.test.ts
T
Kaoru0822andClaude Opus 5.5 fc791bb606 fix(paths): select caller paths through symlinks and .. as the filesystem does (#4067)
* fix: select caller paths through symlinks and .. as the filesystem does

CLI adapters, binary target resolution, the reverse-engineering service
and the objdump and Rizin command builders made caller-selected paths
absolute with path.resolve, which collapses `link/..` lexically. On
POSIX the filesystem follows `link` first, so these commands inspected,
hashed or launched a different file and returned internally consistent
Evidence for it.

absoluteCallerPath now owns that step: on POSIX it prefixes a relative
path with the working directory and keeps an absolute path as spelled;
on Windows, where `..` resolves lexically, it keeps win32.resolve.

Fixes #3864
Fixes #3911
Fixes #3912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep firmware, WASM list and output-root paths on the selected file

Review follow-up. The CLI already passed firmware and WASM paths through
absoluteCallerPath, but snapshotFirmware and SafeOutputTree.create
re-resolved them lexically, and the WASM glue and candidate lists still
used resolve, so those inputs could still select a sibling file or
create output beside the intended directory.

absoluteCallerPath now also drops `.` segments and repeated or trailing
separators on POSIX, which never change the named file, so selected
paths such as `./module.wasm` keep matching their glue references
exactly. Every `..` is still left for the filesystem.

SafeOutputTree keeps host path syntax; its platform argument selects only
the Windows name policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep directory requirements and relative cwd in caller paths

Review follow-up. absoluteCallerPath stripped a trailing `/` or `/.`, so
`file/` selected the regular file instead of failing with ENOTDIR; it
now keeps one trailing separator. A relative cwd option was appended to
the root; it is now made absolute without collapsing `..`. WASM glue
matching resolved local literals lexically, so a module selected through
`link/..` matched only by basename; the adapter now compares both sides
through absoluteCallerPath and still reports each candidate as given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 05:19:43 -07:00

91 lines
3.5 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
import { createGoBinaryFixture } from "../../fixtures/go/image.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
// POSIX follows `link` before `..`, so `link/../sample` names target/sample.
describe.skipIf(process.platform === "win32")(
"caller paths through a directory symlink and ..",
() => {
cliTest(
"inspects the file the filesystem selects, not its lexical sibling",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-symlink-parent-");
await mkdir(join(root, "target/child"), { recursive: true });
await symlink("target/child", join(root, "link"));
const selected = createGoBinaryFixture({ goVersion: "go1.26.0" }).bytes;
const sibling = createGoBinaryFixture({ goVersion: "go1.25.0" }).bytes;
await writeFile(join(root, "target/sample"), selected);
await writeFile(join(root, "sample"), sibling);
const selectedDigest = createHash("sha256")
.update(selected)
.digest("hex");
for (const [command, operation] of [
["inspect-go-binary", "inspect_go_binary"],
["inspect-rust-binary", "inspect_rust_binary"],
] as const)
// Concatenate: path.join would collapse `link/..` itself.
for (const path of ["link/../sample", `${root}/link/../sample`]) {
const result = await cli.run({
arguments: [command, path, "--json"],
cwd: root,
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract(operation).outputSchema.parse(
result.json,
);
expect(evidence.subject?.digest.sha256, `${command} ${path}`).toBe(
selectedDigest,
);
}
},
30_000,
);
cliTest(
"reads PE resources from the file the filesystem selects",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-symlink-parent-pe-");
await mkdir(join(root, "target/child"), { recursive: true });
await symlink("target/child", join(root, "link"));
const image = (payload: string) =>
peResourceFixture(true, [
{
type: 10,
name: 1,
language: 1033,
payload: Buffer.from(payload),
},
]).bytes;
await writeFile(join(root, "target/sample.exe"), image("SELECTED"));
await writeFile(join(root, "sample.exe"), image("OTHER"));
const selectedDigest = createHash("sha256")
.update(image("SELECTED"))
.digest("hex");
for (const path of [
"link/../sample.exe",
`${root}/link/../sample.exe`,
]) {
const result = await cli.run({
arguments: ["inspect-pe-resources", path, "--json"],
cwd: root,
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract(
"inspect_pe_resources",
).outputSchema.parse(result.json);
expect(evidence.subject?.digest.sha256, path).toBe(selectedDigest);
}
},
30_000,
);
},
);