From c5862cc6eea70d23919d1df43f2886580c631109 Mon Sep 17 00:00:00 2001 From: jeremysamuel13 <70209502+jeremysamuel13@users.noreply.github.com> Date: Sun, 24 May 2026 08:46:11 -0400 Subject: [PATCH] fix: resolve symlinks in project directory paths (#1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve symlinks in project directory paths OpenCode stores sessions using the canonical (realpath) directory, but OpenChamber passed the unresolved symlink path in several places. The string-match directory filter would fail when a project was accessed via a symlink, making sessions invisible. Changes: - Add safeRealpathSync to settings normalization — project paths and lastDirectory are canonicalized at persistence time - Add Express middleware before the API proxy to resolve symlinks in ?directory= query params on in-flight requests - Resolve symlinks in /api/fs/list so the directory browser returns canonical paths, allowing the "already added" check to work correctly - Reconcile the in-memory projects store when the server responds with normalized paths, preventing temporary duplicates Fixes #1315 * fix: avoid sync realpath in opencode proxy --------- Co-authored-by: Bohdan Triapitsyn --- .../src/components/session/DirectoryTree.tsx | 20 +- packages/ui/src/lib/persistence.ts | 13 +- packages/web/server/index.js | 1 + packages/web/server/lib/fs/routes.js | 7 +- .../lib/opencode/project-directory-runtime.js | 8 +- .../project-directory-runtime.test.js | 226 ++++++++++++++++++ packages/web/server/lib/opencode/proxy.js | 43 ++++ .../web/server/lib/opencode/proxy.test.js | 72 ++++++ .../settings-normalization-runtime.js | 25 +- .../settings-normalization-runtime.test.js | 132 ++++++++++ .../web/server/lib/path-realpath-cache.js | 85 +++++++ .../server/lib/path-realpath-cache.test.js | 75 ++++++ 12 files changed, 695 insertions(+), 12 deletions(-) create mode 100644 packages/web/server/lib/opencode/project-directory-runtime.test.js create mode 100644 packages/web/server/lib/opencode/proxy.test.js create mode 100644 packages/web/server/lib/opencode/settings-normalization-runtime.test.js create mode 100644 packages/web/server/lib/path-realpath-cache.js create mode 100644 packages/web/server/lib/path-realpath-cache.test.js diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index 44488c7f..59cc8d06 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -42,6 +42,18 @@ interface DirectoryTreeProps { disabledPaths?: Iterable; } +const areStringSetsEqual = (left: Set, right: Set) => { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +}; + export const DirectoryTree: React.FC = ({ currentPath, onSelectPath, @@ -244,7 +256,10 @@ export const DirectoryTree: React.FC = ({ const normalizedPath = path.replace(/\\/g, '/'); return (stripTrailingSlashes(normalizedPath) as string) ?? normalizedPath; }); - setPinnedPaths(new Set(normalized)); + setPinnedPaths((prev) => { + const next = new Set(normalized); + return areStringSetsEqual(prev, next) ? prev : next; + }); }; const loadFromLocalStorage = () => { @@ -326,7 +341,8 @@ export const DirectoryTree: React.FC = ({ const filtered = Array.from(prev) .map((path) => (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path) .filter((path) => isPathWithinHome(path)); - return new Set(filtered); + const next = new Set(filtered); + return areStringSetsEqual(prev, next) ? prev : next; }); }, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index cbc46e30..35037334 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -119,6 +119,13 @@ const persistToLocalStorage = (settings: DesktopSettings) => { } }; +const dispatchSettingsSynced = (settings: DesktopSettings): void => { + if (typeof window === 'undefined') { + return; + } + window.dispatchEvent(new CustomEvent('openchamber:settings-synced', { detail: settings })); +}; + type PersistApi = { hasHydrated?: () => boolean; onFinishHydration?: (callback: () => void) => (() => void) | undefined; @@ -1165,9 +1172,7 @@ export const syncDesktopSettings = async (): Promise => { console.warn('applyDesktopUiPreferences failed:', error); } - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('openchamber:settings-synced', { detail: settings })); - } + dispatchSettingsSynced(settings); }; try { @@ -1198,6 +1203,7 @@ const _flushSettingsUpdate = async (): Promise => { if (updated) { persistToLocalStorage(updated); applyDesktopUiPreferences(updated); + dispatchSettingsSynced(updated); } return; } catch (error) { @@ -1224,6 +1230,7 @@ const _flushSettingsUpdate = async (): Promise => { if (updated) { persistToLocalStorage(updated); applyDesktopUiPreferences(updated); + dispatchSettingsSynced(updated); // Invalidate GET cache so next read sees the fresh data _settingsCache = null; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 2e579c2b..b6f9b6cf 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -198,6 +198,7 @@ const settingsNormalizationRuntime = createSettingsNormalizationRuntime({ os, path, processLike: process, + realpathSync: fs.realpathSync, tunnelBootstrapTtlDefaultMs: TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS, tunnelBootstrapTtlMinMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, tunnelBootstrapTtlMaxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index b747847a..9984e783 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -1,3 +1,5 @@ +import { createRealpathCache } from '../path-realpath-cache.js'; + const EXEC_JOB_TTL_MS = 30 * 60 * 1000; const createCommandTimeoutMs = () => { @@ -270,6 +272,9 @@ export const registerFsRoutes = (app, dependencies) => { resolveGitBinaryForSpawn, openchamberUserConfigRoot, } = dependencies; + const realpathCache = createRealpathCache({ + realpath: fsPromises.realpath.bind(fsPromises), + }); const execJobs = new Map(); const commandTimeoutMs = createCommandTimeoutMs(); @@ -1033,7 +1038,7 @@ export const registerFsRoutes = (app, dependencies) => { }; try { - resolvedPath = path.resolve(normalizeDirectoryPath(rawPath)); + resolvedPath = await realpathCache.resolve(path.resolve(normalizeDirectoryPath(rawPath))); const stats = await fsPromises.stat(resolvedPath); if (!stats.isDirectory()) { diff --git a/packages/web/server/lib/opencode/project-directory-runtime.js b/packages/web/server/lib/opencode/project-directory-runtime.js index 61ca5882..22248e5e 100644 --- a/packages/web/server/lib/opencode/project-directory-runtime.js +++ b/packages/web/server/lib/opencode/project-directory-runtime.js @@ -1,3 +1,5 @@ +import { createRealpathCache } from '../path-realpath-cache.js'; + export const createProjectDirectoryRuntime = (dependencies) => { const { fsPromises, @@ -7,6 +9,9 @@ export const createProjectDirectoryRuntime = (dependencies) => { getReadSettingsFromDiskMigrated, sanitizeProjects, } = dependencies; + const realpathCache = createRealpathCache({ + realpath: fsPromises.realpath.bind(fsPromises), + }); const resolveDirectoryCandidate = (value) => { if (typeof value !== 'string') { @@ -30,7 +35,8 @@ export const createProjectDirectoryRuntime = (dependencies) => { if (!stats.isDirectory()) { return { ok: false, error: 'Specified path is not a directory' }; } - return { ok: true, directory: resolved }; + const realPath = await realpathCache.resolve(resolved); + return { ok: true, directory: realPath }; } catch (error) { const err = error; if (err && typeof err === 'object' && err.code === 'ENOENT') { diff --git a/packages/web/server/lib/opencode/project-directory-runtime.test.js b/packages/web/server/lib/opencode/project-directory-runtime.test.js new file mode 100644 index 00000000..b02be6d8 --- /dev/null +++ b/packages/web/server/lib/opencode/project-directory-runtime.test.js @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; + +import { createProjectDirectoryRuntime } from './project-directory-runtime.js'; + +const createTestRuntime = (overrides = {}) => { + const defaults = { + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async (p) => p, + }, + path: { + resolve: (p) => p, + }, + normalizeDirectoryPath: (value) => value, + readSettingsFromDiskMigrated: async () => ({}), + getReadSettingsFromDiskMigrated: () => async () => ({}), + sanitizeProjects: (input) => input, + }; + + return createProjectDirectoryRuntime({ ...defaults, ...overrides }); +}; + +describe('project directory runtime', () => { + describe('validateDirectoryPath', () => { + it('returns resolved real path for a valid directory', async () => { + const runtime = createTestRuntime(); + const result = await runtime.validateDirectoryPath('/home/user/project'); + + expect(result).toEqual({ ok: true, directory: '/home/user/project' }); + }); + + it('resolves symlinks via fsPromises.realpath', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/path/to/project', + }, + }); + + const result = await runtime.validateDirectoryPath('/symlink/path/to/project'); + + expect(result).toEqual({ ok: true, directory: '/real/path/to/project' }); + }); + + it('returns error when candidate is empty', async () => { + const runtime = createTestRuntime(); + const result = await runtime.validateDirectoryPath(''); + + expect(result).toEqual({ ok: false, error: 'Directory parameter is required' }); + }); + + it('returns error when candidate is not a string', async () => { + const runtime = createTestRuntime(); + const result = await runtime.validateDirectoryPath(null); + + expect(result).toEqual({ ok: false, error: 'Directory parameter is required' }); + }); + + it('returns error when path is not a directory', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => false }), + realpath: async (p) => p, + }, + }); + + const result = await runtime.validateDirectoryPath('/some/file.txt'); + + expect(result).toEqual({ ok: false, error: 'Specified path is not a directory' }); + }); + + it('returns error when path does not exist', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => { throw { code: 'ENOENT' }; }, + realpath: async (p) => p, + }, + }); + + const result = await runtime.validateDirectoryPath('/nonexistent'); + + expect(result).toEqual({ ok: false, error: 'Directory not found' }); + }); + + it('returns error when access is denied', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => { throw { code: 'EACCES' }; }, + realpath: async (p) => p, + }, + }); + + const result = await runtime.validateDirectoryPath('/restricted'); + + expect(result).toEqual({ ok: false, error: 'Access to directory denied' }); + }); + + it('returns error when realpath fails after stat succeeds', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => { throw { code: 'ENOENT' }; }, + }, + }); + + const result = await runtime.validateDirectoryPath('/deleted-after-stat'); + + expect(result).toEqual({ ok: false, error: 'Directory not found' }); + }); + }); + + describe('resolveProjectDirectory', () => { + it('resolves symlinks in x-opencode-directory header', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/workspace/project', + }, + }); + + const req = { + get: (header) => header === 'x-opencode-directory' ? '/home/user/workspace/project' : null, + query: {}, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(result).toEqual({ directory: '/real/workspace/project', error: null }); + }); + + it('resolves symlinks in query directory parameter', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/workspace/project', + }, + }); + + const req = { + get: () => null, + query: { directory: '/home/user/workspace/project' }, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(result).toEqual({ directory: '/real/workspace/project', error: null }); + }); + + it('resolves symlinks in lastDirectory from settings', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/workspace/project', + }, + getReadSettingsFromDiskMigrated: () => async () => ({ + lastDirectory: '/home/user/workspace/project', + }), + }); + + const req = { + get: () => null, + query: {}, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(result).toEqual({ directory: '/real/workspace/project', error: null }); + }); + + it('resolves symlinks in active project path from settings', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/workspace/project', + }, + getReadSettingsFromDiskMigrated: () => async () => ({ + projects: [{ id: 'proj-1', path: '/home/user/workspace/project' }], + activeProjectId: 'proj-1', + }), + sanitizeProjects: (input) => input, + }); + + const req = { + get: () => null, + query: {}, + }; + + const result = await runtime.resolveProjectDirectory(req); + + expect(result).toEqual({ directory: '/real/workspace/project', error: null }); + }); + }); + + describe('resolveOptionalProjectDirectory', () => { + it('returns null directory when no directory is requested', async () => { + const runtime = createTestRuntime(); + + const req = { + get: () => null, + query: {}, + }; + + const result = await runtime.resolveOptionalProjectDirectory(req); + + expect(result).toEqual({ directory: null, error: null }); + }); + + it('resolves symlinks when directory is provided', async () => { + const runtime = createTestRuntime({ + fsPromises: { + stat: async () => ({ isDirectory: () => true }), + realpath: async () => '/real/workspace/project', + }, + }); + + const req = { + get: (header) => header === 'x-opencode-directory' ? '/symlink/workspace/project' : null, + query: {}, + }; + + const result = await runtime.resolveOptionalProjectDirectory(req); + + expect(result).toEqual({ directory: '/real/workspace/project', error: null }); + }); + }); +}); diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 3b09fc50..6b805a78 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -5,6 +5,31 @@ import { collectForwardProxyHeaders, shouldForwardProxyResponseHeader, } from '../../proxy-headers.js'; +import { createRealpathCache } from '../path-realpath-cache.js'; + +export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => { + const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions }); + + return async (requestUrl) => { + if (typeof requestUrl !== 'string' || !requestUrl.includes('directory=')) { + return requestUrl; + } + + const url = new URL(requestUrl, 'http://localhost'); + const directory = url.searchParams.get('directory'); + if (!directory) { + return requestUrl; + } + + const canonicalDirectory = await realpathCache.resolve(directory); + if (!canonicalDirectory || canonicalDirectory === directory) { + return requestUrl; + } + + url.searchParams.set('directory', canonicalDirectory); + return `${url.pathname}${url.search}`; + }; +}; export const waitForSseDrain = (res, signal) => new Promise((resolve) => { if (signal?.aborted || res.writableEnded || res.destroyed) { @@ -94,6 +119,9 @@ export const registerOpenCodeProxy = (app, deps) => { const isAbortError = (error) => error?.name === 'AbortError'; const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902'; + const canonicalizeDirectoryQuery = createDirectoryQueryCanonicalizer({ + realpath: fs?.promises?.realpath?.bind(fs.promises), + }); const normalizeProxyTarget = (candidate) => { if (typeof candidate !== 'string') { @@ -416,5 +444,20 @@ export const registerOpenCodeProxy = (app, deps) => { }, }); + // Best-effort fallback for stale clients still sending symlink paths. + // Settings and project selection normalize at source; this cached async path + // avoids blocking the proxy hot path on every directory-scoped request. + app.use('/api', async (req, _res, next) => { + try { + const rewrittenUrl = await canonicalizeDirectoryQuery(req.url); + if (rewrittenUrl !== req.url) { + req.url = rewrittenUrl; + } + } catch { + // Pass through as-is if URL parsing or realpath resolution fails. + } + next(); + }); + app.use('/api', apiProxy); }; diff --git a/packages/web/server/lib/opencode/proxy.test.js b/packages/web/server/lib/opencode/proxy.test.js new file mode 100644 index 00000000..5829b1e5 --- /dev/null +++ b/packages/web/server/lib/opencode/proxy.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { createDirectoryQueryCanonicalizer } from './proxy.js'; + +describe('createDirectoryQueryCanonicalizer', () => { + it('canonicalizes directory query params and preserves other params', async () => { + const canonicalize = createDirectoryQueryCanonicalizer({ + realpath: async (value) => value === '/link/project' ? '/real/project' : value, + }); + + await expect(canonicalize('/session?foo=1&directory=/link/project&bar=2')) + .resolves.toBe('/session?foo=1&directory=%2Freal%2Fproject&bar=2'); + }); + + it('caches directory realpath lookups', async () => { + let calls = 0; + const canonicalize = createDirectoryQueryCanonicalizer({ + realpath: async () => { + calls += 1; + return '/real/project'; + }, + }); + + await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject'); + await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject'); + expect(calls).toBe(1); + }); + + it('deduplicates concurrent directory realpath lookups', async () => { + let calls = 0; + let release = () => undefined; + const pending = new Promise((resolve) => { + release = () => resolve('/real/project'); + }); + const canonicalize = createDirectoryQueryCanonicalizer({ + realpath: async () => { + calls += 1; + return pending; + }, + }); + + const first = canonicalize('/session?directory=/link/project'); + const second = canonicalize('/session?directory=/link/project'); + await Promise.resolve(); + + expect(calls).toBe(1); + release(); + await expect(Promise.all([first, second])).resolves.toEqual([ + '/session?directory=%2Freal%2Fproject', + '/session?directory=%2Freal%2Fproject', + ]); + }); + + it('falls back to the original URL when realpath fails', async () => { + const canonicalize = createDirectoryQueryCanonicalizer({ + realpath: async () => { + throw new Error('missing'); + }, + }); + + await expect(canonicalize('/session?foo=1&directory=/missing/project')) + .resolves.toBe('/session?foo=1&directory=/missing/project'); + }); + + it('leaves URLs without directory params unchanged', async () => { + const canonicalize = createDirectoryQueryCanonicalizer({ + realpath: async () => '/real/project', + }); + + await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1'); + }); +}); diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js index 5c5c6c9c..89810edd 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -3,6 +3,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => { os, path, processLike, + realpathSync, tunnelBootstrapTtlDefaultMs, tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs, @@ -32,7 +33,19 @@ export const createSettingsNormalizationRuntime = (dependencies) => { return trimmed; }; - const normalizePathForPersistence = (value) => { + // Resolve symlinks, falling back to the original value on failure. + const safeRealpathSync = (value) => { + if (!realpathSync || typeof value !== 'string' || !value) { + return value; + } + try { + return realpathSync(value); + } catch { + return value; + } + }; + + const normalizePathForPersistence = (value, options = {}) => { if (typeof value !== 'string') { return value; } @@ -47,11 +60,13 @@ export const createSettingsNormalizationRuntime = (dependencies) => { return trimmed; } + const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed); + if (processLike.platform !== 'win32') { - return trimmed; + return resolved; } - return trimmed.replace(/\//g, '\\'); + return resolved.replace(/\//g, '\\'); }; const areStringArraysEqual = (a, b) => { @@ -107,8 +122,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => { const candidate = entry; const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; - const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : ''; + const resolvedPath = rawPath ? safeRealpathSync(path.resolve(normalizeDirectoryPath(rawPath))) : ''; + const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath, { resolveRealpath: false }) : ''; const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : ''; const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object' diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.test.js b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js new file mode 100644 index 00000000..3fd6f377 --- /dev/null +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js'; + +const createTestRuntime = (overrides = {}) => { + const defaults = { + os: { homedir: () => '/home/testuser' }, + path: { + resolve: (...args) => args[args.length - 1], + sep: '/', + dirname: (p) => p.split('/').slice(0, -1).join('/') || '/', + }, + processLike: { platform: 'linux', env: {} }, + realpathSync: (p) => p, + tunnelBootstrapTtlDefaultMs: 600000, + tunnelBootstrapTtlMinMs: 60000, + tunnelBootstrapTtlMaxMs: 3600000, + tunnelSessionTtlDefaultMs: 86400000, + tunnelSessionTtlMinMs: 3600000, + tunnelSessionTtlMaxMs: 604800000, + }; + + return createSettingsNormalizationRuntime({ ...defaults, ...overrides }); +}; + +describe('settings normalization runtime - symlink resolution', () => { + describe('normalizePathForPersistence', () => { + it('resolves symlinks via realpathSync', () => { + const runtime = createTestRuntime({ + realpathSync: (p) => + p === '/home/user/workplace' ? '/workplace/user' : p, + }); + + const result = runtime.normalizePathForPersistence('/home/user/workplace'); + expect(result).toBe('/workplace/user'); + }); + + it('falls back to original path when realpathSync throws', () => { + const runtime = createTestRuntime({ + realpathSync: () => { + throw new Error('ENOENT'); + }, + }); + + const result = runtime.normalizePathForPersistence('/nonexistent/path'); + expect(result).toBe('/nonexistent/path'); + }); + + it('passes through when realpathSync is not provided', () => { + const runtime = createTestRuntime({ realpathSync: undefined }); + + const result = runtime.normalizePathForPersistence('/some/path'); + expect(result).toBe('/some/path'); + }); + }); + + describe('sanitizeProjects', () => { + it('resolves symlinks in project paths', () => { + const runtime = createTestRuntime({ + realpathSync: (p) => + p === '/home/user/workplace/MyProject' + ? '/workplace/user/MyProject' + : p, + }); + + const projects = [ + { id: 'proj1', path: '/home/user/workplace/MyProject', label: 'MyProject', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 }, + ]; + + const result = runtime.sanitizeProjects(projects); + expect(result[0].path).toBe('/workplace/user/MyProject'); + }); + + it('falls back to path.resolve when realpathSync throws', () => { + const runtime = createTestRuntime({ + realpathSync: () => { throw new Error('ENOENT'); }, + path: { resolve: (p) => '/resolved' + p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' }, + }); + + const projects = [ + { id: 'proj1', path: '/missing/path', label: 'Missing', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 }, + ]; + + const result = runtime.sanitizeProjects(projects); + expect(result[0].path).toBe('/resolved/missing/path'); + }); + + it('deduplicates projects that resolve to the same realpath', () => { + const runtime = createTestRuntime({ + realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p, + path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' }, + }); + + const projects = [ + { id: 'proj1', path: '/symlink/a', label: 'A', color: 'primary', addedAt: 1000, lastOpenedAt: 1000 }, + { id: 'proj2', path: '/symlink/b', label: 'B', color: 'keyword', addedAt: 2000, lastOpenedAt: 2000 }, + ]; + + const result = runtime.sanitizeProjects(projects); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('proj1'); + }); + }); + + describe('normalizeSettingsPaths', () => { + it('resolves symlinks in lastDirectory', () => { + const runtime = createTestRuntime({ + realpathSync: (p) => + p === '/home/user/workplace/LyraRefactoring' + ? '/workplace/user/LyraRefactoring' + : p, + }); + + const result = runtime.normalizeSettingsPaths({ + lastDirectory: '/home/user/workplace/LyraRefactoring', + }); + + expect(result.changed).toBe(true); + expect(result.settings.lastDirectory).toBe('/workplace/user/LyraRefactoring'); + }); + + it('does not flag as changed when path is already canonical', () => { + const runtime = createTestRuntime(); + + const result = runtime.normalizeSettingsPaths({ + lastDirectory: '/real/path', + }); + + expect(result.changed).toBe(false); + }); + }); +}); diff --git a/packages/web/server/lib/path-realpath-cache.js b/packages/web/server/lib/path-realpath-cache.js new file mode 100644 index 00000000..6689a79d --- /dev/null +++ b/packages/web/server/lib/path-realpath-cache.js @@ -0,0 +1,85 @@ +export const createRealpathCache = ({ + realpath, + successTtlMs = 600_000, + failureTtlMs = 60_000, + maxEntries = 256, + fallbackOnError = false, + now = () => Date.now(), +} = {}) => { + const cache = new Map(); + const resolveRealpath = typeof realpath === 'function' ? realpath : null; + + const prune = () => { + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) { + return; + } + cache.delete(oldestKey); + } + }; + + const remember = (key, entry, ttlMs) => { + if (!Number.isFinite(maxEntries) || maxEntries <= 0) { + return; + } + cache.delete(key); + cache.set(key, { ...entry, expiresAt: now() + Math.max(0, ttlMs) }); + prune(); + }; + + const resolve = async (value) => { + if (typeof value !== 'string' || value.length === 0 || !resolveRealpath) { + return value; + } + + const cached = cache.get(value); + const currentTime = now(); + if (cached?.promise) { + return cached.promise; + } + if (cached && cached.expiresAt > currentTime) { + cache.delete(value); + cache.set(value, cached); + if (cached.error) { + if (fallbackOnError) { + return value; + } + throw cached.error; + } + return cached.value; + } + if (cached) { + cache.delete(value); + } + + const promise = Promise.resolve() + .then(() => resolveRealpath(value)) + .then((resolved) => { + const next = typeof resolved === 'string' && resolved.length > 0 ? resolved : value; + remember(value, { value: next }, successTtlMs); + return next; + }) + .catch((error) => { + remember(value, { value, error }, failureTtlMs); + if (!fallbackOnError) { + throw error; + } + return value; + }); + + if (Number.isFinite(maxEntries) && maxEntries > 0) { + cache.delete(value); + cache.set(value, { value, expiresAt: 0, promise }); + prune(); + } + + return promise; + }; + + return { + resolve, + clear: () => cache.clear(), + size: () => cache.size, + }; +}; diff --git a/packages/web/server/lib/path-realpath-cache.test.js b/packages/web/server/lib/path-realpath-cache.test.js new file mode 100644 index 00000000..03b97c3b --- /dev/null +++ b/packages/web/server/lib/path-realpath-cache.test.js @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { createRealpathCache } from './path-realpath-cache.js'; + +describe('createRealpathCache', () => { + it('caches successful realpath lookups until the success TTL expires', async () => { + let now = 1_000; + let calls = 0; + const cache = createRealpathCache({ + now: () => now, + successTtlMs: 1_000, + realpath: async () => { + calls += 1; + return `/real-${calls}`; + }, + }); + + await expect(cache.resolve('/link')).resolves.toBe('/real-1'); + await expect(cache.resolve('/link')).resolves.toBe('/real-1'); + expect(calls).toBe(1); + + now += 1_001; + await expect(cache.resolve('/link')).resolves.toBe('/real-2'); + expect(calls).toBe(2); + }); + + it('shares in-flight realpath lookups for the same path', async () => { + let calls = 0; + let release = () => undefined; + const pending = new Promise((resolve) => { + release = () => resolve('/real/path'); + }); + const cache = createRealpathCache({ + realpath: async () => { + calls += 1; + return pending; + }, + }); + + const first = cache.resolve('/link/path'); + const second = cache.resolve('/link/path'); + await Promise.resolve(); + + expect(calls).toBe(1); + release(); + await expect(Promise.all([first, second])).resolves.toEqual(['/real/path', '/real/path']); + }); + + it('throws realpath failures by default', async () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const cache = createRealpathCache({ + realpath: async () => { + throw error; + }, + }); + + await expect(cache.resolve('/missing')).rejects.toBe(error); + }); + + it('can fall back to the original path and cache failures briefly', async () => { + let calls = 0; + const cache = createRealpathCache({ + fallbackOnError: true, + failureTtlMs: 1_000, + realpath: async () => { + calls += 1; + throw new Error('missing'); + }, + }); + + await expect(cache.resolve('/missing')).resolves.toBe('/missing'); + await expect(cache.resolve('/missing')).resolves.toBe('/missing'); + expect(calls).toBe(1); + }); +});