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.

<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)`
format, will close the issue(s) when PR gets merged)*: -->

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

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR
requires additional action from users switching to the new release,
include the string "action required".
2. If no release note is required, just write "NONE".
3. Only include user-facing changes (new features, bug fixes visible to
users, UI changes, behavior changes). For CI, maintenance, internal
refactoring, build tooling, or other non-user-facing work, write "NONE".
-->

```release-note
Fix Windows backup failures caused by symlinks in the Data directory when running without administrator privileges.
```

---------

Signed-off-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
亢奋猫
2026-04-23 12:12:55 +08:00
committed by GitHub
parent 09da6a3364
commit 930fdf75bd
2 changed files with 443 additions and 79 deletions

View File

@@ -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<number> {
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<string>()
): Promise<number> {
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<void> {
// First count total files
let totalFiles = 0
let processedFiles = 0
let lastProgressReported = 0
// Calculate total file count
const countFiles = async (dir: string): Promise<number> => {
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<string>()
totalFiles = await countFiles(source)
// Copy files and update progress
const copyDir = async (src: string, dest: string): Promise<void> => {
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<string>): Promise<string | null> {
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<EffectiveEntryStats | null> {
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<Stats | null> {
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

View File

@@ -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