mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
1 parent
23e145427c
commit
98ffd88670
3 files changed
+65
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user