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>
This commit is contained in:
Kaoru0822
2026-10-15 05:19:43 -07:00
committed by GitHub
parent 8ed3d1df72
commit fc791bb606
25 changed files with 352 additions and 54 deletions
+3 -2
View File
@@ -7,7 +7,7 @@ import {
stat,
type FileHandle,
} from "node:fs/promises";
import { extname, isAbsolute, resolve } from "node:path";
import { extname } from "node:path";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import {
@@ -52,6 +52,7 @@ import {
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024;
@@ -80,7 +81,7 @@ export const parseBinaryTarget = async (
const cwd = options.cwd ?? process.cwd();
const hostArchitecture = options.hostArchitecture ?? process.arch;
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
const candidate = absoluteCallerPath(input, { cwd });
try {
throwIfTargetResolutionCancelled(signal);
await access(candidate, constants.R_OK);
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { stat as statPath } from "node:fs/promises";
import type { Stats } from "node:fs";
import { resolve } from "node:path";
import {
AnalysisAccessDeniedError,
@@ -37,6 +36,7 @@ import {
runOwnedCommand,
} from "../../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
export type ReverseEngineeringOperation =
| "inspect_with_objdump"
@@ -291,7 +291,8 @@ export class ReverseEngineeringService {
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
const cleanup = await this.retryCleanup(operation);
if (!cleanup.ok) return cleanup;
const absolutePath = path === undefined ? undefined : resolve(path);
const absolutePath =
path === undefined ? undefined : absoluteCallerPath(path);
let digest: string | undefined;
try {
if (absolutePath !== undefined)
+4 -2
View File
@@ -7,7 +7,7 @@ import {
realpath,
type FileHandle,
} from "node:fs/promises";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { basename, dirname, isAbsolute, join } from "node:path";
import type { Readable } from "node:stream";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
@@ -17,6 +17,7 @@ import {
destinationCaseCollisionMessage,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { SafeOutputTreeCreationFailure } from "./SafeOutputTreeCreationFailure.js";
import { hashOutputFile } from "./SafeOutputTreeReadback.js";
@@ -92,7 +93,8 @@ export class SafeOutputTree {
"path",
"Extraction output root must be absolute",
);
const requested = resolve(outputRoot);
// Path syntax follows the host; `platform` selects only the name policy.
const requested = absoluteCallerPath(outputRoot);
const name = basename(requested);
if (name === "." || name === "..")
throw new ArtifactReaderFailure("path", "Invalid extraction output root");
+4 -4
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { resolve } from "node:path";
import {
createRecordedCrashService,
createBinaryLayoutService,
@@ -13,6 +12,7 @@ import { withCommandCancellation } from "./commandCancellation.js";
import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Inspect operator-selected files through the same shared workflow as MCP. */
export const registerBinaryDiagnosticsCommands = (
@@ -39,7 +39,7 @@ export const registerBinaryDiagnosticsCommands = (
() =>
pe.inspect(
{
path: resolve(args.path),
path: absoluteCallerPath(args.path),
max_file_bytes: options.maxFileBytes,
max_entries: options.maxEntries,
},
@@ -63,7 +63,7 @@ export const registerBinaryDiagnosticsCommands = (
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => {
const result = await service.inspectAndClose(
{ path: resolve(args.path) },
{ path: absoluteCallerPath(args.path) },
{ signal },
);
return result.ok ? result.value : projectAnalysisError(result.error);
@@ -90,7 +90,7 @@ export const registerBinaryDiagnosticsCommands = (
logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => {
const result = await recorded.inspectAndClose(
{
path: resolve(args.path),
path: absoluteCallerPath(args.path),
include_debugger_context: options.debuggerContext,
},
{ signal },
+2 -2
View File
@@ -1,7 +1,6 @@
import { inspectPacketFieldsInputSchema } from "../domain/captures/packetFields.js";
import { z } from "incur";
import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
import { resolve } from "node:path";
import { createPacketCaptureService } from "../composition/captures.js";
import {
packetCaptureOptionsSchema,
@@ -14,6 +13,7 @@ import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
export const registerCaptureCommands = (
cli: CliInstance,
logger: Logger,
@@ -105,7 +105,7 @@ export const registerCaptureCommands = (
{
...options,
...(all_matches ? { match_limit: null } : {}),
path: resolve(args.path),
path: absoluteCallerPath(args.path),
},
{ signal },
),
+2 -2
View File
@@ -1,6 +1,5 @@
import { z } from "incur";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import { resolveCliAnalysisSelector } from "./analysisSelector.js";
import type { DirectAnalysis } from "../composition/directAnalysis.js";
@@ -16,6 +15,7 @@ import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnal
import { withCommandCancellation } from "./commandCancellation.js";
import { javascriptApplicationOptions } from "../cliObservationOptions.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Register provider-neutral binary overview and procedure CLI commands. */
export const registerCoreBinaryCommands = (
@@ -84,7 +84,7 @@ const registerOverviewCommands = (
logCliCommand(logger, "analyze", () =>
runCliJavaScriptApplicationAnalysis(
{
input_path: resolve(args.path),
input_path: absoluteCallerPath(args.path),
integrity_policy:
options.integrityPolicy ?? defaultIntegrityPolicy,
...(options.sourcePath === undefined
+5 -2
View File
@@ -1,11 +1,11 @@
import { z } from "incur";
import { resolve } from "node:path";
import { evmInterfaceModule } from "../composition/evm.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Resolve operator paths and preserve explicit encoding through the shared workflow. */
export const registerEvmCommands = (
@@ -30,7 +30,10 @@ export const registerEvmCommands = (
evmInterfaceModule.cliCommand,
async () => {
const result = await service.inspect(
{ path: resolve(args.path), encoding: args.encoding },
{
path: absoluteCallerPath(args.path),
encoding: args.encoding,
},
{ signal },
);
return result.ok
+4 -4
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { resolve } from "node:path";
import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js";
import { createFirmwareAnalysisProvider } from "../composition/firmware.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@@ -11,6 +10,7 @@ import type { FirmwareOperation } from "../domain/firmware/firmwareAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** One-shot firmware CLI commands backed by the same workflows as MCP. */
export const registerFirmwareCommands = (
@@ -50,7 +50,7 @@ export const registerFirmwareCommands = (
CLI_COMMANDS.inspectFirmwareRegions,
"inspect_firmware_regions",
// CLI paths are operator-relative; resolve before shared validation.
{ path: resolve(args.path) },
{ path: absoluteCallerPath(args.path) },
),
});
cli.command(CLI_COMMANDS.extractFirmware, {
@@ -75,8 +75,8 @@ export const registerFirmwareCommands = (
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.extractFirmware, "extract_firmware", {
path: resolve(args.path),
output_directory: resolve(args.outputDirectory),
path: absoluteCallerPath(args.path),
output_directory: absoluteCallerPath(args.outputDirectory),
...(options.offset === undefined && options.length === undefined
? {}
: { range: { offset: options.offset, length: options.length } }),
+6 -2
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { resolve } from "node:path";
import { createGoBinaryService } from "../composition/go.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -7,6 +6,7 @@ import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Resolve caller paths and inspect static build metadata through the shared workflow. */
export const registerGoCommands = (cli: CliInstance, logger: Logger): void => {
@@ -24,7 +24,11 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => {
CLI_COMMANDS.inspectGoBinary,
async () =>
finishAnalysisCommand(
() => service.inspect({ path: resolve(args.path) }, { signal }),
() =>
service.inspect(
{ path: absoluteCallerPath(args.path) },
{ signal },
),
service,
"go-build-info",
"inspect_go_binary",
+2 -2
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { resolve } from "node:path";
import { createRustBinaryService } from "../composition/rust.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -7,6 +6,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */
export const registerRustCommands = (
@@ -24,7 +24,7 @@ export const registerRustCommands = (
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
const result = await service.inspect(
{ path: resolve(args.path) },
{ path: absoluteCallerPath(args.path) },
{ signal },
);
return result.ok ? result.value : projectAnalysisError(result.error);
+2 -2
View File
@@ -1,7 +1,6 @@
import { inspectSignalDecodersInputSchema } from "../domain/signals/signalDecoders.js";
import { z } from "incur";
import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js";
import { resolve } from "node:path";
import { createSignalRecordingService } from "../composition/signals.js";
import {
signalRecordingOptionsSchema,
@@ -14,6 +13,7 @@ import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
export const registerSignalCommands = (
cli: CliInstance,
logger: Logger,
@@ -107,7 +107,7 @@ export const registerSignalCommands = (
{
...options,
...(all_lines ? { line_limit: null } : {}),
path: resolve(args.path),
path: absoluteCallerPath(args.path),
},
{ signal },
),
+2 -2
View File
@@ -1,5 +1,4 @@
import { z } from "incur";
import { resolve } from "node:path";
import { createSqliteDatabaseService } from "../composition/sqlite.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -9,6 +8,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */
export const registerSqliteCommands = (
@@ -55,7 +55,7 @@ export const registerSqliteCommands = (
const inspection = await service
.inspect(
{
path: resolve(args.path),
path: absoluteCallerPath(args.path),
...(options.table === undefined
? {}
: { table: options.table }),
+6 -4
View File
@@ -2,13 +2,13 @@ import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparis
import { parseCliJsonInput } from "../cliJsonInput.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { z } from "incur";
import { resolve } from "node:path";
import { wasmArtifactModule } from "../composition/wasm.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Resolve all caller-selected artifact paths through the shared workflow. */
export const registerWasmCommands = (
@@ -64,10 +64,12 @@ export const registerWasmCommands = (
async () => {
const result = await service.inspect(
{
path: resolve(args.path),
glue_paths: options.glue_paths?.map((path) => resolve(path)),
path: absoluteCallerPath(args.path),
glue_paths: options.glue_paths?.map((path) =>
absoluteCallerPath(path),
),
candidate_paths: options.candidate_paths?.map((path) =>
resolve(path),
absoluteCallerPath(path),
),
},
{ signal },
+32
View File
@@ -0,0 +1,32 @@
import { posix, win32 } from "node:path";
/**
* Make a caller-selected local path absolute without changing which file it
* names. POSIX resolves `..` after following the preceding symlink, so lexical
* normalization of `link/../file` can select a different file. On POSIX, only
* interior `.` segments and repeated separators are removed, which never
* change the named file; every `..` is kept for the filesystem to resolve, and
* a trailing `/` or `/.` keeps its directory requirement as one trailing `/`.
* A relative `cwd` is made absolute the same way. Win32 resolves `..`
* lexically, so normalizing there is exact.
*/
export const absoluteCallerPath = (
path: string,
{
cwd = process.cwd(),
platform = process.platform,
}: { readonly cwd?: string; readonly platform?: NodeJS.Platform } = {},
): string => {
if (platform === "win32") return win32.resolve(cwd, path);
if (!posix.isAbsolute(path)) {
const base = posix.isAbsolute(cwd)
? cwd
: absoluteCallerPath(cwd, { platform });
return absoluteCallerPath(`${base}/${path}`, { platform });
}
const segments = path
.split("/")
.filter((segment) => segment !== "" && segment !== ".");
const directory = /\/\.?$/u.test(path) && segments.length > 0;
return `/${segments.join("/")}${directory ? "/" : ""}`;
};
+3 -2
View File
@@ -8,7 +8,7 @@ import {
readlink,
realpath,
} from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { isAbsolute, join, relative, sep } from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
@@ -17,6 +17,7 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { FirmwareRequest } from "../domain/firmware/firmwareAnalysis.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { FIRMWARE_LIMITS } from "./FirmwareRelease.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -63,7 +64,7 @@ export const snapshotFirmware = async (
root: string,
signal: AbortSignal,
) => {
const sourcePath = resolve(request.input.path);
const sourcePath = absoluteCallerPath(request.input.path);
const source = await realpath(sourcePath);
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
const fullPath = join(root, "firmware.bin");
+5 -3
View File
@@ -1,6 +1,5 @@
import { resolve } from "node:path";
import type { ObjdumpInput } from "../domain/reverseEngineering.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
export const OBJDUMP_PROVIDER_IDENTITY = {
id: "gnu.objdump",
@@ -20,5 +19,8 @@ export const objdumpCommand = (input: ObjdumpInput): readonly string[] => {
disassemble: ["-d"],
disassemble_all: ["-D"],
} satisfies Record<typeof input.operation, readonly string[]>;
return [...argumentsByOperation[input.operation], resolve(input.path)];
return [
...argumentsByOperation[input.operation],
absoluteCallerPath(input.path),
];
};
+2 -3
View File
@@ -1,6 +1,5 @@
import { resolve } from "node:path";
import type { RizinInput } from "../domain/reverseEngineering.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
export const RIZIN_PROVIDER_IDENTITY = {
id: "rizin",
@@ -14,5 +13,5 @@ export const rizinCommand = (input: RizinInput): readonly string[] => [
"-q",
"-c",
input.command,
resolve(input.path),
absoluteCallerPath(input.path),
];
+2 -2
View File
@@ -3,7 +3,6 @@ import { isUtf8 } from "node:buffer";
import { open, readFile, readlink, realpath, stat } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { isatty } from "node:tty";
import { resolve } from "node:path";
import {
AnalysisCancelledError,
@@ -30,6 +29,7 @@ import {
type OwnedInferiorIdentity,
type OwnedDebuggerDetachResult,
} from "../process/ownedDebuggerSession.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
const MAX_SESSIONS = 8;
const MAX_PENDING_COMMANDS = 16;
@@ -378,7 +378,7 @@ export class RizinDebugSessionManager {
if (signal?.aborted)
return err(new AnalysisCancelledError("start_rizin_debug_session"));
const id = randomUUID();
const resolvedPath = resolve(input.path);
const resolvedPath = absoluteCallerPath(input.path);
const launchProvenance: RizinLaunchProvenance = {
caller_selected_path: input.path,
resolved_path: resolvedPath,
+20
View File
@@ -3,6 +3,7 @@ import { expect, it } from "vitest";
import { parseWabtOutput } from "./WabtOutput.js";
import { parseWabtWat } from "./WabtWat.js";
import { associateWasmGlue } from "./WasmGlueAdapter.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
const golden = () =>
readFile(
new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url),
@@ -282,3 +283,22 @@ it("keeps exact local, ambiguous URL, computed and unparseable glue distinct", (
references: [],
});
});
it("matches a CLI-selected ./ module exactly against its glue reference", () => {
// The CLI makes `./module.wasm` absolute from /app with absoluteCallerPath.
const selected = absoluteCallerPath("./module.wasm", {
cwd: "/app",
platform: "linux",
});
expect(
associateWasmGlue('fetch("./module.wasm");', "/app/glue.js", [
selected,
"/other/module.wasm",
]).references,
).toMatchObject([
{
association: "local-path-candidate",
candidate_paths: ["/app/module.wasm"],
},
]);
});
+13 -6
View File
@@ -1,7 +1,8 @@
import { basename, dirname, resolve } from "node:path";
import { basename, dirname } from "node:path";
import * as t from "@babel/types";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Associate visible literals with selected candidates without asserting data flow or URL identity. */
export const associateWasmGlue = (
source: string,
@@ -41,11 +42,17 @@ export const associateWasmGlue = (
!/^[a-z][a-z\d+.-]*:|^\/\//iu.test(value) &&
!value.startsWith("/") &&
!/[?#]/u.test(value);
const exact = local
? candidates.filter(
(candidate) => candidate === resolve(dirname(path), value),
)
: [];
// Compare through the caller-path owner so `.` spellings match and a
// symlink followed by `..` is never collapsed onto a sibling.
const target = local
? absoluteCallerPath(value, { cwd: dirname(path) })
: undefined;
const exact =
target === undefined
? []
: candidates.filter(
(candidate) => absoluteCallerPath(candidate) === target,
);
const matches =
exact.length > 0
? exact
@@ -0,0 +1,90 @@
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,
);
},
);
@@ -0,0 +1,41 @@
import { expect, it } from "vitest";
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
it("keeps POSIX parent components for the filesystem to resolve", () => {
const posix = { cwd: "/work", platform: "linux" } as const;
expect(absoluteCallerPath("link/../sample", posix)).toBe(
"/work/link/../sample",
);
expect(absoluteCallerPath("/abs/link/../sample", posix)).toBe(
"/abs/link/../sample",
);
expect(absoluteCallerPath("../sample", posix)).toBe("/work/../sample");
});
it("removes only POSIX spellings that cannot change the named file", () => {
const posix = { cwd: "/work/", platform: "darwin" } as const;
expect(absoluteCallerPath("./module.wasm", posix)).toBe("/work/module.wasm");
expect(absoluteCallerPath("a//./b", posix)).toBe("/work/a/b");
expect(absoluteCallerPath("/x/./y", posix)).toBe("/x/y");
expect(absoluteCallerPath("sample", { ...posix, cwd: "/" })).toBe("/sample");
expect(absoluteCallerPath("/", posix)).toBe("/");
// A trailing separator or `.` still requires a directory.
expect(absoluteCallerPath("sample/", posix)).toBe("/work/sample/");
expect(absoluteCallerPath("sample/.", posix)).toBe("/work/sample/");
expect(absoluteCallerPath("a//b//", posix)).toBe("/work/a/b/");
});
it("makes a relative POSIX cwd absolute before appending the path", () => {
expect(
absoluteCallerPath("sample", { cwd: "fixtures/../x", platform: "linux" }),
).toBe(`${process.cwd()}/fixtures/../x/sample`);
});
it("uses lexical resolution on Windows, where it is exact", () => {
const win32 = { cwd: "C:\\work", platform: "win32" } as const;
expect(absoluteCallerPath("link\\..\\sample", win32)).toBe(
"C:\\work\\sample",
);
expect(absoluteCallerPath("D:\\x\\..\\y", win32)).toBe("D:\\y");
});
@@ -0,0 +1,92 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
realpath,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
import { snapshotFirmware } from "../../../src/firmware/FirmwareFiles.js";
import { associateWasmGlue } from "../../../src/wasm/WasmGlueAdapter.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// POSIX follows `link` before `..`, so `link/../x` names target/x, not ./x.
const layout = async (prefix: string) => {
const root = await realpath(await createTestTempDirectory(prefix));
await mkdir(join(root, "target/child"), { recursive: true });
await symlink("target/child", join(root, "link"));
return root;
};
describe.skipIf(process.platform === "win32")(
"file owners given a directory symlink followed by ..",
() => {
it("snapshots the firmware image the filesystem selects", async () => {
const root = await layout("rea-symlink-parent-firmware-");
const selected = Buffer.from("selected firmware");
await writeFile(join(root, "target/image.bin"), selected);
await writeFile(join(root, "image.bin"), "sibling firmware");
const work = await createTestTempDirectory("rea-firmware-snapshot-");
// Concatenate: path.join would collapse `link/..` itself.
const path = `${root}/link/../image.bin`;
const snapshot = await snapshotFirmware(
{ operation: "inspect_firmware_regions", input: { path } },
work,
new AbortController().signal,
);
expect(snapshot.subject).toMatchObject({
path,
sha256: createHash("sha256").update(selected).digest("hex"),
});
});
it("rejects a trailing separator after a regular file", async () => {
const root = await layout("rea-trailing-separator-");
// A real executable, so only the trailing separator can reject it.
await copyFile(process.execPath, join(root, "sample"));
expect((await parseBinaryTarget(`${root}/sample`)).ok).toBe(true);
for (const path of [`${root}/sample/`, `${root}/sample/.`]) {
const target = await parseBinaryTarget(path);
expect(target.ok, path).toBe(false);
}
});
it("matches WASM glue to a module selected through link/..", () => {
const root = "/app";
const selected = absoluteCallerPath("link/../module.wasm", {
cwd: root,
});
expect(
associateWasmGlue(
'fetch("./module.wasm");',
absoluteCallerPath("link/../glue.js", { cwd: root }),
[selected, "/app/module.wasm"],
).references,
).toMatchObject([
{ association: "local-path-candidate", candidate_paths: [selected] },
]);
});
it("creates the output directory the filesystem selects", async () => {
const root = await layout("rea-symlink-parent-output-");
const tree = await SafeOutputTree.create(`${root}/link/../out`);
try {
expect(tree.outputRoot).toBe(join(root, "target/out"));
expect((await stat(join(root, "target/out"))).isDirectory()).toBe(true);
await expect(stat(join(root, "out"))).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
await tree.rollback();
}
});
},
);
@@ -1,6 +1,6 @@
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
@@ -10,6 +10,7 @@ import { z } from "zod";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { evidenceSchema } from "../../../src/domain/evidence.js";
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
import { err, ok } from "../../../src/domain/result.js";
import {
createRizinTestManager,
@@ -92,7 +93,7 @@ process.stdin.on("data", (chunk) => {
const evidence = evidenceSchema.parse(command.structuredContent);
expect(evidence.parameters.launch_provenance).toEqual({
caller_selected_path: callerPath,
resolved_path: resolve(callerPath),
resolved_path: absoluteCallerPath(callerPath),
});
expect(evidence.normalized_result).toMatchObject({
output_invalid_utf8: true,
@@ -1,6 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -12,6 +11,7 @@ import {
type ProviderProcessHandle,
} from "../../../src/process/ProviderProcess.js";
import { evidenceSchema } from "../../../src/domain/evidence.js";
import { absoluteCallerPath } from "../../../src/filesystem/callerPath.js";
import {
createRizinTestManager,
rizinTestInferiorTty,
@@ -422,7 +422,7 @@ process.stdin.on("data", (chunk) => {
const manager = createRizinTestManager({ REA_RIZIN_COMMAND: provider });
managers.push(manager);
const callerSelectedPath = "./relative/../fixture.bin";
const resolvedPath = resolve(callerSelectedPath);
const resolvedPath = absoluteCallerPath(callerSelectedPath);
const started = await manager.start({
path: callerSelectedPath,
inferior_tty: inferiorTty,
@@ -670,7 +670,7 @@ process.stdin.on("data", (chunk) => {
);
managers.push(manager);
const callerSelectedPath = "./cancelled/../fixture.bin";
const resolvedPath = resolve(callerSelectedPath);
const resolvedPath = absoluteCallerPath(callerSelectedPath);
const started = await manager.start({
path: callerSelectedPath,
inferior_tty: await rizinTestInferiorTty(),
@@ -849,7 +849,7 @@ process.stdin.on("data", (chunk) => {
);
managers.push(manager);
const callerSelectedPath = "./exit/../fixture.bin";
const resolvedPath = resolve(callerSelectedPath);
const resolvedPath = absoluteCallerPath(callerSelectedPath);
const started = await manager.start({
path: callerSelectedPath,
inferior_tty: await rizinTestInferiorTty(),