From 920fba7e1333ff53b7826981bf61b238091dbcf6 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:59 -0400 Subject: [PATCH] fix: reject symlinked cleanup directories --- src/commands/cleanup-command.ts | 28 ++++++++++++--------- tests/unit/commands/cleanup-command.test.ts | 28 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 47bc4ac2..1db86c7b 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -36,12 +36,21 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } +/** Return entries for a real directory, rejecting symlinked directory targets. */ +function readRealDirectory(dirPath: string): string[] { + try { + const stats = fs.lstatSync(dirPath); + if (!stats.isDirectory() || stats.isSymbolicLink()) return []; + return fs.readdirSync(dirPath); + } catch { + return []; + } +} + /** Calculate total size of regular top-level files in a directory */ function getDirSize(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; - let totalSize = 0; - const entries = fs.readdirSync(dirPath); + const entries = readRealDirectory(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); @@ -60,9 +69,8 @@ function getDirSize(dirPath: string): number { /** Count files in a directory */ function countFiles(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; let count = 0; - const entries = fs.readdirSync(dirPath); + const entries = readRealDirectory(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); @@ -78,13 +86,11 @@ function countFiles(dirPath: string): number { return count; } -/** Delete all regular files in a directory (skips symlinks for safety) */ +/** Delete all regular files in a real directory (skips symlinks for safety) */ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number } { - if (!fs.existsSync(dirPath)) return { deleted: 0, freedBytes: 0 }; - let deleted = 0; let freedBytes = 0; - const files = fs.readdirSync(dirPath); + const files = readRealDirectory(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); @@ -116,11 +122,9 @@ interface ErrorLogInfo { /** Get error log files with metadata */ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { - if (!fs.existsSync(logsDir)) return []; - const now = Date.now(); const files: ErrorLogInfo[] = []; - const entries = fs.readdirSync(logsDir); + const entries = readRealDirectory(logsDir); for (const entry of entries) { // Only process error-*.log files diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index 7f234dae..1e09e680 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -53,4 +53,32 @@ describe('cleanup command', () => { logSpy.mockRestore(); } }); + + it('does not follow a symlinked CCS archive directory during cleanup', async () => { + if (process.platform === 'win32') return; + + const ccsLogsDir = getNativeLogsDir(); + const archiveDir = getLogArchiveDir(); + const victimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-victim-')); + + fs.mkdirSync(ccsLogsDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keepme.log'), 'do not delete'); + fs.symlinkSync(victimDir, archiveDir, 'dir'); + + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--force']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('No CCS or CLIProxy logs found.'); + expect(fs.existsSync(path.join(victimDir, 'keepme.log'))).toBe(true); + } finally { + logSpy.mockRestore(); + fs.rmSync(victimDir, { recursive: true, force: true }); + } + }); });