fix(uninstall): preserve path identity and user drift

This commit is contained in:
haelyra
2026-08-13 18:02:06 -04:00
parent 8dc6a8e73a
commit 01335551a3
4 changed files with 235 additions and 33 deletions
+69 -18
View File
@@ -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) {
+15 -10
View File
@@ -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;
}