From 98ffd886708bf26d4cfde364e7a236ab03552046 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 19 Jul 2026 00:19:13 +0800 Subject: [PATCH] fix(electron): block overlapping legacy migrations Resolve legacy and active data directories through real paths and reject parent-child overlaps before any recursive deletion. Cover settings migration, parent paths, and path aliases in the desktop path regression test. --- apps/desktop/main/paths/legacy-migration.ts | 17 +++++----- apps/desktop/main/paths/paths.test.ts | 35 ++++++++++++++++----- apps/desktop/main/utils/pathUtils.ts | 34 +++++++++++++++++--- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/apps/desktop/main/paths/legacy-migration.ts b/apps/desktop/main/paths/legacy-migration.ts index 3a4f8c19..7e24d105 100644 --- a/apps/desktop/main/paths/legacy-migration.ts +++ b/apps/desktop/main/paths/legacy-migration.ts @@ -6,7 +6,7 @@ import { app } from 'electron' import * as fs from 'node:fs' import * as path from 'node:path' import { loadConfig, writeConfigField } from '@openchatlab/config' -import { arePathsEqual, copyDirMerge, copyDirRecursive, writeMigrationLog } from '../utils/pathUtils' +import { arePathsOverlapping, copyDirMerge, copyDirRecursive, writeMigrationLog } from '../utils/pathUtils' import { shouldMarkUnifiedDirMigrationDone } from '../utils/unifiedDirMigration' import { ensureDir, @@ -47,8 +47,8 @@ export function getLegacyDataDir(): string { export function needsLegacyMigration(): boolean { const legacyDir = getLegacyDataDir() - // 用户可以主动将 Documents/ChatLab 设为当前数据目录,此时它不是待迁移的旧目录。 - return fs.existsSync(legacyDir) && !arePathsEqual(legacyDir, getUserDataDir()) + // 用户可以将 Documents/ChatLab 或其父子目录设为当前数据目录,此时它不是可安全删除的旧目录。 + return fs.existsSync(legacyDir) && !arePathsOverlapping(legacyDir, getUserDataDir()) } /** @@ -134,9 +134,9 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string return { success: true, migratedDirs: [] } } - // 迁移函数包含递归删除,必须在执行层再次阻止源目录与当前数据目录相同。 - if (arePathsEqual(legacyDir, newDir)) { - const message = `Legacy migration skipped because source is the active data directory: ${legacyDir}` + // 迁移函数包含递归删除,必须在执行层再次阻止源目录与当前数据目录重叠。 + if (arePathsOverlapping(legacyDir, newDir)) { + const message = `Legacy migration skipped because source overlaps the active data directory: ${legacyDir} -> ${newDir}` console.warn(`[Paths] ${message}`) writeMigrationLog(getLogsDir(), message, ensureDir) return { success: true, migratedDirs: [] } @@ -202,8 +202,9 @@ export function removeLegacyDir(): boolean { return true } - if (arePathsEqual(legacyDir, getUserDataDir())) { - const message = `Legacy directory removal blocked because it is the active data directory: ${legacyDir}` + const currentDir = getUserDataDir() + if (arePathsOverlapping(legacyDir, currentDir)) { + const message = `Legacy directory removal blocked because it overlaps the active data directory: ${legacyDir} -> ${currentDir}` console.error(`[Paths] ${message}`) writeMigrationLog(getLogsDir(), message, ensureDir) return false diff --git a/apps/desktop/main/paths/paths.test.ts b/apps/desktop/main/paths/paths.test.ts index cecf2e50..7af9f03d 100644 --- a/apps/desktop/main/paths/paths.test.ts +++ b/apps/desktop/main/paths/paths.test.ts @@ -102,17 +102,36 @@ test('desktop paths preserve data across configured and legacy directory migrati fs.writeFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'legacy', 'utf-8') fs.writeFileSync(path.join(legacyDir, 'temp', 'stale.tmp'), 'temporary', 'utf-8') - configuredUserDataDir = legacyDir - paths.setCachedUserDataDir(legacyDir) - assert.equal(paths.needsLegacyMigration(), false) - const sameDirResult = paths.migrateFromLegacyDir() - assert.equal(sameDirResult.success, true) - assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') - assert.equal(paths.removeLegacyDir(), false) - assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') + const assertLegacyMigrationBlocked = (activeDir: string) => { + configuredUserDataDir = activeDir + paths.setCachedUserDataDir(activeDir) + assert.equal(paths.needsLegacyMigration(), false) + assert.equal(paths.migrateFromLegacyDir().success, true) + assert.equal(paths.removeLegacyDir(), false) + assert.equal(fs.readFileSync(path.join(legacyDir, 'databases', 'legacy.db'), 'utf-8'), 'legacy') + } + + assertLegacyMigrationBlocked(legacyDir) configuredUserDataDir = targetDir paths.setCachedUserDataDir(targetDir) + const nestedActiveDir = path.join(legacyDir, 'active-data') + assert.equal(paths.setCustomDataDir(nestedActiveDir, true).success, true) + assert.equal(paths.applyPendingDataDirMigration().success, true) + assert.equal(configuredUserDataDir, nestedActiveDir) + assertLegacyMigrationBlocked(nestedActiveDir) + assert.equal(fs.readFileSync(path.join(nestedActiveDir, 'databases', 'current.db'), 'utf-8'), 'sqlite') + + assertLegacyMigrationBlocked(documentsDir) + + const legacyAliasDir = path.join(root, 'legacy-alias') + fs.symlinkSync(legacyDir, legacyAliasDir, process.platform === 'win32' ? 'junction' : 'dir') + assertLegacyMigrationBlocked(legacyAliasDir) + + configuredUserDataDir = targetDir + paths.setCachedUserDataDir(targetDir) + fs.unlinkSync(legacyAliasDir) + fs.rmSync(nestedActiveDir, { recursive: true, force: true }) assert.equal(paths.needsLegacyMigration(), true) const legacyResult = paths.migrateFromLegacyDir() assert.equal(legacyResult.success, true) diff --git a/apps/desktop/main/utils/pathUtils.ts b/apps/desktop/main/utils/pathUtils.ts index f047218e..e0819964 100644 --- a/apps/desktop/main/utils/pathUtils.ts +++ b/apps/desktop/main/utils/pathUtils.ts @@ -21,18 +21,42 @@ const DANGEROUS_PATHS = [ '/Library', ] -// 统一路径标准化(兼容 Windows 大小写差异) +// 统一路径标准化:解析已存在的真实父目录,同时兼容 Windows 大小写差异。 function normalizePathForCompare(input: string): string { const resolved = path.resolve(input) - const normalized = path.normalize(resolved) + const missingSegments: string[] = [] + let existingPath = resolved + + // 目标目录可能尚未创建,因此从最近的已存在父目录解析符号链接或 Windows 目录别名。 + while (!fs.existsSync(existingPath)) { + const parent = path.dirname(existingPath) + if (parent === existingPath) break + missingSegments.unshift(path.basename(existingPath)) + existingPath = parent + } + + try { + existingPath = fs.realpathSync.native(existingPath) + } catch { + // 无法解析真实路径时保留 path.resolve 的结果,仍可执行普通的路径重叠检查。 + } + + const normalized = path.normalize(path.join(existingPath, ...missingSegments)) return process.platform === 'win32' ? normalized.toLowerCase() : normalized } /** - * 判断两个路径是否指向同一个规范化位置 + * 判断两个路径是否相同或存在父子目录重叠 */ -export function arePathsEqual(first: string, second: string): boolean { - return normalizePathForCompare(first) === normalizePathForCompare(second) +export function arePathsOverlapping(first: string, second: string): boolean { + const firstPath = normalizePathForCompare(first) + const secondPath = normalizePathForCompare(second) + + return ( + firstPath === secondPath || + secondPath.startsWith(`${firstPath}${path.sep}`) || + firstPath.startsWith(`${secondPath}${path.sep}`) + ) } /**