* test: add regression test for stat directory scoping (#3019) * fix: scope file-reference stat probes to the session directory (#3019) The /api/fs/stat probe sent by the markdown renderer carried no directory hint, so the server resolved the workspace from settings.lastDirectory. When the browsed directory differs from the session directory the stat returns 400 and the renderer treats it as file-does-not-exist, silently disabling file-reference links. Send effectiveDirectory as x-opencode-directory on the probe and qualify the stat cache key by directory so a rejection under one directory cannot leak into another. The probe logic moves from MarkdownRendererImpl into fileReferenceStat with unit coverage.
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
|
|
import { normalizeReferencePath } from './fileReferenceParser';
|
|
|
|
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
|
|
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
|
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
|
|
|
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
|
let activeFileReferenceStatCount = 0;
|
|
const pendingFileReferenceStats: Array<() => void> = [];
|
|
|
|
const getFileReferenceStatCacheMax = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
|
);
|
|
|
|
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
|
|
// with a differently scoped entry.
|
|
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
|
|
|
|
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
|
|
const normalizedPath = normalizeReferencePath(resolvedPath);
|
|
if (!normalizedPath) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
|
|
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
|
|
if (cached) {
|
|
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
|
|
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
|
|
return cached;
|
|
}
|
|
|
|
const request = new Promise<boolean>((resolve) => {
|
|
const run = () => {
|
|
activeFileReferenceStatCount += 1;
|
|
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
// The stat route resolves the workspace from this header. Without it
|
|
// the server falls back to the browsed lastDirectory, which rejects
|
|
// session-local files with 400 whenever the two directories differ.
|
|
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
|
|
resolve(payload?.exists !== false);
|
|
})
|
|
.catch(() => resolve(false))
|
|
.finally(() => {
|
|
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
|
pendingFileReferenceStats.shift()?.();
|
|
});
|
|
};
|
|
|
|
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
|
run();
|
|
return;
|
|
}
|
|
|
|
pendingFileReferenceStats.push(run);
|
|
});
|
|
|
|
const maxCacheEntries = getFileReferenceStatCacheMax();
|
|
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
|
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
|
if (typeof oldest !== 'string') {
|
|
break;
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
|
|
return request;
|
|
};
|