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.
This commit is contained in:
digua committed 2026-07-19 00:24:50 +08:00
1 parent 23e145427c
commit 98ffd88670
3 files changed
+65 -21

No files matched your search

+9 -8
View File
@@ -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
+27 -8
View File
@@ -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)
+29 -5
View File
@@ -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}`)
)
}
/**