From fc791bb606d56d0b5c986237886b9eb59c425825 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 05:19:43 -0700 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- src/application/BinaryTargetResolver.ts | 5 +- .../reverse/ReverseEngineeringService.ts | 5 +- src/artifacts/SafeOutputTree.ts | 6 +- src/cli/binaryDiagnosticsCommands.ts | 8 +- src/cli/captureCommands.ts | 4 +- src/cli/coreBinaryCommands.ts | 4 +- src/cli/evmCommands.ts | 7 +- src/cli/firmwareCommands.ts | 8 +- src/cli/goCommands.ts | 8 +- src/cli/rustCommands.ts | 4 +- src/cli/signalCommands.ts | 4 +- src/cli/sqliteCommands.ts | 4 +- src/cli/wasmCommands.ts | 10 +- src/filesystem/callerPath.ts | 32 +++++++ src/firmware/FirmwareFiles.ts | 5 +- src/objdump/ObjdumpCommand.ts | 8 +- src/rizin/RizinCommand.ts | 5 +- src/rizin/RizinDebugSessionManager.ts | 4 +- src/wasm/WabtOutput.test.ts | 20 ++++ src/wasm/WasmGlueAdapter.ts | 19 ++-- .../cli/callerPathSymlinkParentCli.test.ts | 90 ++++++++++++++++++ tests/boundary/filesystem/callerPath.test.ts | 41 +++++++++ .../filesystem/symlinkParentOwners.test.ts | 92 +++++++++++++++++++ .../mcp/rizinDebuggerCancellationMcp.test.ts | 5 +- ...RizinDebugSessionReviewRegressions.test.ts | 8 +- 25 files changed, 352 insertions(+), 54 deletions(-) create mode 100644 src/filesystem/callerPath.ts create mode 100644 tests/boundary/cli/callerPathSymlinkParentCli.test.ts create mode 100644 tests/boundary/filesystem/callerPath.test.ts create mode 100644 tests/boundary/filesystem/symlinkParentOwners.test.ts diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 41383ff250..1e206801b1 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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); diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 41589747b8..8818bb8eff 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -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) diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 0cf4dab98c..4c661f1980 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -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"); diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index f57e773a70..502e51145f 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -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 }, diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index 542361e906..241edb89b6 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -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 }, ), diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 3f9c7f0c0b..3ea172746c 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -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 diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index 883b4117b7..7d18066c99 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -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 diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 3921d85218..ff584c0258 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -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 } }), diff --git a/src/cli/goCommands.ts b/src/cli/goCommands.ts index 12b2b992a7..44807c4fbc 100644 --- a/src/cli/goCommands.ts +++ b/src/cli/goCommands.ts @@ -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", diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index fc3f8ee858..68ca0f28b4 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -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); diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index 63b7d9edf2..497cd53e76 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -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 }, ), diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 49ded4801d..39e5165f98 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -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 }), diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index 73b8f8d3fd..db2c3d4321 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -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 }, diff --git a/src/filesystem/callerPath.ts b/src/filesystem/callerPath.ts new file mode 100644 index 0000000000..cf7ff99c44 --- /dev/null +++ b/src/filesystem/callerPath.ts @@ -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 ? "/" : ""}`; +}; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index d20226e76b..11e0b9e1b3 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -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"); diff --git a/src/objdump/ObjdumpCommand.ts b/src/objdump/ObjdumpCommand.ts index bca39bff46..33fe303032 100644 --- a/src/objdump/ObjdumpCommand.ts +++ b/src/objdump/ObjdumpCommand.ts @@ -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; - return [...argumentsByOperation[input.operation], resolve(input.path)]; + return [ + ...argumentsByOperation[input.operation], + absoluteCallerPath(input.path), + ]; }; diff --git a/src/rizin/RizinCommand.ts b/src/rizin/RizinCommand.ts index e2431da632..5f6bc69b6e 100644 --- a/src/rizin/RizinCommand.ts +++ b/src/rizin/RizinCommand.ts @@ -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), ]; diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index fbad4a9e61..c4222c0b69 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -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, diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index 91740f09d8..536769bb2d 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -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"], + }, + ]); +}); diff --git a/src/wasm/WasmGlueAdapter.ts b/src/wasm/WasmGlueAdapter.ts index 91c5242707..aac9664957 100644 --- a/src/wasm/WasmGlueAdapter.ts +++ b/src/wasm/WasmGlueAdapter.ts @@ -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 diff --git a/tests/boundary/cli/callerPathSymlinkParentCli.test.ts b/tests/boundary/cli/callerPathSymlinkParentCli.test.ts new file mode 100644 index 0000000000..a7d4ef2ca0 --- /dev/null +++ b/tests/boundary/cli/callerPathSymlinkParentCli.test.ts @@ -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, + ); + }, +); diff --git a/tests/boundary/filesystem/callerPath.test.ts b/tests/boundary/filesystem/callerPath.test.ts new file mode 100644 index 0000000000..98d8f5a5ec --- /dev/null +++ b/tests/boundary/filesystem/callerPath.test.ts @@ -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"); +}); diff --git a/tests/boundary/filesystem/symlinkParentOwners.test.ts b/tests/boundary/filesystem/symlinkParentOwners.test.ts new file mode 100644 index 0000000000..62953a2909 --- /dev/null +++ b/tests/boundary/filesystem/symlinkParentOwners.test.ts @@ -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(); + } + }); + }, +); diff --git a/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts index be5070301a..67d14090ff 100644 --- a/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts +++ b/tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts @@ -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, diff --git a/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts index 749995a3d0..49f214fb89 100644 --- a/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts @@ -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(),