fix: resolve symlinks in project directory paths (#1316)
* 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 <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
90b3d4760e
commit
c5862cc6ee
@@ -42,6 +42,18 @@ interface DirectoryTreeProps {
|
||||
disabledPaths?: Iterable<string>;
|
||||
}
|
||||
|
||||
const areStringSetsEqual = (left: Set<string>, right: Set<string>) => {
|
||||
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<DirectoryTreeProps> = ({
|
||||
currentPath,
|
||||
onSelectPath,
|
||||
@@ -244,7 +256,10 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
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<DirectoryTreeProps> = ({
|
||||
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]);
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
|
||||
};
|
||||
|
||||
type PersistApi = {
|
||||
hasHydrated?: () => boolean;
|
||||
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
|
||||
@@ -1165,9 +1172,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
console.warn('applyDesktopUiPreferences failed:', error);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
|
||||
}
|
||||
dispatchSettingsSynced(settings);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1198,6 +1203,7 @@ const _flushSettingsUpdate = async (): Promise<void> => {
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -1224,6 +1230,7 @@ const _flushSettingsUpdate = async (): Promise<void> => {
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user