diff --git a/docs/electron-fuses.md b/docs/electron-fuses.md index 206ea24bb..4b02f7367 100644 --- a/docs/electron-fuses.md +++ b/docs/electron-fuses.md @@ -20,6 +20,11 @@ requires an absolute filesystem path and preserves that exact selection: } ``` +On POSIX hosts, the CLI preserves path components so symlinks are followed +before parent (`..`) steps. Bundle selections may end in `.` or parent steps +through real directories; a terminal parent step through a directory symlink +is rejected. Windows CLI paths retain native drive and separator resolution. + Check this before `observe_javascript_runtime` with `electron-main`. When `EnableNodeCliInspectArguments` is disabled or removed, Electron ignores Inspector flags, and a later target listing can simply show no main-process diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 37ee3e70b..1bf8c1066 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { isAbsolute, resolve, sep } from "node:path"; import { Cli, z } from "incur"; @@ -55,7 +55,7 @@ const registerElectronFuseCommand = ( withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectElectronFuses, async () => { const result = await service.inspect( - { path: resolve(args.path) }, + { path: absoluteElectronFusePath(args.path) }, { signal }, ); return result.ok ? result.value : projectAnalysisError(result.error); @@ -64,6 +64,16 @@ const registerElectronFuseCommand = ( }); }; +/** Anchor relative input without changing which file symlink/.. selects. */ +const absoluteElectronFusePath = (path: string): string => { + if (path === "") return path; + // Win32 resolves dot components before directory links, including drive-relative paths. + if (sep === "\\") return resolve(path); + if (isAbsolute(path)) return path; + const base = process.cwd(); + return `${base}${base.endsWith(sep) ? "" : sep}${path}`; +}; + const registerElectronActiveCommand = ( cli: ReturnType, logger: Logger, diff --git a/src/electron/ElectronFusePath.ts b/src/electron/ElectronFusePath.ts index 399e001f5..0836c961c 100644 --- a/src/electron/ElectronFusePath.ts +++ b/src/electron/ElectronFusePath.ts @@ -1,6 +1,6 @@ import type { BigIntStats } from "node:fs"; import { lstat, readlink } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, sep } from "node:path"; +import { basename, dirname, isAbsolute, sep } from "node:path"; import { isPathWithinRoot } from "../domain/localPath.js"; @@ -56,9 +56,13 @@ export const resolveElectronFuseTarget = async ( frameworkRootOf(selectedPath) === null ? { path: selectedPath, metadata, nodes: [] } : await resolveFrameworkSymlink(selectedPath, signal); - else if (metadata.isDirectory()) + else if (metadata.isDirectory()) { file = await resolveBundleBinary(selectedPath, signal); - else + file = { + ...file, + nodes: [{ path: selectedPath, metadata }, ...file.nodes], + }; + } else throw new ElectronFusePathFailure( "invalid_format", `Expected a regular file or Electron app bundle: ${selectedPath}`, @@ -94,20 +98,58 @@ const resolveBundleBinary = async ( selectedPath: string, signal?: AbortSignal, ): Promise => { - const name = basename(selectedPath); + const directory = await resolveDirectorySuffix(selectedPath, signal); + const name = basename(directory.path); + let file: ResolvedElectronFuseFile; if (name === FRAMEWORK_NAME) - return resolveKnownBinary(selectedPath, selectedPath, signal); - if (name.endsWith(".app")) { - return resolveKnownBinary( - join(selectedPath, "Contents", "Frameworks", FRAMEWORK_NAME), + file = await resolveKnownBinary(directory.path, selectedPath, signal); + else if (name.endsWith(".app")) + file = await resolveKnownBinary( + appendPath(directory.path, "Contents", "Frameworks", FRAMEWORK_NAME), selectedPath, signal, ); + else + throw new ElectronFusePathFailure( + "invalid_format", + `Expected an Electron .app bundle, ${FRAMEWORK_NAME} directory, or executable file: ${selectedPath}`, + ); + return { ...file, nodes: [...directory.nodes, ...file.nodes] }; +}; + +/** Reduce directory suffixes only when doing so preserves filesystem selection. */ +const resolveDirectorySuffix = async ( + selectedPath: string, + signal?: AbortSignal, +): Promise<{ + readonly path: string; + readonly nodes: readonly ElectronFusePathNode[]; +}> => { + let path = selectedPath; + const pending: string[] = []; + const nodes: ElectronFusePathNode[] = []; + while (true) { + signal?.throwIfAborted(); + const name = basename(path); + // lstat("link/") follows a link; inspect the named component itself. + if (name !== "") while (path.endsWith(sep)) path = path.slice(0, -1); + if (name === "." || name === "..") { + pending.push(name); + path = dirname(path); + continue; + } + const component = pending.pop(); + if (component === undefined) return { path, nodes }; + if (component === ".") continue; + const metadata = await lstat(path, { bigint: true }); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new ElectronFusePathFailure( + "invalid_value", + `A bundle path ending in '..' requires a real directory before each parent segment: ${selectedPath}`, + ); + nodes.push({ path, metadata }); + path = dirname(path); } - throw new ElectronFusePathFailure( - "invalid_format", - `Expected an Electron .app bundle, ${FRAMEWORK_NAME} directory, or executable file: ${selectedPath}`, - ); }; /** `root` is the framework directory for both an `.app` and a framework selection. */ @@ -154,7 +196,7 @@ const resolveInside = async ( last = "directory"; continue; } - const absolute = join(root, ...resolved, segment); + const absolute = appendPath(root, ...resolved, segment); if (!isPathWithinRoot(root, absolute)) return { kind: "escapes" }; let metadata; try { @@ -185,7 +227,7 @@ const resolveInside = async ( } if (last !== "file" || fileMetadata === undefined) return { kind: "not-file" }; - const path = join(root, ...resolved); + const path = appendPath(root, ...resolved); return isPathWithinRoot(root, path) ? { kind: "file", path, metadata: fileMetadata, nodes } : { kind: "escapes" }; @@ -229,7 +271,7 @@ const requireRealDirectories = async ( let current = anchor; const nodes: ElectronFusePathNode[] = []; for (const segment of segments) { - current = join(current, segment); + current = appendPath(current, segment); let metadata; try { metadata = await lstat(current, { bigint: true }); @@ -301,6 +343,10 @@ const relativeInside = (root: string, path: string): string | null => { return value === "" ? null : value.split(sep).join("/"); }; +/** Append known components without normalizing a caller-selected parent path. */ +const appendPath = (parent: string, ...segments: readonly string[]): string => + `${parent}${parent.endsWith(sep) ? "" : sep}${segments.join(sep)}`; + const isMissing = (cause: unknown): boolean => cause instanceof Error && "code" in cause && diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts index fa43f4787..dd6227caa 100644 --- a/src/electron/ElectronFuseProvider.test.ts +++ b/src/electron/ElectronFuseProvider.test.ts @@ -682,3 +682,20 @@ it("reports a checked version directory removed before open as changed", async ( readElectronFuseImage(located.file.path, { selection: located.file }), ).rejects.toBeInstanceOf(RegularFileChangedError); }); + +it("retains the identity of a directory removed by a terminal parent segment", async () => { + const root = await createTestTempDirectory("rea-electron-parent-identity-"); + const framework = join(root, "Electron Framework.framework"); + const versions = join(framework, "Versions"); + await mkdir(versions, { recursive: true }); + await writeFile( + join(framework, "Electron Framework"), + thinFuseImage([0x31]).bytes, + ); + const located = await resolveElectronFuseTarget(`${versions}/..`); + await rename(versions, `${versions}.saved`); + await mkdir(versions); + await expect( + readElectronFuseImage(located.file.path, { selection: located.file }), + ).rejects.toBeInstanceOf(RegularFileChangedError); +}); diff --git a/tests/acceptance/electronFusesCli.test.ts b/tests/acceptance/electronFusesCli.test.ts index 763d2faf5..e1e86f276 100644 --- a/tests/acceptance/electronFusesCli.test.ts +++ b/tests/acceptance/electronFusesCli.test.ts @@ -1,5 +1,5 @@ -import { mkdir, symlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { dirname, join, parse, sep } from "node:path"; import { createHash } from "node:crypto"; import { expect } from "vitest"; @@ -13,6 +13,142 @@ import { import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; +// Windows resolves dot components before directory links in Win32 paths. +cliTest.skipIf(process.platform === "win32").for([ + { kind: "binary", mode: "absolute" }, + { kind: "binary", mode: "relative" }, + { kind: "bundle", mode: "absolute" }, + { kind: "bundle", mode: "relative" }, +] as const)( + "preserves $mode $kind selections containing a symlink before a parent segment", + async ({ kind, mode }, { cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-cli-path-"); + const framework = "Contents/Frameworks/Electron Framework.framework"; + const directory = + kind === "binary" + ? join(root, "Fixture.app", framework, "Versions") + : root; + const selectedTail = + kind === "binary" ? "A/Electron Framework" : "Fixture.app"; + const binaryTail = + kind === "binary" + ? selectedTail + : `Fixture.app/${framework}/Versions/A/Electron Framework`; + const binary = join(directory, "nested", binaryTail); + const sibling = join(directory, binaryTail); + const image = thinFuseImage([0x31, 0x30, 0x30, 0x31]); + const otherImage = thinFuseImage([0x30, 0x30, 0x30, 0x30]); + await mkdir(dirname(binary), { recursive: true }); + await mkdir(dirname(sibling), { recursive: true }); + await mkdir(join(directory, "nested", "B"), { recursive: true }); + await writeFile(binary, image.bytes); + await writeFile(sibling, otherImage.bytes); + await symlink("nested/B", join(directory, "Current")); + const selected = `${directory}/Current/../${selectedTail}`; + expect(await realpath(selected)).toBe( + kind === "binary" ? binary : join(root, "nested", "Fixture.app"), + ); + + const result = await cli.run({ + arguments: [ + "inspect-electron-fuses", + mode === "absolute" ? selected : selected.slice(root.length + 1), + "--json", + ], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_electron_fuses").outputSchema.parse( + result.json, + ); + const digest = createHash("sha256").update(image.bytes).digest("hex"); + expect(evidence.normalized_result.artifact).toMatchObject({ + selected_path: selected, + resolved_path: + kind === "binary" + ? binary + : `${selected}/${framework}/Versions/A/Electron Framework`, + sha256: digest, + }); + expect( + await realpath(evidence.normalized_result.artifact.resolved_path), + ).toBe(binary); + expect(evidence.subject?.local_path).toBe( + evidence.normalized_result.artifact.resolved_path, + ); + expect(evidence.subject?.digest.sha256).toBe(digest); + expect(evidence.normalized_result.runtime_implications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: "electron_run_as_node", + outcome: "honored", + }), + expect.objectContaining({ + subject: "node_cli_inspect_arguments", + outcome: "honored", + }), + ]), + ); + + const control = await cli.run({ + arguments: ["inspect-electron-fuses", sibling, "--json"], + }); + expect(control.exitCode, control.stderr).toBe(0); + const siblingEvidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(control.json); + expect(siblingEvidence.normalized_result.artifact.sha256).toBe( + createHash("sha256").update(otherImage.bytes).digest("hex"), + ); + expect(siblingEvidence.normalized_result.runtime_implications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: "electron_run_as_node", + outcome: "not_honored", + }), + expect.objectContaining({ + subject: "node_cli_inspect_arguments", + outcome: "not_honored", + }), + ]), + ); + }, +); + +cliTest.skipIf(process.platform !== "win32")( + "retains Windows drive, root, and mixed-separator path resolution", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-win-path-"); + const tail = + "Fixture.app/Contents\\Frameworks/Electron Framework.framework/Versions\\A/Electron Framework"; + const binary = join(root, tail); + const image = thinFuseImage([0x31]); + await mkdir(dirname(binary), { recursive: true }); + await writeFile(binary, image.bytes); + const volume = parse(root).root; + for (const input of [ + binary.replaceAll("\\", "/"), + tail, + `${volume.slice(0, -1)}${tail}`, + binary.slice(volume.length - 1), + ]) { + const result = await cli.run({ + arguments: ["inspect-electron-fuses", input, "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(result.json); + expect(evidence.normalized_result.artifact).toMatchObject({ + selected_path: binary, + resolved_path: binary, + sha256: createHash("sha256").update(image.bytes).digest("hex"), + }); + } + }, +); + cliTest( "reads thin and universal Electron fuse wires through the public CLI", async ({ cli }) => { @@ -116,19 +252,80 @@ cliTest( await mkdir(join(binary, ".."), { recursive: true }); await writeFile(binary, image.bytes); await symlink("A", join(binary, "..", "..", "Current")); - const result = await cli.run({ - arguments: ["inspect-electron-fuses", app, "--json"], + for (const { input, cwd, selected } of [ + { input: app, cwd: root, selected: app }, + { input: `${app}/./`, cwd: root, selected: `${app}/./` }, + { input: "Fixture.app/.", cwd: root, selected: `${app}/.` }, + { input: ".", cwd: app, selected: `${app}${sep}.` }, + { + input: "..", + cwd: join(app, "Contents"), + selected: `${app}${sep}Contents${sep}..`, + }, + { + input: ".././..", + cwd: join(app, "Contents", "Frameworks"), + selected: `${app}${sep}Contents${sep}Frameworks${sep}.././..`, + }, + ]) { + const result = await cli.run({ + arguments: ["inspect-electron-fuses", input, "--json"], + cwd, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(result.json); + expect(evidence.normalized_result.artifact).toMatchObject({ + selected_path: process.platform === "win32" ? app : selected, + resolved_path: binary, + format: "mach-o", + }); + expect(evidence.subject?.local_path).toBe(binary); + } + + const empty = await cli.run({ + arguments: ["inspect-electron-fuses", "", "--json"], + cwd: app, }); - expect(result.exitCode, result.stderr).toBe(0); - const evidence = toolContract("inspect_electron_fuses").outputSchema.parse( - result.json, + expect(empty.exitCode, empty.stderr).toBe(1); + expect(empty.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_electron_fuses" }, + }); + }, +); + +cliTest.skipIf(process.platform === "win32")( + "refuses a terminal bundle parent segment after a directory symlink", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-electron-fuses-cli-parent-", ); - expect(evidence.normalized_result.artifact).toMatchObject({ - selected_path: app, - resolved_path: binary, - format: "mach-o", + const app = join(root, "Fixture.app"); + const binary = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + "Electron Framework", + ); + await mkdir(dirname(binary), { recursive: true }); + await writeFile(binary, thinFuseImage([0x31]).bytes); + await symlink("Contents", join(app, "linked")); + const result = await cli.run({ + arguments: ["inspect-electron-fuses", `${app}/linked//..`, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_electron_fuses", + issues: [ + expect.objectContaining({ path: ["path"], reason: "invalid_value" }), + ], + }, }); - expect(evidence.subject?.local_path).toBe(binary); }, );