diff --git a/src/main/http/validation.ts b/src/main/http/validation.ts index 9b15c606..90c09147 100644 --- a/src/main/http/validation.ts +++ b/src/main/http/validation.ts @@ -22,7 +22,8 @@ const logger = createLogger('HTTP:validation'); function isPathContained(fullPath: string, basePath: string): boolean { const normalizedFull = normalizeForContainment(fullPath); const normalizedBase = normalizeForContainment(basePath); - return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep); + const relative = path.relative(normalizedBase, normalizedFull); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function normalizeForContainment(value: string): string { @@ -31,7 +32,9 @@ function normalizeForContainment(value: string): string { } function resolveProjectPath(projectPath: string, requestedPath: string): string { - return path.isAbsolute(requestedPath) ? requestedPath : path.join(projectPath, requestedPath); + return path.isAbsolute(requestedPath) + ? path.resolve(path.normalize(requestedPath)) + : path.resolve(projectPath, requestedPath); } export function registerValidationRoutes(app: FastifyInstance): void { diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index f9d7d6cc..074a335f 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -7,6 +7,7 @@ */ import { isValidProjectId } from '@main/utils/pathDecoder'; +import { isWindowsReservedFileName } from '@main/utils/pathValidation'; const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; @@ -51,6 +52,12 @@ function validateString( return { valid: true, value: trimmed }; } +function rejectWindowsReserved(value: string, fieldName: string): ValidationResult | null { + return isWindowsReservedFileName(value) + ? { valid: false, error: `${fieldName} is reserved on Windows` } + : null; +} + export function validateProjectId(projectId: unknown): ValidationResult { const basic = validateString(projectId, 'projectId'); if (!basic.valid) { @@ -126,6 +133,11 @@ export function validateTeamName(teamName: unknown): ValidationResult { return { valid: false, error: 'teamName contains invalid characters' }; } + const reserved = rejectWindowsReserved(basic.value!, 'teamName'); + if (reserved) { + return reserved; + } + return { valid: true, value: basic.value }; } @@ -139,6 +151,11 @@ export function validateTaskId(taskId: unknown): ValidationResult { return { valid: false, error: 'taskId contains invalid characters' }; } + const reserved = rejectWindowsReserved(basic.value!, 'taskId'); + if (reserved) { + return reserved; + } + return { valid: true, value: basic.value }; } @@ -152,6 +169,11 @@ export function validateMemberName(memberName: unknown): ValidationResult { try { - const fullPath = path.join(projectPath, relativePath); + const fullPath = resolveProjectPath(projectPath, relativePath); // Security: Ensure path doesn't escape project directory if (!isPathContained(fullPath, projectPath)) { @@ -100,7 +110,7 @@ async function handleValidateMentions( // (was sequential sync existsSync — blocked main thread per mention) const entries = await Promise.all( mentions.map(async (mention) => { - const fullPath = path.join(projectPath, mention.value); + const fullPath = resolveProjectPath(projectPath, mention.value); // Security: Skip paths that escape project directory if (!isPathContained(fullPath, projectPath)) { diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index e8ba6801..161b56ba 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -11,7 +11,12 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { extractCwd } from '@main/utils/jsonl'; -import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { + decodePath, + extractBaseDir, + getProjectDirNameCandidates, + getProjectsBasePath, +} from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; @@ -109,20 +114,24 @@ export class ProjectPathResolver { } private async listSessionPaths(projectId: string): Promise { - const projectDir = path.join(this.projectsDir, extractBaseDir(projectId)); - if (!(await this.fsProvider.exists(projectDir))) { - return []; + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectDir = path.join(this.projectsDir, dirName); + if (!(await this.fsProvider.exists(projectDir))) { + continue; + } + + try { + const entries = await this.fsProvider.readdir(projectDir); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => path.join(projectDir, entry.name)); + } catch (error) { + logger.error(`Failed to read session files for ${projectId}:`, error); + return []; + } } - try { - const entries = await this.fsProvider.readdir(projectDir); - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) - .map((entry) => path.join(projectDir, entry.name)); - } catch (error) { - logger.error(`Failed to read session files for ${projectId}:`, error); - return []; - } + return []; } } diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 4fe892a1..38337f23 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -47,6 +47,7 @@ import { extractBaseDir, extractProjectName, extractSessionId, + getProjectDirNameCandidates, getProjectsBasePath, getTodosBasePath, isValidEncodedPath, @@ -76,6 +77,10 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; // for lookups and navigation; a small cap preserves that behavior without huge payloads. const MAX_SESSION_IDS_EXPORTED = 200; +function splitPathSegments(value: string): string[] { + return value.split(/[/\\]+/).filter(Boolean); +} + /** * Fast, zero-I/O worktree detection based on path patterns only. * Used by scanWithWorktreeGrouping to provide accurate worktree metadata @@ -85,7 +90,7 @@ function detectWorktreeFromPath(projectPath: string): { isWorktree: boolean; source: WorktreeSource; } { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'vibe-kanban' }; @@ -601,12 +606,12 @@ export class ProjectScanner { * Handles composite IDs by scanning the base directory and finding the matching subproject. */ async getProject(projectId: string): Promise { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return null; } + const baseDir = path.basename(projectPath); // For composite IDs, scan and find the matching subproject if (subprojectRegistry.isComposite(projectId)) { @@ -1214,11 +1219,10 @@ export class ProjectScanner { */ async listSessionFiles(projectId: string): Promise { try { - const baseDir = extractBaseDir(projectId); - const projectPath = path.join(this.projectsDir, baseDir); + const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); - if (!(await this.fsProvider.exists(projectPath))) { + if (!projectPath) { return []; } @@ -1237,6 +1241,16 @@ export class ProjectScanner { } } + private async resolveProjectStorageDir(projectId: string): Promise { + for (const dirName of getProjectDirNameCandidates(projectId)) { + const projectPath = path.join(this.projectsDir, dirName); + if (await this.fsProvider.exists(projectPath)) { + return projectPath; + } + } + return null; + } + /** * Returns the session filter set for a project. * In local mode, composite IDs are refreshed from disk first so newly created diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 3200c1af..09fdfd48 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -51,6 +51,10 @@ async function fileExists(filePath: string): Promise { } } +function splitPathSegments(value: string): string[] { + return value.split(/[/\\]+/).filter(Boolean); +} + class GitIdentityResolver { private identityCache = new Map>(); private branchCache = new Map>(); @@ -195,7 +199,7 @@ class GitIdentityResolver { * - Default: last path component */ private extractRepoNameFromPath(projectPath: string): string | null { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); if (parts.length === 0) { return null; @@ -271,7 +275,7 @@ class GitIdentityResolver { */ async isWorktree(projectPath: string): Promise { // First, try path-based heuristics (works for deleted worktrees) - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); // Check for known worktree patterns - these are ALWAYS worktrees if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { @@ -326,7 +330,7 @@ class GitIdentityResolver { private extractMainGitDir(worktreeGitDir: string): string { // worktreeGitDir is typically: /path/to/main/.git/worktrees/ // We need to go up two levels to get to .git - const parts = worktreeGitDir.split(path.sep); + const parts = splitPathSegments(worktreeGitDir); const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR); if (worktreesIndex > 0) { @@ -551,7 +555,7 @@ class GitIdentityResolver { * @returns WorktreeSource identifier */ async detectWorktreeSource(projectPath: string): Promise { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); // Pattern: vibe-kanban // /tmp/vibe-kanban/worktrees/{issue-branch}/{repo} @@ -626,7 +630,7 @@ class GitIdentityResolver { branch: string | null, isMainWorktree: boolean ): Promise { - const parts = projectPath.split(path.sep).filter(Boolean); + const parts = splitPathSegments(projectPath); switch (source) { case 'vibe-kanban': { @@ -754,7 +758,7 @@ class GitIdentityResolver { if (!match) return null; // gitdir: /main/.git/worktrees/my-worktree-name - const gitdirParts = match[1].trim().split(path.sep); + const gitdirParts = splitPathSegments(match[1].trim()); const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR); if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) { return gitdirParts[worktreesIdx + 1]; diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index ebed1d6c..9b3e651d 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -378,7 +378,10 @@ export class FileContentResolver { * For subagents, sessionId = the parent directory's parent name. */ private extractSessionId(logPath: string): string | null { - const parts = path.normalize(logPath).split(path.sep).filter(Boolean); + const parts = path + .normalize(logPath) + .split(/[/\\]+/) + .filter(Boolean); // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl const subagentsIdx = parts.indexOf('subagents'); @@ -613,7 +616,7 @@ export class FileContentResolver { private getDisplayRelativePath(filePath: string, segmentCount: number): string { const normalized = path.normalize(filePath); - const parts = normalized.split(path.sep).filter(Boolean); + const parts = normalized.split(/[/\\]+/).filter(Boolean); return parts.slice(-segmentCount).join('/'); } diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 85f248b0..bee27e61 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -52,16 +52,13 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean { return /^task-id-[0-9a-f]{32}$/.test(segment); } -export function shouldIgnoreLogSourceWatcherPath( - projectDir: string, - watchedPath: string -): boolean { +export function shouldIgnoreLogSourceWatcherPath(projectDir: string, watchedPath: string): boolean { const relativePath = path.relative(projectDir, watchedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return false; } - const parts = relativePath.split(path.sep).filter(Boolean); + const parts = relativePath.split(/[/\\]+/).filter(Boolean); return parts[0] === BOARD_TASK_CHANGES_DIRNAME; } @@ -399,9 +396,7 @@ export class TeamLogSourceTracker { try { const taskId = decodeURIComponent(encodedTaskId); - return taskId.trim().length > 0 - ? { kind: 'task-id', taskId } - : { kind: 'invalid' }; + return taskId.trim().length > 0 ? { kind: 'task-id', taskId } : { kind: 'invalid' }; } catch { return { kind: 'invalid' }; } diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index a778d74f..e7a59938 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -40,6 +40,30 @@ export function encodePath(absolutePath: string): string { return encoded.startsWith('-') ? encoded : `-${encoded}`; } +function isWindowsAbsolutePathLike(name: string): boolean { + const slashPath = name.replace(/\\/g, '/'); + return /^[a-zA-Z]:\//.test(slashPath) || slashPath.startsWith('//'); +} + +function normalizeWindowsPathForStorageKey(name: string): string { + if (!isWindowsAbsolutePathLike(name)) { + return name; + } + return name.replace(/\\/g, '/').toLowerCase(); +} + +/** + * Matches the orchestrator's cross-platform storage key codec. + * It lowercases Windows absolute paths, normalizes separators, and replaces + * every non-ASCII-alphanumeric character with a dash. + */ +export function encodePathPortable(absolutePath: string): string { + if (!absolutePath) { + return ''; + } + return normalizeWindowsPathForStorageKey(absolutePath).replace(/[^a-zA-Z0-9]/g, '-'); +} + /** * Decodes a project directory name to its original path. * Note: This is a best-effort decode. Paths with dashes cannot be decoded accurately. @@ -133,7 +157,7 @@ export function isValidEncodedPath(encodedName: string): boolean { // Support legacy Windows format: "C--Users-name-project" // (no leading dash, drive separator encoded as "--"). - if (/^[a-zA-Z]--[a-zA-Z0-9_.\s-]+$/.test(encodedName)) { + if (/^[a-zA-Z]--[^\x00-\x1f/\\:*?"<>|]+$/u.test(encodedName)) { return true; } @@ -142,11 +166,10 @@ export function isValidEncodedPath(encodedName: string): boolean { return false; } - // Allow only expected encoded characters: - // - alphanumeric, underscores, dots, spaces, dashes - // - optional ":" for Windows drive notation (e.g., -C:-Users-name-project) - const validPattern = /^-[a-zA-Z0-9_.\s:-]+$/; - if (!validPattern.test(encodedName)) { + // Encoded path is a single directory name. It may contain Unicode project + // names, but must not contain separators, control chars, or Windows-invalid chars. + // A single drive colon is allowed only in the old "-C:-Users-name" form. + if (/[\x00-\x1f/\\*?"<>|]/u.test(encodedName)) { return false; } @@ -202,6 +225,35 @@ export function extractBaseDir(projectId: string): string { return projectId; } +function addUniqueCandidate(candidates: string[], candidate: string): void { + if (candidate && !candidates.includes(candidate)) { + candidates.push(candidate); + } +} + +/** + * Returns possible ~/.claude/projects directory names for a project id. + * The first candidate is always the id's own base dir. Additional entries cover + * the orchestrator's portable codec, which lowercases Windows paths and folds + * underscores/non-ASCII characters to dashes. + */ +export function getProjectDirNameCandidates(projectId: string): string[] { + const baseDir = extractBaseDir(projectId); + const candidates: string[] = []; + addUniqueCandidate(candidates, baseDir); + + const decoded = decodePath(baseDir); + addUniqueCandidate(candidates, encodePath(decoded)); + addUniqueCandidate(candidates, encodePathPortable(decoded)); + + if (path.isAbsolute(projectId)) { + addUniqueCandidate(candidates, encodePath(projectId)); + addUniqueCandidate(candidates, encodePathPortable(projectId)); + } + + return candidates; +} + // ============================================================================= // Session ID Extraction // ============================================================================= diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 63e50334..9e58601d 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -70,9 +70,11 @@ function normalizeForCompare(input: string, isWindows: boolean): string { } export function isPathWithinRoot(targetPath: string, rootPath: string): boolean { - const target = path.resolve(targetPath); - const root = path.resolve(rootPath); - return target === root || target.startsWith(root + path.sep); + const isWindows = process.platform === 'win32'; + const target = normalizeForCompare(targetPath, isWindows); + const root = normalizeForCompare(rootPath, isWindows); + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function resolveRealPathIfExists(inputPath: string): string | null { @@ -317,6 +319,47 @@ const MAX_FILENAME_LENGTH = 255; /** Characters forbidden in file/directory names. */ // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/; +const WINDOWS_RESERVED_BASENAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +export function isWindowsReservedFileName(name: string): boolean { + if (typeof name !== 'string') { + return false; + } + + const normalized = name + .trim() + .replace(/[. ]+$/g, '') + .toLowerCase(); + if (!normalized) { + return false; + } + + const stem = normalized.split('.')[0] ?? normalized; + return WINDOWS_RESERVED_BASENAMES.has(stem); +} /** * Validates a file or directory name for creation. @@ -344,6 +387,14 @@ export function validateFileName(name: string): PathValidationResult { return { valid: false, error: 'Name contains invalid characters' }; } + if (/[. ]$/.test(name)) { + return { valid: false, error: 'Name cannot end with a space or period' }; + } + + if (isWindowsReservedFileName(trimmed)) { + return { valid: false, error: 'Name is reserved on Windows' }; + } + return { valid: true }; } diff --git a/test/main/ipc/guards.test.ts b/test/main/ipc/guards.test.ts index ff0cc1d9..e04264af 100644 --- a/test/main/ipc/guards.test.ts +++ b/test/main/ipc/guards.test.ts @@ -75,4 +75,11 @@ describe('ipc guards', () => { expect(validateMemberName('alice bob').valid).toBe(false); expect(validateFromField('../../etc').valid).toBe(false); }); + + it('rejects Windows reserved device names for filesystem-backed fields', () => { + expect(validateTeamName('con').valid).toBe(false); + expect(validateTaskId('NUL').valid).toBe(false); + expect(validateMemberName('com1').valid).toBe(false); + expect(validateMemberName('lpt9.txt').valid).toBe(false); + }); }); diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts index 5a851846..62c216c1 100644 --- a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry'; +import { encodePathPortable } from '../../../../src/main/utils/pathDecoder'; function createSessionLine(opts: { cwd?: string; type?: string }): string { return JSON.stringify({ @@ -102,4 +103,42 @@ describe('ProjectScanner cwd split logic', () => { expect(proj.id).toContain('::'); } }); + + it('finds sessions stored with the orchestrator Windows project codec', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const projectPath = 'C:\\Users\\User\\PROJECT_IT\\сlaude_team'; + const uiEncodedName = 'C--Users-User-PROJECT_IT-сlaude_team'; + const orchestratorEncodedName = encodePathPortable(projectPath); + const projectDir = path.join(projectsDir, orchestratorEncodedName); + fs.mkdirSync(projectDir); + + const sessionPath = path.join(projectDir, 'session-orchestrator.jsonl'); + fs.writeFileSync(sessionPath, createSessionLine({ cwd: projectPath }) + '\n'); + + const scanner = new ProjectScanner(projectsDir); + await expect(scanner.listSessionFiles(uiEncodedName)).resolves.toEqual([sessionPath]); + }); + + it('detects Windows forward-slash worktree paths', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-')); + tempDirs.push(projectsDir); + + const encodedName = 'c--users-test--claude-worktrees-myrepo-feature'; + const projectDir = path.join(projectsDir, encodedName); + fs.mkdirSync(projectDir); + + fs.writeFileSync( + path.join(projectDir, 'session-worktree.jsonl'), + createSessionLine({ cwd: 'C:/Users/test/.claude-worktrees/myrepo/feature' }) + '\n' + ); + + const scanner = new ProjectScanner(projectsDir); + const groups = await scanner.scanWithWorktreeGrouping(); + const worktree = groups.find((group) => group.id === encodedName)?.worktrees[0]; + + expect(worktree?.isMainWorktree).toBe(false); + expect(worktree?.source).toBe('claude-desktop'); + }); }); diff --git a/test/main/utils/pathDecoder.test.ts b/test/main/utils/pathDecoder.test.ts index 5805be88..5183095e 100644 --- a/test/main/utils/pathDecoder.test.ts +++ b/test/main/utils/pathDecoder.test.ts @@ -10,8 +10,10 @@ import { buildTodoPath, decodePath, encodePath, + encodePathPortable, extractProjectName, extractSessionId, + getProjectDirNameCandidates, getAppDataPath, getProjectsBasePath, getSchedulesBasePath, @@ -72,6 +74,12 @@ describe('pathDecoder', () => { it('should encode a Linux-style path', () => { expect(encodePath('/home/user/projects/myapp')).toBe('-home-user-projects-myapp'); }); + + it('should produce orchestrator-compatible Windows storage keys', () => { + expect(encodePathPortable('C:\\Users\\User\\PROJECT_IT\\сlaude_team')).toBe( + 'c--users-user-project-it--laude-team' + ); + }); }); describe('decodePath', () => { @@ -188,12 +196,27 @@ describe('pathDecoder', () => { expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true); }); + it('should return true for Windows encoded paths with underscores and Unicode', () => { + expect(isValidEncodedPath('C--Users-User-PROJECT_IT-сlaude_team')).toBe(true); + }); + it('should return false for misplaced colons', () => { expect(isValidEncodedPath('-Users-username:project')).toBe(false); expect(isValidEncodedPath('-C:-Users-name-project:extra')).toBe(false); }); }); + describe('getProjectDirNameCandidates', () => { + it('includes the orchestrator storage key for the current Windows project path shape', () => { + expect(getProjectDirNameCandidates('C--Users-User-PROJECT_IT-сlaude_team')).toEqual( + expect.arrayContaining([ + 'C--Users-User-PROJECT_IT-сlaude_team', + 'c--users-user-project-it--laude-team', + ]) + ); + }); + }); + describe('extractSessionId', () => { it('should extract session ID from JSONL filename', () => { expect(extractSessionId('abc123.jsonl')).toBe('abc123'); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index dcaf8e47..9965f72f 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -10,7 +10,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder'; import { + isPathWithinRoot, isPathWithinAllowedDirectories, + isWindowsReservedFileName, + validateFileName, validateFilePath, validateOpenPath, validateOpenPathUserSelected, @@ -62,6 +65,41 @@ describe('pathValidation', () => { }); }); + describe('isPathWithinRoot', () => { + it('rejects sibling paths that only share the same prefix', () => { + const root = path.join(os.tmpdir(), 'repo'); + const sibling = path.join(os.tmpdir(), 'repo2', 'file.ts'); + expect(isPathWithinRoot(sibling, root)).toBe(false); + }); + + it('handles Windows drive casing and traversal consistently', () => { + if (process.platform !== 'win32') { + return; + } + + expect(isPathWithinRoot('C:\\Repo\\File.ts', 'c:\\repo')).toBe(true); + expect(isPathWithinRoot('c:\\repo\\file.ts', 'C:\\Repo')).toBe(true); + expect(isPathWithinRoot('C:\\Repo2\\file.ts', 'C:\\Repo')).toBe(false); + expect(isPathWithinRoot('C:\\Repo\\..\\escape\\file.ts', 'C:\\Repo')).toBe(false); + }); + }); + + describe('validateFileName', () => { + it('rejects Windows reserved basenames before file creation', () => { + expect(isWindowsReservedFileName('con')).toBe(true); + expect(isWindowsReservedFileName('NUL.txt')).toBe(true); + expect(isWindowsReservedFileName('com1.json')).toBe(true); + expect(validateFileName('con').valid).toBe(false); + expect(validateFileName('NUL.txt').valid).toBe(false); + expect(validateFileName('com1.json').valid).toBe(false); + }); + + it('rejects trailing spaces and periods for Windows-safe names', () => { + expect(validateFileName('report.').valid).toBe(false); + expect(validateFileName('report ').valid).toBe(false); + }); + }); + describe('validateFilePath', () => { describe('basic validation', () => { it('should reject empty path', () => {