fix(windows): align session paths and validators
This commit is contained in:
parent
f2b7024226
commit
645ac4573e
14 changed files with 324 additions and 54 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<never> | null {
|
||||
return isWindowsReservedFileName(value)
|
||||
? { valid: false, error: `${fieldName} is reserved on Windows` }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function validateProjectId(projectId: unknown): ValidationResult<string> {
|
||||
const basic = validateString(projectId, 'projectId');
|
||||
if (!basic.valid) {
|
||||
|
|
@ -126,6 +133,11 @@ export function validateTeamName(teamName: unknown): ValidationResult<string> {
|
|||
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<string> {
|
|||
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<string
|
|||
return { valid: false, error: 'member contains invalid characters' };
|
||||
}
|
||||
|
||||
const windowsReserved = rejectWindowsReserved(basic.value!, 'member');
|
||||
if (windowsReserved) {
|
||||
return windowsReserved;
|
||||
}
|
||||
|
||||
const lower = basic.value!.toLowerCase();
|
||||
if (RESERVED_MEMBER_NAMES.has(lower)) {
|
||||
return { valid: false, error: `member name "${basic.value!}" is reserved` };
|
||||
|
|
|
|||
|
|
@ -45,12 +45,22 @@ export function removeValidationHandlers(ipcMain: IpcMain): void {
|
|||
* Prevents path traversal attacks (e.g., ../../etc/passwd).
|
||||
*/
|
||||
function isPathContained(fullPath: string, basePath: string): boolean {
|
||||
const normalizedFull = path.normalize(fullPath);
|
||||
const normalizedBase = path.normalize(basePath);
|
||||
const normalizedFull = normalizeForContainment(fullPath);
|
||||
const normalizedBase = normalizeForContainment(basePath);
|
||||
const relative = path.relative(normalizedBase, normalizedFull);
|
||||
|
||||
// Ensure the full path starts with the base path followed by a separator
|
||||
// or is exactly the base path
|
||||
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function normalizeForContainment(value: string): string {
|
||||
const resolved = path.resolve(path.normalize(value));
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function resolveProjectPath(projectPath: string, requestedPath: string): string {
|
||||
return path.isAbsolute(requestedPath)
|
||||
? path.resolve(path.normalize(requestedPath))
|
||||
: path.resolve(projectPath, requestedPath);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -67,7 +77,7 @@ async function handleValidatePath(
|
|||
projectPath: string
|
||||
): Promise<{ exists: boolean; isDirectory?: boolean }> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Project | null> {
|
||||
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<string[]> {
|
||||
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<string | null> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string): string[] {
|
||||
return value.split(/[/\\]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
class GitIdentityResolver {
|
||||
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
|
||||
private branchCache = new Map<string, CacheEntry<string | null>>();
|
||||
|
|
@ -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<boolean> {
|
||||
// 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/<worktree-name>
|
||||
// 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<WorktreeSource> {
|
||||
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<string> {
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue