import * as fs from 'node:fs/promises' import nativeFs from 'node:fs/promises' import { spawn } from 'node:child_process' import { syncBuiltinESMExports } from 'node:module' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { DataMigration, MIGRATION_JOURNAL_FILE, type DataMigrationHooks } from './dataMigration' import type { AppModeAppLike } from './appMode' import { resolveRelocatedAttachmentPath } from '../../../src/utils/storageRelocations' import { assertMigrationManifest, migrationManifest, validateMigrationManifest } from './dataMigrationFiles' import { preserveWindowsMigrationPermissions, restrictWindowsMigrationStaging } from './migrationPermissions' const roots: string[] = [] async function fixture() { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-migration-')) roots.push(root) const home = path.join(root, 'home') const source = path.join(home, '.claude') const target = path.join(root, '新数据 directory') const userData = path.join(root, 'profile') await fs.mkdir(source, { recursive: true }) await fs.mkdir(path.join(root, 'install'), { recursive: true }) const app: AppModeAppLike = { getPath(name) { return name === 'home' ? home : name === 'userData' ? userData : path.join(root, 'install', 'haha.exe') } } const hooks: DataMigrationHooks = { preview: vi.fn(async () => ({ activeTasks: 2, externalProcesses: 0 })), quiesce: vi.fn(async () => {}), resume: vi.fn(async () => {}), restart: vi.fn(), progress: vi.fn(), platform: 'win32', permissions: { restrictStaging: vi.fn(async () => {}), preserve: vi.fn(async () => {}) } } const put = async (relative: string, value: string) => { const file = path.join(source, relative) await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, value) } await put('settings.json', '{"unknown":{"preserve":true}}') await put('projects/fixture/session.jsonl', '{"type":"user","message":"hello"}\n') await put('uploads/session/file.txt', 'attachment bytes') await fs.writeFile(path.join(home, '.claude.json'), '{"trustedProjects":{"x":true},"unknown":7}') return { root, home, source, target, userData, app, hooks, put, migration: new DataMigration(app, hooks, {}) } } afterEach(async () => { vi.restoreAllMocks() syncBuiltinESMExports() for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }) }) // A real Windows handle without FILE_SHARE_DELETE, as held by a scanner or // indexer. All paths and ACL writes belong to the disposable migration fixture. async function lockWindowsEntry(entry: string) { const script = ` $ErrorActionPreference = 'Stop' [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; public static class MigrationFixtureHandle { [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); } '@ $payload = ConvertFrom-Json -InputObject ([Console]::In.ReadLine()) $handle = [MigrationFixtureHandle]::CreateFileW($payload.entry, 1, 3, [IntPtr]::Zero, 3, 0x02000000, [IntPtr]::Zero) if ($handle.IsInvalid) { exit 1 } try { [Console]::Out.WriteLine('locked') [Console]::Out.Flush() $null = [Console]::In.ReadLine() } finally { $handle.Dispose() } ` const executable = path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') const child = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'ignore'], timeout: 15_000, }) const closed = new Promise(resolve => child.once('close', resolve)) const ready = new Promise((resolve, reject) => { let output = '' child.stdout.setEncoding('utf8') child.stdout.on('data', chunk => { output += chunk; if (output.includes('locked')) resolve() }) child.once('error', () => reject(new Error('Windows lock fixture could not start'))) child.stdin.once('error', () => reject(new Error('Windows lock fixture input failed'))) child.once('close', () => reject(new Error('Windows lock fixture exited before readiness'))) }) child.stdin.write(JSON.stringify({ entry }) + '\n', 'utf8') try { await ready } catch (error) { child.stdin.end(); await closed; throw error } return async () => { child.stdin.end('\n') if (await closed !== 0) throw new Error('Windows lock fixture did not exit cleanly') } } describe('data directory migration', () => { it.skipIf(process.platform !== 'win32')('publishes verified data after a real Windows sharing lock releases', async () => { const f = await fixture() await f.put('cc-haha/tasks/pending.json', '{"task":"preserved"}') f.hooks.permissions = { restrictStaging: restrictWindowsMigrationStaging, preserve: preserveWindowsMigrationPermissions } const rename = fs.rename let release: (() => Promise) | undefined const failures: NodeJS.ErrnoException[] = [] let attempts = 0 vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 if (attempts === 1) release = await lockWindowsEntry(path.join(String(source), 'tasks', 'pending.json')) try { return await rename(source, target) } catch (error) { failures.push(error as NodeJS.ErrnoException) await release?.() release = undefined throw error } }) syncBuiltinESMExports() try { const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(failures).toHaveLength(1) expect(failures[0]).toMatchObject({ code: 'EPERM', syscall: 'rename', path: path.join(f.target, `.cc-haha-migration-${preview.id}`, 'cc-haha'), dest: path.join(f.target, 'cc-haha') }) expect(f.migration.status?.stage, failures[0]?.message).toBe('restarting') expect(attempts).toBe(2) expect(f.hooks.restart).toHaveBeenCalledOnce() expect(f.hooks.resume).not.toHaveBeenCalled() expect(await fs.readFile(path.join(f.target, 'cc-haha/tasks/pending.json'), 'utf8')).toBe('{"task":"preserved"}') expect(await fs.readFile(path.join(f.source, 'cc-haha/tasks/pending.json'), 'utf8')).toBe('{"task":"preserved"}') const relaunched = new DataMigration(f.app, f.hooks, {}) expect(await relaunched.recover()).toBe('validate') await relaunched.completeValidation() expect(relaunched.status?.stage).toBe('completed') } finally { await release?.() } }, 20_000) it.skipIf(process.platform !== 'win32')('bounds retries for a persistent real Windows lock and keeps the original startup pointer', async () => { const f = await fixture() await f.put('cc-haha/tasks/pending.json', '{"task":"preserved"}') await fs.mkdir(f.userData) const oldMode = '{"mode":"default","portable_dir":null,"unknown":42}' await fs.writeFile(path.join(f.userData, 'app-mode.json'), oldMode) f.hooks.permissions = { restrictStaging: restrictWindowsMigrationStaging, preserve: preserveWindowsMigrationPermissions } const rename = fs.rename let release: (() => Promise) | undefined let attempts = 0 vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 if (attempts === 1) release = await lockWindowsEntry(path.join(String(source), 'tasks', 'pending.json')) return rename(source, target) }) syncBuiltinESMExports() try { const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(attempts).toBe(6) expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('EPERM') }) expect(f.hooks.restart).not.toHaveBeenCalled() expect(f.hooks.resume).toHaveBeenCalledOnce() expect(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).toBe(oldMode) expect(await fs.readFile(path.join(f.source, 'cc-haha/tasks/pending.json'), 'utf8')).toBe('{"task":"preserved"}') await expect(fs.stat(path.join(f.target, 'cc-haha'))).rejects.toMatchObject({ code: 'ENOENT' }) } finally { await release?.() } }, 20_000) it.each(['EPERM', 'EACCES', 'EBUSY'])('retries transient Windows %s publication errors before switching directories', async code => { const f = await fixture() const rename = fs.rename let attempts = 0 vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 expect(f.hooks.restart).not.toHaveBeenCalled() await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) if (attempts < 3) throw Object.assign(new Error('Temporary publication lock'), { code }) return rename(source, target) }) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(attempts).toBe(3) expect(f.migration.status?.stage).toBe('restarting') expect(f.hooks.restart).toHaveBeenCalledOnce() expect(f.hooks.resume).not.toHaveBeenCalled() }) it('does not retry publication errors on other platforms', async () => { const f = await fixture() f.hooks.platform = 'linux' const rename = fs.rename let attempts = 0 vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 throw Object.assign(new Error('Permanent publication permission error'), { code: 'EPERM' }) }) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(attempts).toBe(1) expect(f.migration.status?.stage).toBe('failed') expect(f.hooks.restart).not.toHaveBeenCalled() expect(f.hooks.resume).toHaveBeenCalledOnce() }) it('refuses an independently created destination before retrying publication', async () => { const f = await fixture() const rename = fs.rename let attempts = 0 vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 await fs.mkdir(target) await fs.writeFile(path.join(String(target), 'keep'), 'independent data') throw Object.assign(new Error('Temporary publication lock'), { code: 'EPERM' }) }) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(attempts).toBe(1) expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Target directory changed during migration' }) expect(await fs.readFile(path.join(f.target, 'cc-haha/keep'), 'utf8')).toBe('independent data') expect(f.hooks.restart).not.toHaveBeenCalled() expect(f.hooks.resume).toHaveBeenCalledOnce() }) it.each(['source', 'target', 'staging'])('refuses a replaced %s directory before retrying publication', async directory => { const f = await fixture() const rename = fs.rename let attempts = 0 let replacement = '' vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (!String(source).includes('.cc-haha-migration-') || path.basename(String(source)) !== 'cc-haha') return rename(source, target) attempts += 1 replacement = directory === 'source' ? f.source : directory === 'target' ? f.target : path.dirname(String(source)) await rename(replacement, `${replacement}-original`) await fs.mkdir(replacement) await fs.writeFile(path.join(replacement, 'keep'), 'replacement data') throw Object.assign(new Error('Temporary publication lock'), { code: 'EBUSY' }) }) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(attempts).toBe(1) expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) expect(await fs.readFile(path.join(replacement, 'keep'), 'utf8')).toBe('replacement data') expect(await fs.readFile(path.join(directory === 'source' ? `${f.source}-original` : f.source, 'settings.json'), 'utf8')).toContain('preserve') expect(f.hooks.restart).not.toHaveBeenCalled() expect(f.hooks.resume).toHaveBeenCalledOnce() }) it('previews without stopping work and switches only after a verified complete copy', async () => { const f = await fixture() await f.put('cc-haha/db/index-v1.sqlite', 'regenerable') await f.put('cc-haha/db/unknown.sqlite', 'protected unknown database') await f.put('.runtime/venv/Scripts/pip.exe', 'old absolute-path executable') await f.put('.runtime/requirements.sha256', 'old dependency stamp') const original = await fs.readFile(path.join(f.source, 'settings.json')) const preview = await f.migration.prepare(f.target) expect(preview).toMatchObject({ sourceDir: f.source, targetDir: f.target, activeTasks: 2 }) expect(f.hooks.quiesce).not.toHaveBeenCalled() expect(f.migration.status).toBeNull() await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status?.stage).toBe('restarting') expect(f.hooks.restart).toHaveBeenCalledOnce() expect(await fs.readFile(path.join(f.source, 'settings.json'))).toEqual(original) expect(await fs.readFile(path.join(f.target, 'settings.json'))).toEqual(original) expect(await fs.readFile(path.join(f.target, '.claude.json'), 'utf8')).toContain('"unknown":7') expect(await fs.readFile(path.join(f.target, 'projects/fixture/session.jsonl'), 'utf8')).toContain('hello') expect(await fs.readFile(path.join(f.target, 'cc-haha/db/unknown.sqlite'), 'utf8')).toBe('protected unknown database') await expect(fs.stat(path.join(f.target, 'cc-haha/db/index-v1.sqlite'))).rejects.toMatchObject({ code: 'ENOENT' }) await expect(fs.stat(path.join(f.target, '.runtime/venv'))).rejects.toMatchObject({ code: 'ENOENT' }) await expect(fs.stat(path.join(f.target, '.runtime/requirements.sha256'))).rejects.toMatchObject({ code: 'ENOENT' }) expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8'))).toMatchObject({ mode: 'portable', portable_dir: f.target }) await fs.rename(f.source, `${f.source}-retained`) expect(await fs.readFile(resolveRelocatedAttachmentPath(path.join(f.source, 'uploads/session/file.txt'), f.target), 'utf8')).toBe('attachment bytes') }) it('rejects nonempty, overlapping, install-contained and externally controlled targets', async () => { const f = await fixture() await fs.mkdir(f.target) await fs.writeFile(path.join(f.target, 'keep'), 'existing data') await expect(f.migration.prepare(f.target)).rejects.toThrow('empty') await expect(f.migration.prepare(f.source)).rejects.toThrow('separate') await expect(f.migration.prepare(path.join(f.source, 'nested'))).rejects.toThrow('separate') await expect(f.migration.prepare(path.dirname(f.source))).rejects.toThrow('separate') await expect(f.migration.prepare(path.join(f.root, 'install', 'data'))).rejects.toThrow('install') const external = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: f.source }) await expect(external.prepare(path.join(f.root, 'empty'))).rejects.toThrow('launch environment') expect(await fs.readFile(path.join(f.target, 'keep'), 'utf8')).toBe('existing data') expect(f.hooks.quiesce).not.toHaveBeenCalled() }) it('blocks outside writers before stopping any session', async () => { const f = await fixture() f.hooks.preview = async () => ({ activeTasks: 0, externalProcesses: 1 }) await expect(f.migration.prepare(f.target)).rejects.toThrow('Another Claude process') expect(f.hooks.quiesce).not.toHaveBeenCalled() }) it('checks free space before stopping work', async () => { const f = await fixture() vi.spyOn(nativeFs, 'statfs').mockImplementation(async () => ({ bavail: 0, bsize: 4096 } as never)) syncBuiltinESMExports() await expect(f.migration.prepare(f.target)).rejects.toThrow('Not enough free space') expect(f.hooks.quiesce).not.toHaveBeenCalled() expect(await fs.readdir(f.target)).toEqual([]) }) it('relocates internal directory links without copying or changing external link data', async () => { const f = await fixture() await f.put('linked-data/file.txt', 'inside fixture') const external = path.join(f.root, 'external') await fs.mkdir(external) await fs.writeFile(path.join(external, 'keep'), 'external fixture') await fs.symlink(path.join(f.source, 'linked-data'), path.join(f.source, 'internal-link'), 'junction') await fs.symlink(external, path.join(f.source, 'external-link'), 'junction') const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status?.stage).toBe('restarting') expect((await fs.lstat(path.join(f.target, 'internal-link'))).isSymbolicLink()).toBe(true) expect(await fs.realpath(path.join(f.target, 'internal-link'))).toBe(await fs.realpath(path.join(f.target, 'linked-data'))) expect(await fs.realpath(path.join(f.target, 'external-link'))).toBe(await fs.realpath(external)) await fs.rename(f.source, `${f.source}-retained`) expect(await fs.readFile(path.join(f.target, 'internal-link/file.txt'), 'utf8')).toBe('inside fixture') expect(await fs.readFile(path.join(external, 'keep'), 'utf8')).toBe('external fixture') }) it('preserves canonical-path attachments and internal links when the runtime root is an alias', async () => { const f = await fixture() const alias = path.join(f.root, 'active-data-alias') await fs.symlink(f.source, alias, 'junction') await fs.symlink(path.join(f.source, 'uploads'), path.join(f.source, 'internal-link'), 'junction') const migration = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: alias, CC_HAHA_APP_PORTABLE_DIR: '1' }) const preview = await migration.prepare(f.target) expect(preview.sourceDir).toBe(alias) await migration.start(preview.id) await migration.wait() expect(migration.status?.stage).toBe('restarting') await fs.rename(f.source, `${f.source}-retained`) expect(await fs.readFile(path.join(f.target, 'internal-link/session/file.txt'), 'utf8')).toBe('attachment bytes') for (const previous of [alias, f.source]) { expect(await fs.readFile(resolveRelocatedAttachmentPath(path.join(previous, 'uploads/session/file.txt'), f.target), 'utf8')).toBe('attachment bytes') } }) it('fails safely when a target runs out of space during publication', async () => { const f = await fixture() const rename = fs.rename vi.spyOn(nativeFs, 'rename').mockImplementation(async (source, target) => { if (String(source).includes('.cc-haha-migration-')) throw Object.assign(new Error('Target disk is full'), { code: 'ENOSPC' }) return rename(source, target) }) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Target disk is full' }) expect(f.hooks.resume).toHaveBeenCalledOnce() expect(f.hooks.restart).not.toHaveBeenCalled() expect(await fs.readdir(f.target)).toEqual([]) expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) }) it('restores file and directory permissions after copying children and rewriting managed metadata', async () => { const f = await fixture() await f.put('protected/unknown.txt', 'read-only data') const directory = path.join(f.source, 'protected') await fs.chmod(directory, 0o500) await fs.chmod(path.join(directory, 'unknown.txt'), 0o400) const originalMode = (await fs.stat(directory)).mode try { const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status?.stage).toBe('restarting') expect(await fs.readFile(path.join(f.target, 'protected/unknown.txt'), 'utf8')).toBe('read-only data') expect((await fs.stat(path.join(f.target, 'protected'))).mode).toBe(originalMode) if (process.platform !== 'win32') expect((await fs.stat(path.join(f.target, 'protected'))).mode & 0o777).toBe(0o500) } finally { // Leave disposable fixtures writable for cleanup on Unix too. await fs.chmod(directory, 0o700) await fs.chmod(path.join(f.target, 'protected'), 0o700).catch(() => {}) } }) it('fails safely on copy permission errors and never switches the original pointer', async () => { const f = await fixture() const mkdir = fs.mkdir vi.spyOn(nativeFs, 'mkdir').mockImplementation((async (directory: string, options: unknown) => { if (String(directory).includes('.cc-haha-migration-')) return Promise.reject(Object.assign(new Error('Copy permission denied'), { code: 'EACCES' })) return mkdir(directory, options as never) }) as typeof fs.mkdir) syncBuiltinESMExports() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Copy permission denied' }) expect(await fs.readdir(f.target)).toEqual([]) expect(f.hooks.restart).not.toHaveBeenCalled() expect(f.hooks.resume).toHaveBeenCalledOnce() }) it('does not copy before Windows staging is protected or commit after an ACL failure', async () => { const f = await fixture() f.hooks.permissions!.restrictStaging = async directory => { expect(await fs.readdir(directory)).toEqual([]) expect(f.hooks.quiesce).toHaveBeenCalledOnce() } f.hooks.permissions!.preserve = async () => { throw new Error('Windows permission verification failed') } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'Windows permission verification failed' }) expect(f.hooks.resume).toHaveBeenCalledOnce() expect(f.hooks.restart).not.toHaveBeenCalled() expect(await fs.readdir(f.target)).toEqual([]) expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') }) it('cancels before commit, retains the source and resumes services without replaying work', async () => { const f = await fixture() f.hooks.progress = status => { if (status.stage === 'copying') f.migration.cancel(status.id) } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status?.stage).toBe('cancelled') expect(f.hooks.resume).toHaveBeenCalledOnce() expect(f.hooks.restart).not.toHaveBeenCalled() expect(await fs.readdir(f.target)).toEqual([]) await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) expect(await fs.readFile(path.join(f.source, 'uploads/session/file.txt'), 'utf8')).toBe('attachment bytes') }) it('cancels a quit request even before the first journal write finishes', async () => { const f = await fixture() const preview = await f.migration.prepare(f.target) const started = f.migration.start(preview.id) f.migration.cancelActive() await started await f.migration.wait() expect(f.migration.status?.stage).toBe('cancelled') expect(f.hooks.quiesce).not.toHaveBeenCalled() expect(f.hooks.restart).not.toHaveBeenCalled() expect(await fs.readdir(f.target)).toEqual([]) }) it('never copies when the runtime cannot confirm quiescence', async () => { const f = await fixture() f.hooks.quiesce = async () => { throw new Error('process did not exit') } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: 'process did not exit' }) expect(await fs.readdir(f.target)).toEqual([]) expect(f.hooks.resume).toHaveBeenCalledOnce() }) it('reports an unavailable original runtime when recovery after cancellation fails', async () => { const f = await fixture() f.hooks.progress = status => { if (status.stage === 'copying') f.migration.cancel(status.id) } f.hooks.resume = async () => { throw new Error('process is still draining') } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('Original services could not restart: process is still draining') }) expect(await fs.readdir(f.target)).toEqual([]) expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') }) it('detects late source writes and preserves the original startup pointer', async () => { const f = await fixture() await fs.mkdir(f.userData) const old = '{"mode":"default","portable_dir":null,"unknown":{"keep":1}}' await fs.writeFile(path.join(f.userData, 'app-mode.json'), old) f.hooks.progress = status => { if (status.stage === 'verifying') require('node:fs').writeFileSync(path.join(f.source, 'settings.json'), '{"late":true}') } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status?.stage).toBe('failed') expect(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).toBe(old) expect(f.hooks.restart).not.toHaveBeenCalled() }) it('recovers a verified pending restart and retains its success receipt', async () => { const f = await fixture() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() const relaunched = new DataMigration(f.app, f.hooks, {}) expect(await relaunched.recover()).toBe('validate') await relaunched.completeValidation() const later = new DataMigration(f.app, f.hooks, {}) expect(await later.recover()).toBe('normal') expect(later.status).toMatchObject({ stage: 'completed', sourceDir: f.source, targetDir: f.target }) }) it('rolls back a corrupted target before any new tasks are allowed', async () => { const f = await fixture() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() await fs.writeFile(path.join(f.target, 'settings.json'), '{"tampered":true}') const relaunched = new DataMigration(f.app, f.hooks, {}) expect(await relaunched.recover()).toBe('normal') expect(relaunched.status?.stage).toBe('failed') await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') }) it.each(['directory', 'pointer'] as const)('fails closed when a completed migration loses its %s', async unavailable => { const f = await fixture() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() await f.migration.completeValidation() if (unavailable === 'directory') await fs.rename(f.target, `${f.target}-disconnected`) else await fs.rm(path.join(f.userData, 'app-mode.json')) const later = new DataMigration(f.app, f.hooks, {}) await expect(later.recover()).rejects.toThrow(unavailable === 'directory' ? 'Migrated data directory is unavailable' : 'startup configuration is unavailable') expect(f.hooks.resume).not.toHaveBeenCalled() if (unavailable === 'directory') await expect(fs.stat(f.target)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('keeps deliberate default and external launch selections after a completed migration', async () => { const f = await fixture() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() await f.migration.completeValidation() await fs.rename(f.target, `${f.target}-disconnected`) const override = new DataMigration(f.app, f.hooks, { CLAUDE_CONFIG_DIR: path.join(f.root, 'explicit-data') }) expect(await override.recover()).toBe('normal') await fs.writeFile(path.join(f.userData, 'app-mode.json'), '{"mode":"default","portable_dir":null,"unknown":7}') expect(await new DataMigration(f.app, f.hooks, {}).recover()).toBe('normal') expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).unknown).toBe(7) }) it('upgrades a v1 interrupted journal and removes only its private staging directory', async () => { const f = await fixture() await fs.mkdir(f.target) const id = 'fixture-id' const stagingDir = path.join(f.target, `.cc-haha-migration-${id}`) await fs.mkdir(stagingDir) await fs.writeFile(path.join(stagingDir, 'partial'), 'partial') await fs.writeFile(path.join(f.target, 'unrelated'), 'must survive') await fs.mkdir(f.userData) await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify({ version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying', cancellable: true }, stagingDir, oldMode: '{"mode":"default","unknown":42}', manifest: [], createdCredentials: [], credentials: [] })) expect(await f.migration.recover()).toBe('normal') expect(f.migration.status?.stage).toBe('failed') expect(await fs.readFile(path.join(f.target, 'unrelated'), 'utf8')).toBe('must survive') expect(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).toContain('"unknown":42') await expect(fs.stat(stagingDir)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('fails closed when startup rollback would recreate a missing original directory', async () => { const f = await fixture() const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() await fs.rename(f.source, `${f.source}-retained`) await fs.writeFile(path.join(f.target, 'settings.json'), 'corrupted target') const recovered = new DataMigration(f.app, f.hooks, {}) await expect(recovered.recover()).rejects.toThrow('Original data directory is unavailable') expect(JSON.parse(await fs.readFile(path.join(f.userData, 'app-mode.json'), 'utf8')).portable_dir).toBe(f.target) await expect(fs.stat(f.source)).rejects.toMatchObject({ code: 'ENOENT' }) }) it.each(['source', 'target'] as const)('rejects a replaced %s directory after preview even at the same canonical path', async directory => { const f = await fixture() const preview = await f.migration.prepare(f.target) const selected = f[directory] await fs.rename(selected, `${selected}-original`) await fs.mkdir(selected) await fs.writeFile(path.join(selected, 'keep'), 'replacement must survive') await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) expect(f.hooks.quiesce).not.toHaveBeenCalled() expect(f.hooks.restart).not.toHaveBeenCalled() expect(await fs.readFile(path.join(selected, 'keep'), 'utf8')).toBe('replacement must survive') expect(await fs.readFile(path.join(directory === 'source' ? `${f.source}-original` : f.source, 'settings.json'), 'utf8')).toContain('preserve') }) it('refuses a target junction swap before copying and never cleans through its replacement', async () => { const f = await fixture() const outside = path.join(f.root, 'outside') await fs.mkdir(outside) let replacementStage = '' f.hooks.progress = status => { if (status.stage !== 'copying') return const sync = require('node:fs') as typeof import('node:fs') sync.renameSync(f.target, `${f.target}-original`) replacementStage = path.join(outside, `.cc-haha-migration-${status.id}`) sync.mkdirSync(replacementStage) sync.writeFileSync(path.join(replacementStage, 'keep'), 'unrelated data') sync.symlinkSync(outside, f.target, 'junction') } const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) expect(await fs.readFile(path.join(replacementStage, 'keep'), 'utf8')).toBe('unrelated data') expect(await fs.readdir(path.join(`${f.target}-original`, `.cc-haha-migration-${preview.id}`))).toEqual([]) expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') expect(f.hooks.resume).toHaveBeenCalledOnce() expect(f.hooks.restart).not.toHaveBeenCalled() }) it('refuses a replaced target ancestor before verification and preserves external staging data', async () => { const f = await fixture() const parent = path.join(f.root, 'selected-parent') const target = path.join(parent, 'data') const outside = path.join(f.root, 'outside-parent') await fs.mkdir(path.join(outside, 'data'), { recursive: true }) let replacementStage = '' f.hooks.progress = status => { if (status.stage !== 'verifying') return const sync = require('node:fs') as typeof import('node:fs') sync.renameSync(parent, `${parent}-original`) replacementStage = path.join(outside, 'data', `.cc-haha-migration-${status.id}`) sync.mkdirSync(replacementStage) sync.writeFileSync(path.join(replacementStage, 'keep'), 'external transaction') sync.symlinkSync(outside, parent, 'junction') } const preview = await f.migration.prepare(target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('replaced') }) expect(await fs.readFile(path.join(replacementStage, 'keep'), 'utf8')).toBe('external transaction') expect(await fs.readFile(path.join(f.source, 'uploads/session/file.txt'), 'utf8')).toBe('attachment bytes') expect(f.hooks.restart).not.toHaveBeenCalled() }) it('rechecks external writers at the commit boundary', async () => { const f = await fixture() let previews = 0 f.hooks.preview = async () => ({ activeTasks: 0, externalProcesses: ++previews >= 3 ? 1 : 0 }) const preview = await f.migration.prepare(f.target) await f.migration.start(preview.id) await f.migration.wait() expect(f.migration.status).toMatchObject({ stage: 'failed', error: expect.stringContaining('Another Claude process') }) expect(await fs.readdir(f.target)).toEqual([]) expect(f.hooks.restart).not.toHaveBeenCalled() }) it('rejects hostile recovery records before restoring settings or deleting any directory', async () => { const f = await fixture() await fs.mkdir(f.target) await fs.mkdir(f.userData) const id = 'safe-id' const stagingDir = path.join(f.target, `.cc-haha-migration-${id}`) await fs.mkdir(stagingDir) await fs.writeFile(path.join(stagingDir, 'keep'), 'protected transaction') const journal = { version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying', cancellable: true }, stagingDir, oldMode: '{"mode":"default"}', manifest: [], createdCredentials: [], credentials: [] } const digest = 'a'.repeat(64) const hostile = [ { ...journal, stagingDir: f.target }, { ...journal, stagingDir: f.source }, { ...journal, status: { ...journal.status, id: '../escape' } }, { ...journal, status: { ...journal.status, stage: 'unknown' } }, { ...journal, status: { ...journal.status, sourceDir: path.dirname(f.target) } }, { ...journal, manifest: [{ relative: '../settings.json', kind: 'file', digest }] }, { ...journal, manifest: [{ relative: '..\\settings.json', kind: 'file', digest }] }, { ...journal, manifest: [{ relative: f.source, kind: 'directory' }] }, { ...journal, manifest: [{ relative: 'settings.json', kind: 'unexpected' }] }, { ...journal, createdCredentials: [{ service: 'Claude Code', digest }] }, { ...journal, oldMode: '[]' }, ] for (const entry of hostile) { await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify(entry)) const recovery = new DataMigration(f.app, f.hooks, {}) await expect(recovery.recover()).rejects.toThrow() expect(await fs.readFile(path.join(stagingDir, 'keep'), 'utf8')).toBe('protected transaction') await expect(fs.stat(path.join(f.userData, 'app-mode.json'))).rejects.toMatchObject({ code: 'ENOENT' }) } expect(await fs.readFile(path.join(f.source, 'settings.json'), 'utf8')).toContain('preserve') }) it('rejects a legacy recovery target whose canonical path overlaps the source', async () => { const f = await fixture() await fs.symlink(f.source, f.target, 'junction') await fs.mkdir(f.userData) const id = 'safe-id' await fs.writeFile(path.join(f.source, 'keep'), 'source must survive') await fs.writeFile(path.join(f.userData, MIGRATION_JOURNAL_FILE), JSON.stringify({ version: 1, status: { id, sourceDir: f.source, targetDir: f.target, stage: 'copying' }, stagingDir: path.join(f.target, `.cc-haha-migration-${id}`), oldMode: null, manifest: [], credentials: [], createdCredentials: [] })) await expect(f.migration.recover()).rejects.toThrow('overlap') expect(await fs.readFile(path.join(f.source, 'keep'), 'utf8')).toBe('source must survive') }) it('rejects a parent junction replacement even when manifest file bytes match', async () => { const f = await fixture() const manifest = await migrationManifest(f.source) const original = path.join(f.source, 'uploads') const outside = path.join(f.root, 'same-bytes') await fs.rename(original, outside) await fs.symlink(outside, original, 'junction') await expect(validateMigrationManifest(f.source, manifest)).rejects.toThrow() await expect(validateMigrationManifest(f.source, [{ relative: 'uploads/session/file.txt', kind: 'file', digest: manifest.find(file => file.relative === path.join('uploads', 'session', 'file.txt'))!.digest }])).rejects.toThrow('parent') expect(await fs.readFile(path.join(outside, 'session/file.txt'), 'utf8')).toBe('attachment bytes') expect(() => assertMigrationManifest([{ relative: 'a', kind: 'file' }])).toThrow() }) })