diff --git a/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts new file mode 100644 index 00000000000..18c88b84944 --- /dev/null +++ b/packages/core/src/test-utils/mockWorkspaceContext.fallback.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; + +import * as fs from 'node:fs'; + +const throwMissingPath = vi.hoisted(() => () => { + const error = new Error('mocked missing path') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + realpathSync: vi.fn(throwMissingPath), + }; +}); + +import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; + +const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + +describe('createMockWorkspaceContext filesystem fallback', () => { + it('uses lexical containment when canonicalization is unavailable', () => { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false); + }); + + it('uses lexical containment when ENOENT has no path', () => { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + }); + + it('uses lexical containment for non-Node filesystem stubs', () => { + vi.mocked(fs.realpathSync).mockImplementation(() => { + throw new TypeError('mocked filesystem call'); + }); + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false); + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + }); +}); + +afterEach(() => { + vi.mocked(fs.realpathSync).mockImplementation(throwMissingPath); +}); + +afterAll(() => { + rmSync(rootDir, { recursive: true, force: true }); +}); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.test.ts b/packages/core/src/test-utils/mockWorkspaceContext.test.ts new file mode 100644 index 00000000000..04f6b537454 --- /dev/null +++ b/packages/core/src/test-utils/mockWorkspaceContext.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { createMockWorkspaceContext } from './mockWorkspaceContext.js'; + +describe('createMockWorkspaceContext', () => { + it('accepts missing descendants under a workspace root', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('does not treat a similarly prefixed sibling as inside the workspace', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(`${rootDir}-sibling/file.txt`), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('checks additional workspace directories', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const additionalDir = mkdtempSync( + path.join(os.tmpdir(), 'qwen-workspace-'), + ); + try { + const workspace = createMockWorkspaceContext(rootDir, [additionalDir]); + + expect( + workspace.isPathWithinWorkspace( + path.join(additionalDir, 'missing.txt'), + ), + ).toBe(true); + } finally { + rmSync(additionalDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('canonicalizes workspace aliases for containment checks', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const aliasDir = path.join( + os.tmpdir(), + `qwen-workspace-alias-${Date.now()}`, + ); + symlinkSync(rootDir, aliasDir); + + try { + const workspace = createMockWorkspaceContext(aliasDir); + + expect( + workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')), + ).toBe(true); + } finally { + rmSync(aliasDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('does not collapse missing paths below a symlinked ancestor', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const aliasDir = path.join( + os.tmpdir(), + `qwen-workspace-alias-${Date.now()}`, + ); + symlinkSync(rootDir, aliasDir); + + try { + const workspaceRoot = path.join(aliasDir, 'ghost', 'workspace'); + const siblingPath = path.join( + aliasDir, + 'ghost', + 'completely-different', + 'file.txt', + ); + const workspace = createMockWorkspaceContext(workspaceRoot); + + expect(workspace.isPathWithinWorkspace(siblingPath)).toBe(false); + } finally { + rmSync(aliasDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('rejects dangling leaf symlinks', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const danglingPath = path.join(rootDir, 'dangling'); + symlinkSync(path.join(rootDir, 'missing-target'), danglingPath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(danglingPath)).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('resolves existing candidate paths before checking containment', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const outsideDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-outside-')); + const insidePath = path.join(rootDir, 'inside.txt'); + const outsidePath = path.join(outsideDir, 'outside.txt'); + const escapePath = path.join(rootDir, 'escape'); + writeFileSync(insidePath, 'inside'); + writeFileSync(outsidePath, 'outside'); + symlinkSync(outsidePath, escapePath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect(workspace.isPathWithinWorkspace(insidePath)).toBe(true); + expect(workspace.isPathWithinWorkspace(escapePath)).toBe(false); + } finally { + rmSync(outsideDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('rejects paths through a symlink cycle', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const cyclePath = path.join(rootDir, 'cycle'); + symlinkSync('cycle', cyclePath); + + try { + const workspace = createMockWorkspaceContext(rootDir); + + expect( + workspace.isPathWithinWorkspace(path.join(cyclePath, 'file.txt')), + ).toBe(false); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('ignores an invalid workspace root when another root is valid', () => { + const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-')); + const cyclePath = path.join(rootDir, 'cycle'); + const candidatePath = path.join(rootDir, 'inside.txt'); + symlinkSync('cycle', cyclePath); + writeFileSync(candidatePath, 'inside'); + + try { + const workspace = createMockWorkspaceContext(cyclePath, [rootDir]); + + expect(workspace.isPathWithinWorkspace(candidatePath)).toBe(true); + } finally { + rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/test-utils/mockWorkspaceContext.ts b/packages/core/src/test-utils/mockWorkspaceContext.ts index 67c614e9f53..7c8dbc096c2 100644 --- a/packages/core/src/test-utils/mockWorkspaceContext.ts +++ b/packages/core/src/test-utils/mockWorkspaceContext.ts @@ -5,6 +5,11 @@ */ import { vi } from 'vitest'; +import { isNodeError } from '../utils/errors.js'; +import { + isPathWithinRoot, + resolveWorkspacePath, +} from '../utils/workspaceContext.js'; import type { WorkspaceContext } from '../utils/workspaceContext.js'; /** @@ -22,12 +27,41 @@ export function createMockWorkspaceContext( const mockWorkspaceContext = { addDirectory: vi.fn(), getDirectories: vi.fn().mockReturnValue(allDirs), - isPathWithinWorkspace: vi - .fn() - .mockImplementation((path: string) => - allDirs.some((dir) => path.startsWith(dir)), - ), + isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => { + try { + const canonicalPath = canonicalizeForContainment(path); + return allDirs.some((dir) => { + try { + return isPathWithinRoot( + canonicalPath, + canonicalizeForContainment(dir), + ); + } catch { + return false; + } + }); + } catch { + return false; + } + }), } as unknown as WorkspaceContext; return mockWorkspaceContext; } + +function canonicalizeForContainment(inputPath: string): string { + try { + return resolveWorkspacePath(inputPath); + } catch (error: unknown) { + if (isNodeError(error)) { + if (error.code === 'ENOENT' && !error.path) { + return inputPath; + } + throw error; + } + + // Some tests stub filesystem calls; retain lexical behavior for those + // mocked environments. + return inputPath; + } +} diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 7b41be841ea..83ce1c80ffb 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { WorkspaceContext } from './workspaceContext.js'; +import { resolveWorkspacePath, WorkspaceContext } from './workspaceContext.js'; describe('WorkspaceContext with real filesystem', () => { let tempDir: string; @@ -158,6 +158,16 @@ describe('WorkspaceContext with real filesystem', () => { ); }); + it('should preserve paths with missing intermediate components', () => { + const workspaceContext = new WorkspaceContext(cwd); + const nonExistentPath = path.join(cwd, 'missing', 'nested.txt'); + + expect(resolveWorkspacePath(nonExistentPath)).toBe(nonExistentPath); + expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe( + true, + ); + }); + describe('with symbolic link', () => { describe('in the workspace', () => { let realDir: string; diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index fcb5fa91ae1..f287be6d514 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -273,25 +273,7 @@ export class WorkspaceContext { if (cached !== undefined) { return cached; } - let resolved: string; - try { - resolved = fs.realpathSync(pathToCheck); - } catch (e: unknown) { - if ( - isNodeError(e) && - e.code === 'ENOENT' && - e.path && - // realpathSync does not set e.path correctly for symlinks to - // non-existent files. - !this.isFileSymlink(e.path) - ) { - // If it doesn't exist, e.path contains the fully resolved path. - resolved = e.path; - } else { - // Don't cache exceptions — the path may exist on retry. - throw e; - } - } + const resolved = resolveWorkspacePath(pathToCheck); if ( this.resolvedPathCache.size >= WorkspaceContext.RESOLVED_PATH_CACHE_MAX ) { @@ -302,19 +284,70 @@ export class WorkspaceContext { this.resolvedPathCache.set(pathToCheck, resolved); return resolved; } +} - /** - * Checks if a file path is a symbolic link that points to a file. - */ - private isFileSymlink(filePath: string): boolean { +/** + * Resolves a workspace path using the same missing-path and symlink semantics + * used by WorkspaceContext containment checks. + */ +export function resolveWorkspacePath(pathToCheck: string): string { + try { + const resolved = fs.realpathSync(pathToCheck); + return typeof resolved === 'string' ? resolved : pathToCheck; + } catch (error: unknown) { + if (isResolvableMissingPathError(error)) { + return resolveMissingPath(pathToCheck); + } + + throw error; + } +} + +function resolveMissingPath(pathToCheck: string): string { + const missingTail: string[] = []; + let ancestor = pathToCheck; + + while (true) { try { - return !fs.readlinkSync(filePath).endsWith('/'); - } catch (_error) { - return false; + const resolvedAncestor = fs.realpathSync(ancestor); + return path.join(resolvedAncestor, ...missingTail); + } catch (error: unknown) { + if (!isResolvableMissingPathError(error)) { + throw error; + } + + const parent = path.dirname(ancestor); + if (parent === ancestor) { + return pathToCheck; + } + missingTail.unshift(path.basename(ancestor)); + ancestor = parent; } } } +function isResolvableMissingPathError(error: unknown): boolean { + return ( + isNodeError(error) && + error.code === 'ENOENT' && + !!error.path && + // realpathSync does not set error.path correctly for symlinks to + // non-existent files. + !isFileSymlink(error.path) + ); +} + +/** + * Checks if a file path is a symbolic link that points to a file. + */ +function isFileSymlink(filePath: string): boolean { + try { + return !fs.readlinkSync(filePath).endsWith('/'); + } catch (_error) { + return false; + } +} + /** * Checks if a path is within a given root directory. * @param pathToCheck The absolute path to check