mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: normalize Windows drive-root sessions (#601)
Windows folder selection can surface drive roots as D:, which Node treats as drive-relative instead of the absolute D:\ root. Normalize drive-root inputs before resolving launch directories, filesystem access roots, transcript metadata, and workspace paths. Use path.relative containment checks so drive roots and child projects remain distinct in session grouping and file access. Constraint: Windows drive-root inputs may arrive as C:, D:, or any other single-letter drive prefix Rejected: Special-case only D: | every Windows drive letter has the same drive-relative semantics Confidence: high Scope-risk: moderate Directive: Keep Windows drive-root normalization centralized; do not reintroduce string-prefix containment checks for workspace roots Tested: bun test src/server/__tests__/windows-drive-path.test.ts src/server/__tests__/filesystem.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- --run src/components/layout/Sidebar.test.tsx Tested: bun run check:server Tested: cd desktop && bun run lint Not-tested: Real Windows desktop smoke on a physical Windows machine Related: #601
This commit is contained in:
parent
1b2bded1a2
commit
fe8aac6b83
@ -782,6 +782,23 @@ describe('Sidebar', () => {
|
||||
expect(screen.getAllByText('worktree')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a Windows drive root session separate from sessions in child projects', () => {
|
||||
const now = new Date().toISOString()
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
makeSession('drive-root', 'Drive Root Session', 'D:\\', now),
|
||||
makeSession('drive-project', 'Drive Project Session', 'D:\\SomeProject', now),
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
expect(screen.getByText('D:')).toBeInTheDocument()
|
||||
expect(screen.getByText('SomeProject')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Drive Root Session/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Drive Project Session/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('right-aligns running status, worktree marker, and update time on session rows', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z'))
|
||||
|
||||
30
src/server/__tests__/windows-drive-path.test.ts
Normal file
30
src/server/__tests__/windows-drive-path.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
isSameOrInsidePathForPlatform,
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from '../services/windowsDrivePath.js'
|
||||
import { SessionService } from '../services/sessionService.js'
|
||||
|
||||
describe('Windows drive root path handling', () => {
|
||||
it('normalizes drive-relative root inputs to absolute drive roots on Windows', () => {
|
||||
expect(normalizeDriveRootPathForPlatform('D:', 'win32')).toBe('D:\\')
|
||||
expect(normalizeDriveRootPathForPlatform('d:', 'win32')).toBe('d:\\')
|
||||
expect(normalizeDriveRootPathForPlatform('D:\\', 'win32')).toBe('D:\\')
|
||||
expect(normalizeDriveRootPathForPlatform('D:\\project', 'win32')).toBe('D:\\project')
|
||||
expect(normalizeDriveRootPathForPlatform('D:', 'darwin')).toBe('D:')
|
||||
})
|
||||
|
||||
it('recovers sanitized Windows drive-root transcript directories', () => {
|
||||
const service = new SessionService()
|
||||
expect(service.desanitizePath('D--')).toBe('D:\\')
|
||||
expect(service.desanitizePath('D--project')).toBe('D:\\project')
|
||||
})
|
||||
|
||||
it('treats absolute Windows drive-root children as inside the selected root', () => {
|
||||
expect(isSameOrInsidePathForPlatform('D:\\', 'D:', 'win32')).toBe(true)
|
||||
expect(isSameOrInsidePathForPlatform('D:\\child', 'D:', 'win32')).toBe(true)
|
||||
expect(isSameOrInsidePathForPlatform('D:\\child', 'D:\\', 'win32')).toBe(true)
|
||||
expect(isSameOrInsidePathForPlatform('D:\\project-extra', 'D:\\project', 'win32')).toBe(false)
|
||||
expect(isSameOrInsidePathForPlatform('E:\\child', 'D:\\', 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -13,6 +13,10 @@ import { findGitRoot, gitExe } from '../../utils/git.js'
|
||||
import { ripGrep } from '../../utils/ripgrep.js'
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js'
|
||||
import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js'
|
||||
import {
|
||||
isSameOrInsidePathForPlatform,
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from '../services/windowsDrivePath.js'
|
||||
|
||||
type FilesystemEntry = {
|
||||
name: string
|
||||
@ -41,14 +45,7 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
}
|
||||
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const target = normalizeComparablePath(targetPath)
|
||||
const root = normalizeComparablePath(rootPath)
|
||||
return target === root || target.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
function normalizeComparablePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath)
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
return isSameOrInsidePathForPlatform(targetPath, rootPath)
|
||||
}
|
||||
|
||||
function isVcsMetadataDirectoryName(name: string): boolean {
|
||||
@ -56,7 +53,7 @@ function isVcsMetadataDirectoryName(name: string): boolean {
|
||||
}
|
||||
|
||||
function isAllowedFilesystemPath(targetPath: string): boolean {
|
||||
const resolvedPath = path.resolve(targetPath)
|
||||
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath))
|
||||
const homeDir = path.resolve(os.homedir())
|
||||
|
||||
if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) {
|
||||
@ -93,7 +90,7 @@ async function handleServeFile(url: URL): Promise<Response> {
|
||||
return json({ error: 'Missing path parameter' }, 400)
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(filePath))
|
||||
|
||||
if (!isAllowedFilesystemPath(resolvedPath)) {
|
||||
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
||||
@ -132,7 +129,7 @@ async function handleServeFile(url: URL): Promise<Response> {
|
||||
|
||||
async function handleBrowse(url: URL): Promise<Response> {
|
||||
const targetPath = url.searchParams.get('path') || os.homedir() || '/'
|
||||
const resolvedPath = path.resolve(targetPath)
|
||||
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath))
|
||||
|
||||
if (!isAllowedFilesystemPath(resolvedPath)) {
|
||||
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
import * as path from 'node:path'
|
||||
import {
|
||||
isSameOrInsidePathForPlatform,
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from './windowsDrivePath.js'
|
||||
|
||||
const registeredRoots = new Set<string>()
|
||||
|
||||
function normalizeComparablePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath)
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
}
|
||||
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const target = normalizeComparablePath(targetPath)
|
||||
const root = normalizeComparablePath(rootPath)
|
||||
return target === root || target.startsWith(`${root}${path.sep}`)
|
||||
return isSameOrInsidePathForPlatform(targetPath, rootPath)
|
||||
}
|
||||
|
||||
export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void {
|
||||
if (!rootPath) return
|
||||
registeredRoots.add(path.resolve(rootPath))
|
||||
registeredRoots.add(path.resolve(normalizeDriveRootPathForPlatform(rootPath)))
|
||||
}
|
||||
|
||||
export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean {
|
||||
|
||||
@ -5,6 +5,7 @@ import { promisify } from 'node:util'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js'
|
||||
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
||||
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
|
||||
import {
|
||||
ensureWorktreesDirExcluded,
|
||||
performPostCreationSetup,
|
||||
@ -168,7 +169,7 @@ async function runGit(
|
||||
}
|
||||
|
||||
async function resolveDirectory(workDir: string): Promise<string> {
|
||||
const resolved = path.resolve(workDir)
|
||||
const resolved = path.resolve(normalizeDriveRootPathForPlatform(workDir))
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = await fs.realpath(resolved)
|
||||
@ -332,7 +333,7 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'missing_workdir',
|
||||
workDir: path.resolve(workDir),
|
||||
workDir: path.resolve(normalizeDriveRootPathForPlatform(workDir)),
|
||||
repoRoot: null,
|
||||
repoName: null,
|
||||
currentBranch: null,
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
type PreparedSessionWorkspace,
|
||||
} from './repositoryLaunchService.js'
|
||||
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
||||
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
|
||||
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
|
||||
|
||||
// ============================================================================
|
||||
@ -294,14 +295,14 @@ export class SessionService {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i]
|
||||
if (entry.type === 'session-meta' && typeof (entry as Record<string, unknown>).workDir === 'string') {
|
||||
return (entry as Record<string, unknown>).workDir as string
|
||||
return normalizeDriveRootPathForPlatform((entry as Record<string, unknown>).workDir as string)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const cwd = entries[i]?.cwd
|
||||
if (typeof cwd === 'string' && cwd.trim()) {
|
||||
return cwd
|
||||
return normalizeDriveRootPathForPlatform(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
@ -915,7 +916,7 @@ export class SessionService {
|
||||
|
||||
// Optionally filter to a specific project
|
||||
if (projectFilter) {
|
||||
const sanitized = this.sanitizePath(projectFilter)
|
||||
const sanitized = this.sanitizePath(normalizeDriveRootPathForPlatform(projectFilter))
|
||||
projectDirs = projectDirs.filter((d) => d === sanitized)
|
||||
}
|
||||
|
||||
@ -966,6 +967,11 @@ export class SessionService {
|
||||
return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}`
|
||||
}
|
||||
|
||||
const windowsDriveRoot = sanitized.match(/^([a-zA-Z])--$/)
|
||||
if (windowsDriveRoot) {
|
||||
return `${windowsDriveRoot[1]}:${path.win32.sep}`
|
||||
}
|
||||
|
||||
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
|
||||
// UNC-style Windows paths also recover to a leading double separator on Windows.
|
||||
return sanitized.replace(/-/g, path.sep)
|
||||
@ -1644,7 +1650,7 @@ export class SessionService {
|
||||
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
|
||||
let found = await this.findSessionFile(sessionId)
|
||||
if (!found && fallbackWorkDir) {
|
||||
const resolvedPath = path.resolve(fallbackWorkDir)
|
||||
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir))
|
||||
const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath)
|
||||
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
@ -1710,14 +1716,15 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
const targetProjectDir = this.sanitizePath(metadata.workDir)
|
||||
const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir)
|
||||
const targetProjectDir = this.sanitizePath(normalizedWorkDir)
|
||||
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
|
||||
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
|
||||
|
||||
await this.appendJsonlEntry(targetFilePath, {
|
||||
type: 'session-meta',
|
||||
isMeta: true,
|
||||
workDir: metadata.workDir,
|
||||
workDir: normalizedWorkDir,
|
||||
repository,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
@ -1746,7 +1753,7 @@ export class SessionService {
|
||||
throw err
|
||||
}
|
||||
|
||||
const keepProjectDir = this.sanitizePath(keepWorkDir)
|
||||
const keepProjectDir = this.sanitizePath(normalizeDriveRootPathForPlatform(keepWorkDir))
|
||||
let removed = 0
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue
|
||||
|
||||
30
src/server/services/windowsDrivePath.ts
Normal file
30
src/server/services/windowsDrivePath.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
export function normalizeDriveRootPathForPlatform(
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (platform !== 'win32') return filePath
|
||||
|
||||
const driveRootMatch = filePath.match(/^([a-zA-Z]):$/)
|
||||
if (!driveRootMatch) return filePath
|
||||
|
||||
return `${driveRootMatch[1]}:\\`
|
||||
}
|
||||
|
||||
export function isSameOrInsidePathForPlatform(
|
||||
targetPath: string,
|
||||
rootPath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const pathApi = platform === 'win32' ? path.win32 : path
|
||||
const normalize = (filePath: string) => {
|
||||
const resolved = pathApi.resolve(normalizeDriveRootPathForPlatform(filePath, platform))
|
||||
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
}
|
||||
const target = normalize(targetPath)
|
||||
const root = normalize(rootPath)
|
||||
const relative = pathApi.relative(root, target)
|
||||
|
||||
return relative === '' || (!!relative && !relative.startsWith('..') && !pathApi.isAbsolute(relative))
|
||||
}
|
||||
@ -6,6 +6,10 @@ import { diffLines } from 'diff'
|
||||
import type { MessageEntry } from './sessionService.js'
|
||||
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import {
|
||||
isSameOrInsidePathForPlatform,
|
||||
normalizeDriveRootPathForPlatform,
|
||||
} from './windowsDrivePath.js'
|
||||
|
||||
const MAX_PREVIEW_BYTES = 1024 * 1024
|
||||
const MAX_UNTRACKED_STAT_BYTES = 256 * 1024
|
||||
@ -1004,7 +1008,7 @@ export class WorkspaceService {
|
||||
if (!workDir) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
return path.resolve(workDir)
|
||||
return path.resolve(normalizeDriveRootPathForPlatform(workDir))
|
||||
}
|
||||
|
||||
private async getWorkspaceRoot(
|
||||
@ -1149,14 +1153,7 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
private isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const target = this.normalizeComparableAbsolutePath(targetPath)
|
||||
const root = this.normalizeComparableAbsolutePath(rootPath)
|
||||
return target === root || target.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
private normalizeComparableAbsolutePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath)
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
return isSameOrInsidePathForPlatform(targetPath, rootPath)
|
||||
}
|
||||
|
||||
private normalizeRelativePath(filePath: string): string {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user