mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
320 lines
9.5 KiB
JavaScript
320 lines
9.5 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { assertWithinTrustedRoot } = require('../path-safety');
|
|
const { mergeHooksMetadata, metadataPathFor } = require('../hooks-config');
|
|
const { writeFileNoFollow: guardedWriteFile } = require('./guarded-write');
|
|
const { assertClaudeSettingsPath } = require('./claude-settings');
|
|
|
|
function getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink = false } = {}
|
|
) {
|
|
if (!destinationPath || typeof destinationPath !== 'string') {
|
|
throw new Error(`Refusing to ${action}: missing destination path.`);
|
|
}
|
|
|
|
const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action);
|
|
const resolvedDestination = path.resolve(destinationPath);
|
|
const canonicalParent = assertWithinTrustedRoot(
|
|
path.dirname(resolvedDestination),
|
|
canonicalRoot,
|
|
action
|
|
);
|
|
const managedPath = path.join(canonicalParent, path.basename(resolvedDestination));
|
|
let stat = null;
|
|
|
|
try {
|
|
stat = fs.lstatSync(managedPath);
|
|
} catch (error) {
|
|
if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (stat && stat.isSymbolicLink() && !allowFinalSymlink) {
|
|
const error = new Error(
|
|
`Refusing to ${action}: managed destination is a final symlink.`
|
|
);
|
|
error.code = 'ECC_FINAL_DESTINATION_SYMLINK';
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
canonicalRoot,
|
|
exists: stat !== null,
|
|
isFinalSymlink: Boolean(stat && stat.isSymbolicLink()),
|
|
managedPath
|
|
};
|
|
}
|
|
|
|
function ensureContainedParentDir(destinationPath, trustedRoot, action) {
|
|
const initialDestination = getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action
|
|
);
|
|
const { canonicalRoot, managedPath } = initialDestination;
|
|
const canonicalParent = path.dirname(managedPath);
|
|
const relativeParent = path.relative(canonicalRoot, canonicalParent);
|
|
const pathSegments = relativeParent
|
|
? relativeParent.split(path.sep).filter(Boolean)
|
|
: [];
|
|
let currentPath = canonicalRoot;
|
|
|
|
for (const segment of pathSegments) {
|
|
const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action);
|
|
const nextPath = path.join(validatedParent, segment);
|
|
try {
|
|
fs.mkdirSync(nextPath);
|
|
} catch (error) {
|
|
if (!error || error.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action);
|
|
const nextStat = fs.lstatSync(validatedNext);
|
|
if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`);
|
|
}
|
|
currentPath = validatedNext;
|
|
}
|
|
|
|
return getManagedDestination(managedPath, canonicalRoot, action).managedPath;
|
|
}
|
|
|
|
function prepareContainedWriteDestination(destinationPath, trustedRoot, action) {
|
|
return ensureContainedParentDir(destinationPath, trustedRoot, action);
|
|
}
|
|
|
|
function getContainedExistingPath(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink = false } = {}
|
|
) {
|
|
const initialDestination = getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink }
|
|
);
|
|
const followsToExistingPath = fs.existsSync(initialDestination.managedPath);
|
|
if (!followsToExistingPath && !initialDestination.isFinalSymlink) {
|
|
return null;
|
|
}
|
|
|
|
const finalDestination = getManagedDestination(
|
|
initialDestination.managedPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink }
|
|
);
|
|
return finalDestination.exists ? finalDestination.managedPath : null;
|
|
}
|
|
|
|
function hasSameFileIdentity(leftStat, rightStat) {
|
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
}
|
|
|
|
function createChangedDestinationError(action) {
|
|
return new Error(
|
|
`Refusing to ${action}: managed destination changed during the write.`
|
|
);
|
|
}
|
|
|
|
function writeFileNoFollow(filePath, content, mode, trustedRoot, action, writeOptions = {}) {
|
|
return guardedWriteFile(filePath, content, {
|
|
...writeOptions,
|
|
mode,
|
|
action,
|
|
validateDestination(destinationPath) {
|
|
return getManagedDestination(destinationPath, trustedRoot, action).managedPath;
|
|
},
|
|
});
|
|
}
|
|
|
|
function readFileWithMetadataNoFollow(filePath, encoding) {
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const fileDescriptor = fs.openSync(filePath, flags);
|
|
|
|
try {
|
|
const stat = fs.fstatSync(fileDescriptor);
|
|
if (!stat.isFile()) {
|
|
throw new Error(`Refusing to read non-file path: ${filePath}`);
|
|
}
|
|
return {
|
|
content: fs.readFileSync(fileDescriptor, encoding),
|
|
mode: stat.mode,
|
|
};
|
|
} finally {
|
|
fs.closeSync(fileDescriptor);
|
|
}
|
|
}
|
|
|
|
function readFileNoFollow(filePath, encoding) {
|
|
return readFileWithMetadataNoFollow(filePath, encoding).content;
|
|
}
|
|
|
|
function readJsonNoFollow(filePath) {
|
|
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
|
|
}
|
|
|
|
function readHooksConfigNoFollow(hooksPath) {
|
|
const hooksConfig = readJsonNoFollow(hooksPath);
|
|
const metadataPath = metadataPathFor(hooksPath);
|
|
if (!fs.existsSync(metadataPath)) {
|
|
return hooksConfig;
|
|
}
|
|
return mergeHooksMetadata(hooksConfig, readJsonNoFollow(metadataPath), hooksPath);
|
|
}
|
|
|
|
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
|
|
if (target && target !== 'claude' && target !== 'claude-project') {
|
|
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
|
|
}
|
|
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
|
|
}
|
|
|
|
function writeContainedFile(destinationPath, content, trustedRoot, action, mode, writeOptions) {
|
|
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
|
|
const finalDestination = getManagedDestination(
|
|
preparedDestination,
|
|
trustedRoot,
|
|
action
|
|
).managedPath;
|
|
writeFileNoFollow(
|
|
finalDestination,
|
|
content,
|
|
mode,
|
|
trustedRoot,
|
|
action,
|
|
writeOptions
|
|
);
|
|
return finalDestination;
|
|
}
|
|
|
|
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
|
|
const source = readFileWithMetadataNoFollow(sourcePath);
|
|
return writeContainedFile(
|
|
destinationPath,
|
|
source.content,
|
|
trustedRoot,
|
|
action,
|
|
source.mode & 0o777
|
|
);
|
|
}
|
|
|
|
function removeContainedPath(destinationPath, trustedRoot, action, options = {}) {
|
|
const existingDestination = getContainedExistingPath(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink: true }
|
|
);
|
|
if (!existingDestination) {
|
|
return null;
|
|
}
|
|
|
|
const managedDestination = getManagedDestination(
|
|
existingDestination,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink: true }
|
|
);
|
|
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;
|
|
}
|
|
|
|
function cleanupEmptyParentDirs(filePath, stopAt) {
|
|
const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up');
|
|
const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up');
|
|
let currentPath = path.dirname(trustedFilePath);
|
|
|
|
while (currentPath) {
|
|
const relativePath = path.relative(trustedStopAt, currentPath);
|
|
const isContained = relativePath !== '..'
|
|
&& !relativePath.startsWith(`..${path.sep}`)
|
|
&& !path.isAbsolute(relativePath);
|
|
if (!isContained || relativePath === '') {
|
|
break;
|
|
}
|
|
|
|
let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up');
|
|
if (!fs.existsSync(validatedPath)) {
|
|
currentPath = path.dirname(validatedPath);
|
|
continue;
|
|
}
|
|
|
|
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
const stat = fs.lstatSync(validatedPath);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
break;
|
|
}
|
|
|
|
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
if (fs.readdirSync(validatedPath).length > 0) {
|
|
break;
|
|
}
|
|
|
|
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
|
|
if (!removedPath) break;
|
|
currentPath = path.dirname(removedPath);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
assertClaudeSettingsDestination,
|
|
cleanupEmptyParentDirs,
|
|
copyContainedFile,
|
|
createChangedDestinationError,
|
|
ensureContainedParentDir,
|
|
getContainedExistingPath,
|
|
getManagedDestination,
|
|
hasSameFileIdentity,
|
|
prepareContainedWriteDestination,
|
|
readFileNoFollow,
|
|
readFileWithMetadataNoFollow,
|
|
readHooksConfigNoFollow,
|
|
readJsonNoFollow,
|
|
removeContainedPath,
|
|
writeContainedFile,
|
|
writeFileNoFollow
|
|
};
|