From 930fdf75bd467d4b44ffa751bfeb36b9fcaa2e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Thu, 23 Apr 2026 12:12:55 +0800 Subject: [PATCH] fix: handle symlinks during backup copy (#14471) ### What this PR does Before this PR: Windows backups could fail for non-admin users when the Data directory contained symlinks, because the backup copy attempted to recreate symlink entries and hit `EPERM: operation not permitted, symlink`. After this PR: Backup copy dereferences valid symlinks and stores the target file or directory contents in the backup. Broken or unreadable symlinks are skipped with a warning. Restore copy skips symlink entries from old or handcrafted backup archives instead of following them. Fixes #14360 ### Why we need it and why it was done in this way The following tradeoffs were made: This preserves usable backup content instead of preserving filesystem-level symlink identity. Restored symlinked skills become regular files/directories, which avoids requiring elevated Windows privileges. The following alternatives were considered: Copying symlink entries directly was considered, but it can still fail on Windows without administrator privileges or Developer Mode. Skipping symlinks entirely was also considered, but that can omit useful skill content from backups. Links to places where the discussion took place: https://github.com/CherryHQ/cherry-studio/issues/14360 ### Breaking changes None. ### Special notes for your reviewer This is intentionally scoped as a `main` hotfix. It only changes BackupManager's file copy behavior and related tests. Validated locally with: - `pnpm test:main src/main/services/__tests__/BackupManager.test.ts` - `pnpm typecheck:node` - `pnpm format` - `pnpm test` - `pnpm lint` - `git diff --check` `pnpm lint` still prints an unrelated existing React Hooks warning in `src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx:177`, but exits successfully. ### Checklist This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR. Approvers are expected to review this list. - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [x] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note Fix Windows backup failures caused by symlinks in the Data directory when running without administrator privileges. ``` --------- Signed-off-by: kangfenmao --- src/main/services/BackupManager.ts | 296 +++++++++++++----- .../services/__tests__/BackupManager.test.ts | 226 +++++++++++++ 2 files changed, 443 insertions(+), 79 deletions(-) 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