mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
Generalize PR #2981 ownership protection to every managed target. Reject mismatched target state and preserve files that appear during writes or failed-install checkpoints. Keep prior hashes for managed files a failed attempt never writes. Integrate PR #2980 preview wording and global dry-run propagation, with PR #2956 fail-closed environment validation and CLI/legacy regression coverage. Fixes #2964. Fixes #2952. Co-authored-by: ilkmajans-cpu <ilkmajans-cpu@users.noreply.github.com> Co-authored-by: wellkilo <wellkilo@foxmail.com>
160 lines
5.7 KiB
JavaScript
160 lines
5.7 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { readInstallState } = require('../install-state');
|
|
|
|
function pathExists(filePath) {
|
|
try {
|
|
fs.lstatSync(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function comparablePath(filePath) {
|
|
const resolvedPath = path.resolve(filePath);
|
|
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
|
}
|
|
|
|
/**
|
|
* #2964: the shared copy path used to write every copy-file operation
|
|
* unconditionally and record the destination as `ownership: 'managed'` even
|
|
* when the file already existed and was authored by the user. The visible
|
|
* symptom is a lost edit; the dangerous one is the install-state record,
|
|
* which makes a later uninstall delete the user's file.
|
|
*
|
|
* This guard generalises the Claude flat-skill migration conflict pattern to
|
|
* every adapter copy operation: when a destination exists and is NOT recorded
|
|
* as an ECC-managed operation in the previous install-state, the operation is
|
|
* skipped with a warning instead of overwriting and claiming ownership.
|
|
*
|
|
* All managed targets share this ownership boundary (#2964).
|
|
*/
|
|
function prepareUserOwnedFileGuard(plan, migration) {
|
|
const previousState = pathExists(plan.installStatePath)
|
|
? readInstallState(plan.installStatePath)
|
|
: null;
|
|
if (previousState && (
|
|
previousState.target.id !== plan.adapter.id
|
|
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|
|
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
|
|
)) {
|
|
throw new Error(`Refusing install: install-state target does not match the current plan at ${plan.installStatePath}.`);
|
|
}
|
|
// Recorded files remain updateable by reinstall/repair. Preserve their prior
|
|
// digests if an attempt fails before writing them so uninstall detects drift.
|
|
const previousManagedOperations = new Map(
|
|
((previousState && previousState.operations) || [])
|
|
.filter(operation => (
|
|
operation
|
|
&& operation.ownership === 'managed'
|
|
&& operation.destinationPath
|
|
))
|
|
.map(operation => [comparablePath(operation.destinationPath), operation])
|
|
);
|
|
const managedDestinations = new Set(previousManagedOperations.keys());
|
|
|
|
const appliedOperations = [];
|
|
const skippedOperations = [];
|
|
const warnings = [];
|
|
for (const operation of (migration && migration.appliedOperations) || []) {
|
|
if (
|
|
operation
|
|
&& operation.kind === 'copy-file'
|
|
&& operation.destinationPath
|
|
&& pathExists(operation.destinationPath)
|
|
&& !managedDestinations.has(comparablePath(operation.destinationPath))
|
|
) {
|
|
skippedOperations.push(operation);
|
|
warnings.push(
|
|
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
|
|
);
|
|
continue;
|
|
}
|
|
appliedOperations.push(operation);
|
|
}
|
|
|
|
if (skippedOperations.length === 0) {
|
|
return { ...migration, managedDestinations, previousManagedOperations };
|
|
}
|
|
|
|
const skippedDestinations = new Set(
|
|
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
|
);
|
|
const filterStateOperations = operations => (operations || [])
|
|
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
|
|
|
|
// Never leave a skipped destination inside the install-state: recording it
|
|
// would claim ownership of a file ECC did not create and make uninstall
|
|
// delete it (#2964).
|
|
const bridgeState = migration.bridgeState
|
|
? {
|
|
...migration.bridgeState,
|
|
operations: filterStateOperations(migration.bridgeState.operations),
|
|
}
|
|
: migration.bridgeState;
|
|
const finalState = migration.finalState
|
|
? {
|
|
...migration.finalState,
|
|
operations: filterStateOperations(migration.finalState.operations),
|
|
}
|
|
: migration.finalState;
|
|
|
|
return {
|
|
...migration,
|
|
managedDestinations,
|
|
previousManagedOperations,
|
|
appliedOperations,
|
|
skippedOperations: [
|
|
...((migration && migration.skippedOperations) || []),
|
|
...skippedOperations,
|
|
],
|
|
warnings: [...((migration && migration.warnings) || []), ...warnings],
|
|
bridgeState,
|
|
finalState,
|
|
// Only keep bridge persistence when operations actually remain; a fully
|
|
// skipped plan installs nothing and must not claim anything.
|
|
requiresBridgeState: Boolean(migration.requiresBridgeState)
|
|
&& appliedOperations.length > 0,
|
|
};
|
|
}
|
|
|
|
function assertNoNewUserOwnedFile(migration, operation) {
|
|
if (operation.kind !== 'copy-file'
|
|
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|
|
|| !pathExists(operation.destinationPath)) {
|
|
return;
|
|
}
|
|
throw new Error(`Refusing install: a user-owned file appeared at ${operation.destinationPath} after planning. Rerun the installer to preserve it.`);
|
|
}
|
|
|
|
function preserveUnwrittenFiles(state, migration, writtenDestinations) {
|
|
const writtenPaths = new Set([...writtenDestinations].map(comparablePath));
|
|
return {
|
|
...state,
|
|
operations: state.operations.filter(operation => (
|
|
operation.kind !== 'copy-file'
|
|
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|
|
|| writtenPaths.has(comparablePath(operation.destinationPath))
|
|
|| !pathExists(operation.destinationPath)
|
|
)).map(operation => {
|
|
const destination = comparablePath(operation.destinationPath);
|
|
return operation.kind === 'copy-file' && !writtenPaths.has(destination)
|
|
? migration.previousManagedOperations.get(destination) || operation
|
|
: operation;
|
|
}),
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
assertNoNewUserOwnedFile,
|
|
prepareUserOwnedFileGuard,
|
|
preserveUnwrittenFiles,
|
|
};
|