From 01335551a3ebd16af6f8d69aacc26fb122faedea Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:02:06 -0400 Subject: [PATCH] fix(uninstall): preserve path identity and user drift --- scripts/lib/install-lifecycle.js | 87 ++++++++++--- scripts/lib/install/apply.js | 25 ++-- .../lib/antigravity-legacy-migration.test.js | 39 ++++++ tests/lib/install-lifecycle.test.js | 117 +++++++++++++++++- 4 files changed, 235 insertions(+), 33 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 7607f17bf..bf5dd8ef6 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -529,13 +529,46 @@ function removeContainedPath(destinationPath, trustedRoot, action, options = {}) return null; } - const finalDestination = getManagedDestination( + const managedDestination = getManagedDestination( existingDestination, trustedRoot, action, { allowFinalSymlink: true } - ).managedPath; - fs.rmSync(finalDestination, options); + ); + const finalDestination = managedDestination.managedPath; + const expectedStat = fs.lstatSync(finalDestination, { bigint: true }); + const quarantineDir = fs.mkdtempSync(path.join( + path.dirname(managedDestination.canonicalRoot), + '.ecc-remove-' + )); + const quarantinePath = path.join(quarantineDir, path.basename(finalDestination)); + + try { + fs.renameSync(finalDestination, quarantinePath); + } catch (error) { + fs.rmdirSync(quarantineDir); + throw error; + } + + const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true }); + if (!hasSameFileIdentity(expectedStat, quarantinedStat)) { + try { + fs.renameSync(quarantinePath, finalDestination); + fs.rmdirSync(quarantineDir); + } catch (_restoreError) { + throw new Error( + `Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.` + ); + } + throw createChangedDestinationError(action); + } + + if (quarantinedStat.isDirectory() && !options.recursive) { + fs.rmdirSync(quarantinePath); + } else { + fs.rmSync(quarantinePath, options); + } + fs.rmdirSync(quarantineDir); return finalDestination; } @@ -724,7 +757,8 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) { const existingDestination = getContainedExistingPath( operation.destinationPath, trustedRoot, - 'uninstall' + 'uninstall', + { allowFinalSymlink: true } ); if (!existingDestination) { return { @@ -732,6 +766,13 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) { cleanupTargets: [] }; } + if (fs.lstatSync(existingDestination).isSymbolicLink()) { + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: [operation.destinationPath] + }; + } const recordedDigest = operation.contentSha256; const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '') ? crypto.createHash('sha256') @@ -741,7 +782,8 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) { if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) { return { removedPaths: [], - cleanupTargets: [] + cleanupTargets: [], + retainedPaths: [operation.destinationPath] }; } } @@ -1869,8 +1911,9 @@ function cleanupEmptyParentDirs(filePath, stopAt) { } const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up'); - fs.rmdirSync(finalPath); - currentPath = path.dirname(finalPath); + const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up'); + if (!removedPath) break; + currentPath = path.dirname(removedPath); } } @@ -1926,25 +1969,29 @@ function uninstallInstalledStates(options = {}) { try { const removedPaths = []; const cleanupTargets = []; + const retainedPaths = []; const operations = getManagedOperations(state); for (const operation of operations) { const outcome = executeUninstallOperation(operation, record.targetRoot, { - preserveDriftedCopies: record.legacy, + preserveDriftedCopies: true, }); removedPaths.push(...outcome.removedPaths); cleanupTargets.push(...outcome.cleanupTargets); + retainedPaths.push(...(outcome.retainedPaths || [])); } - const removedStatePath = removeContainedPath( - record.installStatePath, - record.targetRoot, - 'uninstall', - { force: true } - ); - if (removedStatePath) { - removedPaths.push(record.installStatePath); - cleanupTargets.push(removedStatePath); + if (retainedPaths.length === 0) { + const removedStatePath = removeContainedPath( + record.installStatePath, + record.targetRoot, + 'uninstall', + { force: true } + ); + if (removedStatePath) { + removedPaths.push(record.installStatePath); + cleanupTargets.push(removedStatePath); + } } for (const cleanupTarget of cleanupTargets) { @@ -1953,10 +2000,14 @@ function uninstallInstalledStates(options = {}) { return { adapter: record.adapter, - status: 'uninstalled', + status: retainedPaths.length > 0 ? 'partial' : 'uninstalled', installStatePath: record.installStatePath, removedPaths, + retainedPaths: [...new Set(retainedPaths)].sort(), plannedRemovals: [], + warning: retainedPaths.length > 0 + ? 'Modified or unverifiable managed files were preserved together with install-state for review.' + : null, error: null }; } catch (error) { diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 8f4e660e0..d1180b436 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -350,20 +350,25 @@ function applyInstallPlan(plan, dependencies = {}) { continue; } - // Markdown may reference files whose installed paths move, such as rules - // copied under rules/ecc. Rewrite only links that point at installed targets; - // untouched links and non-markdown files stay on the byte-for-byte path. - if ( + // Declared transforms are part of the install contract and always apply. + // Markdown link rewriting is additive when the plan has a usable index. + const needsLinkRewrite = Boolean( linkIndex - && operation.kind === 'copy-file' && operation.sourceRelativePath && isMarkdownPath(operation.destinationPath) - ) { - const rewritten = rewriteRelativeLinks( - transformInstallContent(operation, fs.readFileSync(operation.sourcePath, 'utf8')), - { sourceRel: operation.sourceRelativePath, index: linkIndex } + ); + if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) { + const transformed = transformInstallContent( + operation, + fs.readFileSync(operation.sourcePath, 'utf8') ); - fs.writeFileSync(operation.destinationPath, rewritten, 'utf8'); + const installedContent = needsLinkRewrite + ? rewriteRelativeLinks(transformed, { + sourceRel: operation.sourceRelativePath, + index: linkIndex, + }) + : transformed; + fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); continue; } diff --git a/tests/lib/antigravity-legacy-migration.test.js b/tests/lib/antigravity-legacy-migration.test.js index 64b44e260..2cbce8ad8 100644 --- a/tests/lib/antigravity-legacy-migration.test.js +++ b/tests/lib/antigravity-legacy-migration.test.js @@ -652,6 +652,45 @@ function runTests() { } })) passed++; else failed++; + if (test('applies a declared Antigravity transform without link-index metadata', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-transform-only-')); + try { + const sourcePath = path.join(REPO_ROOT, 'agents', 'architect.md'); + const targetRoot = path.join(projectRoot, '.agents'); + const installStatePath = path.join(targetRoot, 'ecc-install-state.json'); + const operation = { + kind: 'copy-file', + moduleId: 'agents-core', + sourcePath, + sourceRelativePath: null, + destinationPath: path.join(targetRoot, 'agents', 'architect.md'), + strategy: 'copy-file', + ownership: 'managed', + scaffoldOnly: false, + contentTransform: 'antigravity-agent-frontmatter', + }; + const plan = { + mode: 'legacy', + target: 'antigravity', + adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' }, + targetRoot, + installRoot: targetRoot, + installStatePath, + operations: [operation], + warnings: [], + statePreview: createAntigravityState(targetRoot, installStatePath, []), + }; + + applyInstallPlan(plan, { writeInstallState() {} }); + + const installedContent = fs.readFileSync(operation.destinationPath, 'utf8'); + assert.notStrictEqual(installedContent, fs.readFileSync(sourcePath, 'utf8')); + assert.ok(!installedContent.includes('color:')); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 80423c155..04ad9a451 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -168,7 +168,7 @@ function withTemporarilyMovedPath(filePath, callback) { } function managedOperation(kind, destinationPath, overrides = {}) { - return { + const operation = { kind, moduleId: 'test-module', sourceRelativePath: 'rules/common/coding-style.md', @@ -178,6 +178,18 @@ function managedOperation(kind, destinationPath, overrides = {}) { scaffoldOnly: false, ...overrides, }; + if ( + kind === 'copy-file' + && !Object.prototype.hasOwnProperty.call(overrides, 'contentSha256') + && fs.existsSync(destinationPath) + && fs.lstatSync(destinationPath).isFile() + && !fs.lstatSync(destinationPath).isSymbolicLink() + ) { + operation.contentSha256 = crypto.createHash('sha256') + .update(fs.readFileSync(destinationPath)) + .digest('hex'); + } + return operation; } function runTests() { @@ -2478,7 +2490,7 @@ function runTests() { targets: ['cursor'], }); - assert.strictEqual(result.results[0].status, 'uninstalled'); + assert.strictEqual(result.results[0].status, 'uninstalled', result.results[0].error); assert.ok(result.results[0].removedPaths.includes(destinationPath)); assert.ok(!fs.existsSync(destinationPath)); assert.ok(!fs.existsSync(path.dirname(destinationPath))); @@ -2489,6 +2501,40 @@ function runTests() { } })) passed++; else failed++; + if (test('uninstall preserves drifted canonical copied files and install-state', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + const destinationPath = path.join(targetRoot, 'rules', 'managed.md'); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, 'managed\n'); + const operation = managedOperation('copy-file', destinationPath, { + strategy: 'copy-file', + }); + const { installStatePath } = writeCursorState(projectRoot, { + request: { legacyMode: false, legacyLanguages: [] }, + operations: [operation], + }); + fs.appendFileSync(destinationPath, 'user edit\n'); + + const result = uninstallInstalledStates({ + homeDir, + projectRoot, + targets: ['cursor'], + }); + + assert.strictEqual(result.results[0].status, 'partial'); + assert.ok(result.results[0].retainedPaths.includes(destinationPath)); + assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'managed\nuser edit\n'); + assert.ok(fs.existsSync(installStatePath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('uninstall cleanup stops at the adapter-derived target root', () => { const homeDir = createTempDir('install-lifecycle-home-'); const cleanupBoundaryRoot = createTempDir('install-lifecycle-boundary-'); @@ -2730,7 +2776,7 @@ function runTests() { } })) passed++; else failed++; - if (test('uninstall removes an in-root final symlink without deleting its victim', () => { + if (test('uninstall preserves a managed path replaced by a symlink and its victim', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); @@ -2758,8 +2804,9 @@ function runTests() { targets: ['cursor'], }); - assert.strictEqual(result.results[0].status, 'uninstalled'); - assert.ok(!fs.existsSync(destinationPath)); + assert.strictEqual(result.results[0].status, 'partial'); + assert.ok(fs.lstatSync(destinationPath).isSymbolicLink()); + assert.ok(result.results[0].retainedPaths.includes(destinationPath)); assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n'); } finally { cleanup(homeDir); @@ -2831,6 +2878,66 @@ function runTests() { } })) passed++; else failed++; + if (test('uninstall quarantine prevents an ancestor swap from deleting outside-root content', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + const outsideRoot = createTempDir('install-lifecycle-outside-'); + const targetRoot = path.join(projectRoot, '.cursor'); + const destinationParent = path.join(targetRoot, 'swap-parent'); + const backupParent = path.join(targetRoot, 'swap-parent-backup'); + const destinationPath = path.join(destinationParent, 'managed.md'); + const outsideDestinationPath = path.join(outsideRoot, 'managed.md'); + const originalRenameSync = fs.renameSync; + let swapped = false; + let result; + + try { + fs.mkdirSync(destinationParent, { recursive: true }); + fs.writeFileSync(destinationPath, 'managed\n'); + fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n'); + writeCursorState(projectRoot, { + operations: [managedOperation('copy-file', destinationPath)], + }); + + fs.renameSync = function renameSyncWithAncestorSwap(sourcePath, targetPath) { + if ( + !swapped + && path.basename(sourcePath) === path.basename(destinationPath) + && path.basename(path.dirname(targetPath)).startsWith('.ecc-remove-') + ) { + originalRenameSync.call(fs, destinationParent, backupParent); + fs.symlinkSync( + outsideRoot, + destinationParent, + process.platform === 'win32' ? 'junction' : 'dir' + ); + swapped = true; + } + return originalRenameSync.call(fs, sourcePath, targetPath); + }; + + result = uninstallInstalledStates({ + homeDir, + projectRoot, + targets: ['cursor'], + }); + } finally { + fs.renameSync = originalRenameSync; + } + + try { + assert.strictEqual(swapped, true); + assert.strictEqual(result.results[0].status, 'error'); + assert.match(result.results[0].error, /changed during|changed before removal/); + assert.strictEqual(fs.readFileSync(outsideDestinationPath, 'utf8'), 'outside sentinel\n'); + assert.strictEqual(fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'), 'managed\n'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + cleanup(outsideRoot); + } + })) passed++; else failed++; + if (test('uninstall restores previous JSON snapshots for template and remove operations', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-');