mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
231 lines
7.2 KiB
JavaScript
231 lines
7.2 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { readInstallState } = require('../install-state');
|
|
const { assertWithinTrustedRoot } = require('../path-safety');
|
|
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
|
|
|
/**
|
|
* Upgrade reconciliation for excluded source paths (issue #3116).
|
|
*
|
|
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
|
|
* and Codex home targets). The exclusion stops new copy operations from being
|
|
* planned, but a home install created before the exclusion still has the
|
|
* copied files on disk and the copy operations recorded in install-state, so
|
|
* doctor keeps reporting drift and repair keeps restoring files the target
|
|
* never reads.
|
|
*
|
|
* prepareExcludedPathsReconciliation runs before the new state is written: it
|
|
* reads the previous install-state and drops the recorded managed operations
|
|
* whose source path is now excluded. completeExcludedPathsReconciliation runs
|
|
* after a successful apply: it removes the files those operations recorded,
|
|
* but only when the recorded content digest still matches, and prunes the
|
|
* emptied directories. Files the state does not own, modified files,
|
|
* symlinks, and anything outside the target root are preserved with a
|
|
* warning.
|
|
*/
|
|
|
|
function comparablePath(filePath) {
|
|
const resolvedPath = path.resolve(filePath);
|
|
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
|
}
|
|
|
|
function getReconcilingAdapter(plan) {
|
|
if (!plan || typeof plan.target !== 'string') {
|
|
return null;
|
|
}
|
|
let adapter;
|
|
try {
|
|
adapter = getInstallTargetAdapter(plan.target);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
|
|
}
|
|
|
|
function isRecordedExcludedManagedOperation(adapter, operation) {
|
|
return Boolean(
|
|
operation
|
|
&& operation.ownership === 'managed'
|
|
&& typeof operation.destinationPath === 'string'
|
|
&& typeof operation.sourceRelativePath === 'string'
|
|
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
|
);
|
|
}
|
|
|
|
function filterStateOperations(state, shouldDrop) {
|
|
if (!state || !Array.isArray(state.operations)) {
|
|
return state;
|
|
}
|
|
return {
|
|
...state,
|
|
operations: state.operations.filter(operation => !shouldDrop(operation)),
|
|
};
|
|
}
|
|
|
|
function prepareExcludedPathsReconciliation(plan, migration) {
|
|
const adapter = getReconcilingAdapter(plan);
|
|
if (!adapter || !fs.existsSync(plan.installStatePath)) {
|
|
return { ...migration, excludedPathCandidates: [] };
|
|
}
|
|
|
|
const previousState = readInstallState(plan.installStatePath);
|
|
const candidates = ((previousState && previousState.operations) || [])
|
|
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
|
|
|
|
if (candidates.length === 0) {
|
|
return { ...migration, excludedPathCandidates: [] };
|
|
}
|
|
|
|
const droppedDestinations = new Set(
|
|
candidates.map(operation => comparablePath(operation.destinationPath))
|
|
);
|
|
const shouldDrop = operation => Boolean(
|
|
operation
|
|
&& typeof operation.destinationPath === 'string'
|
|
&& droppedDestinations.has(comparablePath(operation.destinationPath))
|
|
&& typeof operation.sourceRelativePath === 'string'
|
|
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
|
);
|
|
|
|
return {
|
|
...migration,
|
|
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
|
|
finalState: filterStateOperations(migration.finalState, shouldDrop),
|
|
excludedPathCandidates: candidates,
|
|
};
|
|
}
|
|
|
|
function pathExists(filePath) {
|
|
try {
|
|
fs.lstatSync(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function hashFileNoFollow(filePath) {
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const descriptor = fs.openSync(filePath, flags);
|
|
try {
|
|
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
if (!before.isFile()) {
|
|
throw new Error(`Refusing to read a non-file at ${filePath}`);
|
|
}
|
|
const content = fs.readFileSync(descriptor);
|
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
|
|
const unchanged = before.dev === after.dev
|
|
&& before.ino === after.ino
|
|
&& before.size === after.size
|
|
&& after.dev === finalPathStat.dev
|
|
&& after.ino === finalPathStat.ino
|
|
&& after.size === finalPathStat.size;
|
|
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
|
|
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
|
|
}
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function removeEmptyParents(startPath, targetRoot) {
|
|
let currentPath = path.dirname(startPath);
|
|
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
|
|
const safePath = assertWithinTrustedRoot(
|
|
currentPath,
|
|
targetRoot,
|
|
'reconcile excluded install paths'
|
|
);
|
|
if (!pathExists(safePath)) {
|
|
currentPath = path.dirname(safePath);
|
|
continue;
|
|
}
|
|
const stat = fs.lstatSync(safePath);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
|
|
return;
|
|
}
|
|
fs.rmdirSync(safePath);
|
|
currentPath = path.dirname(safePath);
|
|
}
|
|
}
|
|
|
|
function completeExcludedPathsReconciliation(migration, plan) {
|
|
const candidates = (migration && migration.excludedPathCandidates) || [];
|
|
const removedPaths = [];
|
|
const warnings = [];
|
|
|
|
for (const candidate of candidates) {
|
|
if (candidate.kind !== 'copy-file') {
|
|
continue;
|
|
}
|
|
|
|
let safePath;
|
|
try {
|
|
safePath = assertWithinTrustedRoot(
|
|
candidate.destinationPath,
|
|
plan.targetRoot,
|
|
'reconcile excluded install paths'
|
|
);
|
|
} catch (error) {
|
|
warnings.push(
|
|
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (!pathExists(safePath)) {
|
|
continue;
|
|
}
|
|
|
|
const stat = fs.lstatSync(safePath);
|
|
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
warnings.push(
|
|
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (typeof candidate.contentSha256 !== 'string') {
|
|
warnings.push(
|
|
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let currentDigest;
|
|
try {
|
|
currentDigest = hashFileNoFollow(safePath);
|
|
} catch (error) {
|
|
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
|
|
continue;
|
|
}
|
|
|
|
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
|
|
warnings.push(
|
|
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
fs.unlinkSync(safePath);
|
|
removedPaths.push(safePath);
|
|
removeEmptyParents(safePath, plan.targetRoot);
|
|
}
|
|
|
|
return { removedPaths, warnings };
|
|
}
|
|
|
|
module.exports = {
|
|
completeExcludedPathsReconciliation,
|
|
prepareExcludedPathsReconciliation,
|
|
};
|