Files
Claude-Code-Workflow/ccw/src/utils/path-resolver.ts
catlog22 eea859dd6f fix(cli): 修复 Windows 路径反斜杠被吞掉的问题并添加跨平台路径支持
- 重写 escapeWindowsArg 函数,正确处理反斜杠和引号转义
- 添加 escapeUnixArg 函数支持 Linux/macOS shell 转义
- 添加 normalizePathSeparators 函数自动转换路径分隔符
- 修复 vscode-lsp.ts 中的 TypeScript 类型错误
2026-01-20 09:44:49 +08:00

393 lines
12 KiB
TypeScript

import { resolve, join, relative, isAbsolute } from 'path';
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { StoragePaths, ensureStorageDir, LegacyPaths } from '../config/storage-paths.js';
/**
* Validation result for path operations
*/
export interface PathValidationResult {
valid: boolean;
path: string | null;
error: string | null;
}
/**
* Options for path validation
*/
export interface ValidatePathOptions {
baseDir?: string | null;
mustExist?: boolean;
allowHome?: boolean;
}
/**
* Normalize path separators to the native format for the current platform
* - Windows: converts / to \
* - Linux/macOS: converts \ to /
* @param inputPath - Path with potentially mixed separators
* @returns Path with native separators
*/
export function normalizePathSeparators(inputPath: string): string {
if (!inputPath) return inputPath;
if (process.platform === 'win32') {
// Windows: convert forward slashes to backslashes
return inputPath.replace(/\//g, '\\');
} else {
// Linux/macOS: convert backslashes to forward slashes
// This handles Windows-style paths being used on Unix systems
return inputPath.replace(/\\/g, '/');
}
}
/**
* Resolve a path, handling ~ for home directory
* Also handles Windows drive-relative paths (e.g., "D:path" -> "D:\path")
* and normalizes mixed slashes for cross-platform compatibility
*
* Cross-platform behavior:
* - Windows: D:/path/to/file -> D:\path\to\file
* - Linux/macOS: /path\to/file -> /path/to/file
*
* @param inputPath - Path to resolve (can use / or \ on any platform)
* @returns Absolute path with native separators
*/
export function resolvePath(inputPath: string): string {
if (!inputPath) return process.cwd();
// Handle ~ for home directory (before normalizing separators)
if (inputPath.startsWith('~')) {
const remainder = inputPath.slice(1);
return join(homedir(), normalizePathSeparators(remainder));
}
// Normalize path separators to native format
inputPath = normalizePathSeparators(inputPath);
// Handle Windows drive-relative paths (e.g., "D:path" without backslash)
// Pattern: single letter followed by colon, then immediately a non-slash character
// This converts "D:path" to "D:\path" to make it absolute
// Only apply on Windows or when path looks like a Windows drive path
if (process.platform === 'win32') {
const driveRelativeMatch = inputPath.match(/^([a-zA-Z]:)([^\\].*)$/);
if (driveRelativeMatch) {
// Insert backslash after drive letter
inputPath = driveRelativeMatch[1] + '\\' + driveRelativeMatch[2];
}
}
return resolve(inputPath);
}
/**
* Validate and sanitize a user-provided path
* Prevents path traversal attacks and validates path is within allowed boundaries
* @param inputPath - User-provided path
* @param options - Validation options
* @returns Validation result with path or error
*/
export function validatePath(inputPath: string, options: ValidatePathOptions = {}): PathValidationResult {
const { baseDir = null, mustExist = false, allowHome = true } = options;
// Check for empty/null input
if (!inputPath || typeof inputPath !== 'string') {
return { valid: false, path: null, error: 'Path is required' };
}
// Trim whitespace
const trimmedPath = inputPath.trim();
// Check for suspicious patterns (null bytes, control characters)
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Path contains invalid characters' };
}
// Resolve the path
let resolvedPath: string;
try {
resolvedPath = resolvePath(trimmedPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { valid: false, path: null, error: `Invalid path: ${message}` };
}
// Check if within base directory when specified (pre-symlink resolution)
const resolvedBase = baseDir ? resolvePath(baseDir) : null;
if (resolvedBase) {
const relativePath = relative(resolvedBase, resolvedPath);
// Path traversal detection: relative path should not start with '..'
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return {
valid: false,
path: null,
error: `Path must be within ${resolvedBase}`
};
}
}
// Check if path exists when required
if (mustExist && !existsSync(resolvedPath)) {
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
}
// Get real path if it exists (resolves symlinks)
let realPath = resolvedPath;
if (existsSync(resolvedPath)) {
try {
realPath = realpathSync(resolvedPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { valid: false, path: null, error: `Cannot resolve path: ${message}` };
}
} else if (resolvedBase) {
// For non-existent paths, resolve the nearest existing ancestor to prevent symlink-based escapes
// (e.g., baseDir/link/newfile where baseDir/link is a symlink to a disallowed location).
let existingPath = resolvedPath;
while (!existsSync(existingPath)) {
const parent = resolve(existingPath, '..');
if (parent === existingPath) break;
existingPath = parent;
}
if (existsSync(existingPath)) {
try {
const realExisting = realpathSync(existingPath);
const remainder = relative(existingPath, resolvedPath);
realPath = remainder && remainder !== '.' ? join(realExisting, remainder) : realExisting;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { valid: false, path: null, error: `Cannot resolve path: ${message}` };
}
}
}
// Check if within base directory when specified (post-symlink resolution)
if (resolvedBase) {
const relativePath = relative(resolvedBase, realPath);
// Path traversal detection: relative path should not start with '..'
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return {
valid: false,
path: null,
error: `Path must be within ${resolvedBase}`
};
}
}
// Check home directory restriction
if (!allowHome) {
const home = homedir();
if (realPath === home || realPath.startsWith(home + '/') || realPath.startsWith(home + '\\')) {
// This is fine, we're just checking if it's explicitly the home dir itself
}
}
return { valid: true, path: realPath, error: null };
}
/**
* Validate output file path for writing
* @param outputPath - Output file path
* @param defaultDir - Default directory if path is relative
* @returns Validation result with path or error
*/
export function validateOutputPath(outputPath: string, defaultDir: string = process.cwd()): PathValidationResult {
if (!outputPath || typeof outputPath !== 'string') {
return { valid: false, path: null, error: 'Output path is required' };
}
const trimmedPath = outputPath.trim();
// Check for suspicious patterns
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Output path contains invalid characters' };
}
// Resolve the path
let resolvedPath: string;
try {
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
resolvedPath = resolve(resolvedPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { valid: false, path: null, error: `Invalid output path: ${message}` };
}
// Ensure it's not a directory
if (existsSync(resolvedPath)) {
try {
const stat = statSync(resolvedPath);
if (stat.isDirectory()) {
return { valid: false, path: null, error: 'Output path is a directory, expected a file' };
}
} catch {
// Ignore stat errors
}
}
return { valid: true, path: resolvedPath, error: null };
}
/**
* Get potential template locations
* @returns Array of existing template directories
*/
export function getTemplateLocations(): string[] {
const locations = [
join(homedir(), '.claude', 'templates'),
join(process.cwd(), '.claude', 'templates')
];
return locations.filter(loc => existsSync(loc));
}
/**
* Find a template file in known locations
* @param templateName - Name of template file (e.g., 'workflow-dashboard.html')
* @returns Path to template or null if not found
*/
export function findTemplate(templateName: string): string | null {
const locations = getTemplateLocations();
for (const loc of locations) {
const templatePath = join(loc, templateName);
if (existsSync(templatePath)) {
return templatePath;
}
}
return null;
}
/**
* Ensure directory exists, creating if necessary
* @param dirPath - Directory path to ensure
*/
export function ensureDir(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
}
/**
* Normalize path for display (handle Windows backslashes)
* @param filePath - Path to normalize
* @returns Normalized path with forward slashes
*/
export function normalizePathForDisplay(filePath: string): string {
return filePath.replace(/\\/g, '/');
}
// Recent paths storage - uses centralized storage with backward compatibility
const MAX_RECENT_PATHS = 10;
/**
* Get the recent paths file location
* Uses new location but falls back to legacy location for backward compatibility
*/
function getRecentPathsFile(): string {
const newPath = StoragePaths.global.recentPaths();
const legacyPath = LegacyPaths.recentPaths();
// Backward compatibility: use legacy if it exists and new doesn't
if (!existsSync(newPath) && existsSync(legacyPath)) {
return legacyPath;
}
return newPath;
}
/**
* Recent paths data structure
*/
interface RecentPathsData {
paths: string[];
}
/**
* Get recent project paths
* @returns Array of recent paths
*/
export function getRecentPaths(): string[] {
try {
const recentPathsFile = getRecentPathsFile();
if (existsSync(recentPathsFile)) {
const content = readFileSync(recentPathsFile, 'utf8');
const data = JSON.parse(content) as RecentPathsData;
return Array.isArray(data.paths) ? data.paths : [];
}
} catch {
// Ignore errors, return empty array
}
return [];
}
/**
* Track a project path (add to recent paths)
* @param projectPath - Path to track
*/
export function trackRecentPath(projectPath: string): void {
try {
const normalized = normalizePathForDisplay(resolvePath(projectPath));
let paths = getRecentPaths();
// Remove if already exists (will be added to front)
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
// Add to front
paths.unshift(normalized);
// Limit to max
paths = paths.slice(0, MAX_RECENT_PATHS);
// Save to new centralized location
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
} catch {
// Ignore errors
}
}
/**
* Clear recent paths
*/
export function clearRecentPaths(): void {
try {
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths: [] }, null, 2), 'utf8');
} catch {
// Ignore errors
}
}
/**
* Remove a specific path from recent paths
* @param pathToRemove - Path to remove
* @returns True if removed, false if not found
*/
export function removeRecentPath(pathToRemove: string): boolean {
try {
const normalized = normalizePathForDisplay(resolvePath(pathToRemove));
let paths = getRecentPaths();
const originalLength = paths.length;
// Filter out the path to remove
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
if (paths.length < originalLength) {
// Save updated list to new centralized location
const recentPathsFile = StoragePaths.global.recentPaths();
ensureStorageDir(StoragePaths.global.config());
writeFileSync(recentPathsFile, JSON.stringify({ paths }, null, 2), 'utf8');
return true;
}
return false;
} catch {
return false;
}
}