diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index bde93cc4aa..5d148b65b6 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -14,6 +14,8 @@ * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 * -------------------------------------------------------------------------- */ +import type { Stats } from 'node:fs' + import { loggerService } from '@logger' import { isWin } from '@main/constant' import { IpcChannel } from '@shared/IpcChannel' @@ -27,13 +29,29 @@ import * as path from 'path' import type { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' -import { resolveAndValidatePath } from '../utils/file' +import { isPathInside, resolveAndValidatePath } from '../utils/file' import S3Storage from './S3Storage' import WebDav from './WebDav' import { windowService } from './WindowService' const logger = loggerService.withContext('BackupManager') +interface CopyDirOptions { + dereferenceSymlinks: boolean + sourceRootRealPath?: string +} + +interface EffectiveEntryStats { + isSymlink: boolean + stats: Stats +} + +interface ProgressData { + stage: string + progress: number + total: number +} + class BackupManager { private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup') @@ -192,14 +210,14 @@ class BackupManager { const tempDataDir = path.join(this.tempDir, 'Data') if (await fs.pathExists(sourcePath)) { - const totalSize = await this.getDirSize(sourcePath) - let copiedSize = 0 + const totalSize = await this.getDirSize(sourcePath, { dereferenceSymlinks: true }) - await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { - copiedSize += size - const progress = Math.min(80, 52 + Math.floor((copiedSize / totalSize) * 28)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + await this.copyDirWithProgress( + sourcePath, + tempDataDir, + this.createCopyProgressHandler(totalSize, 52, 80, 'copying_files', onProgress), + { dereferenceSymlinks: true } + ) } } else { logger.debug('[backupDirect] Skip the backup of the file') @@ -287,15 +305,15 @@ class BackupManager { const tempDataDir = path.join(this.tempDir, 'Data') // Get total size of source directory - const totalSize = await this.getDirSize(sourcePath) - let copiedSize = 0 + const totalSize = await this.getDirSize(sourcePath, { dereferenceSymlinks: true }) // Use streaming copy - await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { - copiedSize += size - const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + await this.copyDirWithProgress( + sourcePath, + tempDataDir, + this.createCopyProgressHandler(totalSize, 0, 50, 'copying_files', onProgress), + { dereferenceSymlinks: true } + ) onProgress({ stage: 'preparing_compression', progress: 50, total: 100 }) } else { @@ -607,16 +625,16 @@ class BackupManager { if (dataExists && dataFiles.length > 0) { logger.debug('[restoreDirect] Restoring Data directory...') - const totalSize = await this.getDirSize(dataSource) - let copiedSize = 0 + const totalSize = await this.getDirSize(dataSource, { dereferenceSymlinks: false }) await fs.remove(dataDest) - await this.copyDirWithProgress(dataSource, dataDest, (size) => { - copiedSize += size - const progress = Math.min(95, 65 + Math.floor((copiedSize / totalSize) * 30)) - onProgress({ stage: 'restoring_data', progress, total: 100 }) - }) + await this.copyDirWithProgress( + dataSource, + dataDest, + this.createCopyProgressHandler(totalSize, 65, 95, 'restoring_data', onProgress), + { dereferenceSymlinks: false } + ) } else { logger.debug('[restoreDirect] No Data directory to restore') } @@ -667,17 +685,17 @@ class BackupManager { if (dataExists && dataFiles.length > 0) { // Get total size of source directory - const dataTotalSize = await this.getDirSize(dataSourcePath) - let copiedSize = 0 + const dataTotalSize = await this.getDirSize(dataSourcePath, { dereferenceSymlinks: false }) await fs.remove(dataDestPath) // Use streaming copy - await this.copyDirWithProgress(dataSourcePath, dataDestPath, (size) => { - copiedSize += size - const progress = Math.min(85, 35 + Math.floor((copiedSize / dataTotalSize) * 50)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + await this.copyDirWithProgress( + dataSourcePath, + dataDestPath, + this.createCopyProgressHandler(dataTotalSize, 35, 85, 'copying_files', onProgress), + { dereferenceSymlinks: false } + ) } else { logger.debug('[restoreLegacy] skipBackupFile is true, skip restoring Data directory') } @@ -799,7 +817,7 @@ class BackupManager { * copying_files stage is never logged as it generates too many logs. */ private onProgress = (channel: IpcChannel, shouldLog: boolean) => { - return (processData: { stage: string; progress: number; total: number }) => { + return (processData: ProgressData) => { const mainWindow = windowService.getMainWindow() mainWindow?.webContents.send(channel, processData) // Never log copying_files as it generates too many log entries @@ -809,24 +827,81 @@ class BackupManager { } } + private createCopyProgressHandler( + totalSize: number, + startProgress: number, + endProgress: number, + stage: string, + onProgress: (processData: ProgressData) => void + ) { + let copiedSize = 0 + let lastReported = startProgress + + return (size: number) => { + copiedSize += size + const progress = + totalSize > 0 + ? Math.min(endProgress, startProgress + Math.floor((copiedSize / totalSize) * (endProgress - startProgress))) + : endProgress + if (progress === lastReported && copiedSize < totalSize) { + return + } + lastReported = progress + onProgress({ stage, progress, total: 100 }) + } + } + /** * Calculate total size of a directory recursively * @param dirPath - Directory path to calculate size * @returns Total size in bytes */ - private async getDirSize(dirPath: string): Promise { - let size = 0 - const items = await fs.readdir(dirPath, { withFileTypes: true }) - - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - size += await this.getDirSize(fullPath) - } else { - const stats = await fs.stat(fullPath) - size += stats.size - } + private async getDirSize( + dirPath: string, + options: CopyDirOptions, + activeDirectoryRealPaths = new Set() + ): Promise { + const copyOptions = { + ...options, + sourceRootRealPath: options.sourceRootRealPath ?? (await fs.realpath(dirPath)) } + const directoryRealPath = await this.enterDirectory(dirPath, activeDirectoryRealPaths) + + if (!directoryRealPath) { + return 0 + } + + let size = 0 + + try { + const items = await fs.readdir(dirPath, { withFileTypes: true }) + + for (const item of items) { + const fullPath = path.join(dirPath, item.name) + const entry = await this.getEffectiveEntryStats(fullPath, copyOptions) + + if (!entry) { + continue + } + + if (entry.stats.isDirectory()) { + if (entry.isSymlink) { + try { + size += await this.getDirSize(fullPath, copyOptions, activeDirectoryRealPaths) + } catch (error) { + this.logSkippedSymlink(fullPath, error) + } + } else { + size += await this.getDirSize(fullPath, copyOptions, activeDirectoryRealPaths) + } + } else if (entry.stats.isFile()) { + size += entry.stats.size + } + } + } finally { + activeDirectoryRealPaths.delete(directoryRealPath) + } + return size } @@ -927,58 +1002,121 @@ class BackupManager { private async copyDirWithProgress( source: string, destination: string, - onProgress: (size: number) => void + onProgress: (size: number) => void, + options: CopyDirOptions ): Promise { - // First count total files - let totalFiles = 0 - let processedFiles = 0 - let lastProgressReported = 0 - - // Calculate total file count - const countFiles = async (dir: string): Promise => { - let count = 0 - const items = await fs.readdir(dir, { withFileTypes: true }) - for (const item of items) { - if (item.isDirectory()) { - count += await countFiles(path.join(dir, item.name)) - } else { - count++ - } - } - return count + const copyOptions = { + ...options, + sourceRootRealPath: options.sourceRootRealPath ?? (await fs.realpath(source)) } + const activeDirectoryRealPaths = new Set() - totalFiles = await countFiles(source) - - // Copy files and update progress const copyDir = async (src: string, dest: string): Promise => { - const items = await fs.readdir(src, { withFileTypes: true }) + const directoryRealPath = await this.enterDirectory(src, activeDirectoryRealPaths) - for (const item of items) { - const sourcePath = path.join(src, item.name) - const destPath = path.join(dest, item.name) + if (!directoryRealPath) { + return + } - if (item.isDirectory()) { - await fs.ensureDir(destPath) - await copyDir(sourcePath, destPath) - } else { - const stats = await fs.stat(sourcePath) - await fs.copy(sourcePath, destPath) - processedFiles++ + try { + await fs.ensureDir(dest) - // Only report progress when change exceeds 5% - const currentProgress = Math.floor((processedFiles / totalFiles) * 100) - if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { - lastProgressReported = currentProgress - onProgress(stats.size) + const items = await fs.readdir(src, { withFileTypes: true }) + + for (const item of items) { + const sourcePath = path.join(src, item.name) + const destPath = path.join(dest, item.name) + const entry = await this.getEffectiveEntryStats(sourcePath, copyOptions) + + if (!entry) { + continue + } + + if (entry.stats.isDirectory()) { + try { + await copyDir(sourcePath, destPath) + } catch (error) { + if (!entry.isSymlink) { + throw error + } + await fs.remove(destPath).catch(() => {}) + this.logSkippedSymlink(sourcePath, error) + } + } else if (entry.stats.isFile()) { + if (entry.isSymlink) { + await fs.copy(sourcePath, destPath, { dereference: true }) + } else { + await fs.copy(sourcePath, destPath) + } + onProgress(entry.stats.size) + } else if (entry.isSymlink) { + logger.warn('[BackupManager] Skipping symlink to unsupported target', { path: sourcePath }) } } + } finally { + activeDirectoryRealPaths.delete(directoryRealPath) } } await copyDir(source, destination) } + private async enterDirectory(dirPath: string, activeDirectoryRealPaths: Set): Promise { + const realPath = await fs.realpath(dirPath) + + if (activeDirectoryRealPaths.has(realPath)) { + logger.warn('[BackupManager] Skipping circular symlink directory', { path: dirPath, realPath }) + return null + } + + activeDirectoryRealPaths.add(realPath) + return realPath + } + + private async getEffectiveEntryStats( + sourcePath: string, + options: CopyDirOptions + ): Promise { + const stats = await fs.lstat(sourcePath) + + if (!stats.isSymbolicLink()) { + return { isSymlink: false, stats } + } + + const targetStats = await this.getSymlinkTargetStats(sourcePath, options) + return targetStats ? { isSymlink: true, stats: targetStats } : null + } + + private async getSymlinkTargetStats(sourcePath: string, options: CopyDirOptions): Promise { + if (!options.dereferenceSymlinks) { + logger.warn('[BackupManager] Skipping symlink (dereferenceSymlinks=false)', { path: sourcePath }) + return null + } + + try { + const [targetStats, targetRealPath] = await Promise.all([fs.stat(sourcePath), fs.realpath(sourcePath)]) + const context = { + path: sourcePath, + sourceRootRealPath: options.sourceRootRealPath, + targetRealPath + } + + if (options.sourceRootRealPath && !isPathInside(targetRealPath, options.sourceRootRealPath)) { + logger.warn('[BackupManager] Dereferencing symlink outside source root during backup copy', context) + } else { + logger.info('[BackupManager] Dereferencing symlink during backup copy', context) + } + return targetStats + } catch (error) { + this.logSkippedSymlink(sourcePath, error) + return null + } + } + + private logSkippedSymlink(sourcePath: string, error: unknown) { + logger.warn('[BackupManager] Skipping broken or unreadable symlink', { path: sourcePath, error }) + } + /** * Check WebDAV connection * @param _ - Electron IPC event diff --git a/src/main/services/__tests__/BackupManager.test.ts b/src/main/services/__tests__/BackupManager.test.ts index 6fc6ff658e..2a6444fc48 100644 --- a/src/main/services/__tests__/BackupManager.test.ts +++ b/src/main/services/__tests__/BackupManager.test.ts @@ -69,7 +69,9 @@ vi.mock('fs-extra', () => ({ ensureDir: vi.fn(), copy: vi.fn(), readdir: vi.fn(), + lstat: vi.fn(), stat: vi.fn(), + realpath: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), createWriteStream: vi.fn(), @@ -80,7 +82,9 @@ vi.mock('fs-extra', () => ({ ensureDir: vi.fn(), copy: vi.fn(), readdir: vi.fn(), + lstat: vi.fn(), stat: vi.fn(), + realpath: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), createWriteStream: vi.fn(), @@ -118,6 +122,228 @@ import * as fs from 'fs-extra' import BackupManager from '../BackupManager' +const createDirent = (name: string) => ({ name }) + +const createStats = (type: 'directory' | 'file' | 'symlink', size = 0) => ({ + size, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink' +}) + +describe('BackupManager.copyDirWithProgress - Symlink Handling', () => { + let backupManager: BackupManager + + beforeEach(() => { + vi.clearAllMocks() + backupManager = new BackupManager() + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never) + vi.mocked(fs.copy).mockResolvedValue(undefined as never) + vi.mocked(fs.realpath).mockImplementation(async (entryPath) => String(entryPath) as never) + }) + + it('should copy the real file when a valid symlink points to a file', async () => { + vi.mocked(fs.readdir).mockResolvedValue([createDirent('skill-link')] as never) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + vi.mocked(fs.stat).mockResolvedValue(createStats('file', 42) as never) + + const onProgress = vi.fn() + + await (backupManager as any).copyDirWithProgress('/src', '/dest', onProgress, { dereferenceSymlinks: true }) + + expect(fs.copy).toHaveBeenCalledWith('/src/skill-link', '/dest/skill-link', { dereference: true }) + expect(onProgress).toHaveBeenCalledWith(42) + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Dereferencing symlink during backup copy'), + expect.objectContaining({ + path: '/src/skill-link', + sourceRootRealPath: '/src', + targetRealPath: '/src/skill-link' + }) + ) + }) + + it('should warn when dereferencing a symlink target outside the source root', async () => { + vi.mocked(fs.readdir).mockResolvedValue([createDirent('external-link')] as never) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + vi.mocked(fs.stat).mockResolvedValue(createStats('file', 8) as never) + vi.mocked(fs.realpath).mockImplementation(async (entryPath) => { + const sourcePath = String(entryPath) + return (sourcePath === '/src/external-link' ? '/external/file.txt' : sourcePath) as never + }) + + await (backupManager as any).copyDirWithProgress('/src', '/dest', vi.fn(), { dereferenceSymlinks: true }) + + expect(fs.copy).toHaveBeenCalledWith('/src/external-link', '/dest/external-link', { dereference: true }) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Dereferencing symlink outside source root'), + expect.objectContaining({ + path: '/src/external-link', + sourceRootRealPath: '/src', + targetRealPath: '/external/file.txt' + }) + ) + }) + + it('should copy the real directory contents when a valid symlink points to a directory', async () => { + vi.mocked(fs.readdir).mockImplementation(async (dir) => { + const dirPath = String(dir) + if (dirPath === '/src') { + return [createDirent('skill-link')] as never + } + if (dirPath === '/src/skill-link') { + return [createDirent('SKILL.md')] as never + } + return [] as never + }) + vi.mocked(fs.lstat).mockImplementation(async (entryPath) => { + const sourcePath = String(entryPath) + if (sourcePath === '/src/skill-link') { + return createStats('symlink') as never + } + if (sourcePath === '/src/skill-link/SKILL.md') { + return createStats('file', 12) as never + } + return createStats('directory') as never + }) + vi.mocked(fs.stat).mockResolvedValue(createStats('directory') as never) + + const onProgress = vi.fn() + + await (backupManager as any).copyDirWithProgress('/src', '/dest', onProgress, { dereferenceSymlinks: true }) + + expect(fs.ensureDir).toHaveBeenCalledWith('/dest/skill-link') + expect(fs.copy).toHaveBeenCalledWith('/src/skill-link/SKILL.md', '/dest/skill-link/SKILL.md') + expect(onProgress).toHaveBeenCalledWith(12) + }) + + it('should skip a broken symlink without failing backup copy', async () => { + vi.mocked(fs.readdir).mockResolvedValue([createDirent('missing-skill')] as never) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) as never) + + await expect( + (backupManager as any).copyDirWithProgress('/src', '/dest', vi.fn(), { dereferenceSymlinks: true }) + ).resolves.toBeUndefined() + + expect(fs.copy).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping broken or unreadable symlink'), + expect.objectContaining({ path: '/src/missing-skill' }) + ) + }) + + it('should preserve normal file and directory copy behavior', async () => { + vi.mocked(fs.readdir).mockImplementation(async (dir) => { + const dirPath = String(dir) + if (dirPath === '/src') { + return [createDirent('file.txt'), createDirent('nested')] as never + } + if (dirPath === '/src/nested') { + return [createDirent('child.txt')] as never + } + return [] as never + }) + vi.mocked(fs.lstat).mockImplementation(async (entryPath) => { + const sourcePath = String(entryPath) + if (sourcePath === '/src/nested') { + return createStats('directory') as never + } + return createStats('file', 5) as never + }) + + const onProgress = vi.fn() + + await (backupManager as any).copyDirWithProgress('/src', '/dest', onProgress, { dereferenceSymlinks: true }) + + expect(fs.copy).toHaveBeenCalledWith('/src/file.txt', '/dest/file.txt') + expect(fs.ensureDir).toHaveBeenCalledWith('/dest/nested') + expect(fs.copy).toHaveBeenCalledWith('/src/nested/child.txt', '/dest/nested/child.txt') + expect(onProgress).toHaveBeenCalledWith(5) + }) + + it('should skip symlinks during restore copy', async () => { + vi.mocked(fs.readdir).mockResolvedValue([createDirent('restore-link')] as never) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + + await (backupManager as any).copyDirWithProgress('/restore-src', '/restore-dest', vi.fn(), { + dereferenceSymlinks: false + }) + + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.copy).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping symlink (dereferenceSymlinks=false)'), + expect.objectContaining({ path: '/restore-src/restore-link' }) + ) + }) + + it('should throttle copy progress to integer progress changes and completion', () => { + const onProgress = vi.fn() + const handleProgress = (backupManager as any).createCopyProgressHandler(100, 0, 50, 'copying_files', onProgress) + + handleProgress(1) + handleProgress(1) + handleProgress(98) + + expect(onProgress).toHaveBeenCalledTimes(2) + expect(onProgress).toHaveBeenNthCalledWith(1, { stage: 'copying_files', progress: 1, total: 100 }) + expect(onProgress).toHaveBeenNthCalledWith(2, { stage: 'copying_files', progress: 50, total: 100 }) + }) + + it('should not recurse forever when a symlinked directory points to an ancestor during size calculation', async () => { + vi.mocked(fs.readdir).mockImplementation(async (dir) => { + const dirPath = String(dir) + if (dirPath === '/src') { + return [createDirent('self-link')] as never + } + throw new Error(`Unexpected readdir: ${dirPath}`) + }) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + vi.mocked(fs.stat).mockResolvedValue(createStats('directory') as never) + vi.mocked(fs.realpath).mockImplementation(async (entryPath) => { + const sourcePath = String(entryPath) + return (sourcePath === '/src/self-link' ? '/src' : sourcePath) as never + }) + + await expect((backupManager as any).getDirSize('/src', { dereferenceSymlinks: true })).resolves.toBe(0) + + expect(fs.readdir).toHaveBeenCalledTimes(1) + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping circular symlink directory'), + expect.objectContaining({ path: '/src/self-link', realPath: '/src' }) + ) + }) + + it('should not recurse forever when copying a symlinked directory that points to an ancestor', async () => { + vi.mocked(fs.readdir).mockImplementation(async (dir) => { + const dirPath = String(dir) + if (dirPath === '/src') { + return [createDirent('self-link')] as never + } + throw new Error(`Unexpected readdir: ${dirPath}`) + }) + vi.mocked(fs.lstat).mockResolvedValue(createStats('symlink') as never) + vi.mocked(fs.stat).mockResolvedValue(createStats('directory') as never) + vi.mocked(fs.realpath).mockImplementation(async (entryPath) => { + const sourcePath = String(entryPath) + return (sourcePath === '/src/self-link' ? '/src' : sourcePath) as never + }) + + await expect( + (backupManager as any).copyDirWithProgress('/src', '/dest', vi.fn(), { dereferenceSymlinks: true }) + ).resolves.toBeUndefined() + + expect(fs.readdir).toHaveBeenCalledTimes(1) + expect(fs.ensureDir).toHaveBeenCalledWith('/dest') + expect(fs.ensureDir).not.toHaveBeenCalledWith('/dest/self-link') + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Skipping circular symlink directory'), + expect.objectContaining({ path: '/src/self-link', realPath: '/src' }) + ) + }) +}) + describe('BackupManager.deleteLanTransferBackup - Security Tests', () => { let backupManager: BackupManager