diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6f7bc180..466929f6 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -33,6 +33,7 @@ import { buildDaemonEnv, resolveShellEnv, type ShellRunner } from "./shared/shel import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; +import { pathInside, samePath } from "./shared/path-identity"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -307,21 +308,6 @@ function daemonEnv(): NodeJS.ProcessEnv { return buildDaemonEnv(process.env, cachedShellEnv, telemetryOverrides()); } -function pathKey(value: string): string { - const resolved = path.resolve(value); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -function samePath(a: string, b: string): boolean { - return pathKey(a) === pathKey(b); -} - -function pathInside(child: string, parent: string): boolean { - const childKey = pathKey(child); - const parentKey = pathKey(parent); - return childKey === parentKey || childKey.startsWith(parentKey + path.sep); -} - function processAlive(pid: number): boolean { if (!pid) return false; try { diff --git a/frontend/src/shared/path-identity.test.ts b/frontend/src/shared/path-identity.test.ts new file mode 100644 index 00000000..03e7ce48 --- /dev/null +++ b/frontend/src/shared/path-identity.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { pathInside, samePath } from "./path-identity"; + +const tempDirs: string[] = []; + +function tempDir() { + const dir = mkdtempSync(path.join(os.tmpdir(), "ao-path-identity-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("path identity", () => { + it("matches paths that resolve to the same real directory", () => { + const dir = tempDir(); + const real = realpathSync.native(dir); + expect(samePath(path.join(real, "."), real)).toBe(true); + }); + + it("treats Windows paths as case-insensitive", () => { + expect(samePath("C:\\Users\\me\\AO\\backend", "c:\\users\\me\\ao\\backend", "win32")).toBe(true); + }); + + it("uses realpath canonical casing on macOS case-insensitive volumes", () => { + if (process.platform !== "darwin") return; + + const current = realpathSync.native(process.cwd()); + const lowerCased = current.toLowerCase(); + + expect(samePath(lowerCased, current, "darwin")).toBe(true); + }); + + it("detects children after canonicalization", () => { + const dir = tempDir(); + const child = path.join(dir, "nested"); + + expect(pathInside(child, dir)).toBe(true); + expect(pathInside(dir, child)).toBe(false); + }); +}); diff --git a/frontend/src/shared/path-identity.ts b/frontend/src/shared/path-identity.ts new file mode 100644 index 00000000..42ab049c --- /dev/null +++ b/frontend/src/shared/path-identity.ts @@ -0,0 +1,40 @@ +import { existsSync, realpathSync } from "node:fs"; +import path from "node:path"; + +type Platform = NodeJS.Platform; + +function canonicalPath(value: string): string { + const resolved = path.resolve(value); + try { + return realpathSync.native(resolved); + } catch { + let current = resolved; + const missingParts: string[] = []; + while (!existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) return resolved; + missingParts.unshift(path.basename(current)); + current = parent; + } + try { + return path.join(realpathSync.native(current), ...missingParts); + } catch { + return resolved; + } + } +} + +export function pathIdentityKey(value: string, platform: Platform = process.platform): string { + const canonical = canonicalPath(value); + return platform === "win32" ? canonical.toLowerCase() : canonical; +} + +export function samePath(a: string, b: string, platform: Platform = process.platform): boolean { + return pathIdentityKey(a, platform) === pathIdentityKey(b, platform); +} + +export function pathInside(child: string, parent: string, platform: Platform = process.platform): boolean { + const childKey = pathIdentityKey(child, platform); + const parentKey = pathIdentityKey(parent, platform); + return childKey === parentKey || childKey.startsWith(parentKey + path.sep); +}