mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
fix(install): pin Claude settings parent during atomic replacement
Reject directory replacement after temporary file creation or staging, preserve unrelated files during cleanup, and retry settings edits observed before the final rename. Add three regression tests for the review findings.
This commit is contained in:
@@ -13,21 +13,34 @@ function writeFileAtomic(filePath, content, options = {}) {
|
||||
);
|
||||
const mode = options.mode || 0o600;
|
||||
|
||||
if (options.validateParent) options.validateParent();
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
let descriptor;
|
||||
try {
|
||||
if (options.validateParent) options.validateParent();
|
||||
descriptor = fs.openSync(tempPath, 'wx', mode);
|
||||
if (options.validateParent) options.validateParent();
|
||||
fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' });
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
if (options.validateParent) options.validateParent();
|
||||
if (options.beforeRename) options.beforeRename();
|
||||
fs.renameSync(tempPath, resolvedPath);
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
// If the parent was replaced, this pathname may now name somebody else's
|
||||
// file. Leave the private staging file in its original directory.
|
||||
let parentUnchanged = true;
|
||||
try {
|
||||
if (options.validateParent) options.validateParent();
|
||||
} catch (_error) {
|
||||
parentUnchanged = false;
|
||||
}
|
||||
if (parentUnchanged) fs.rmSync(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -389,9 +389,23 @@ function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
|
||||
|
||||
function updateSettingsAtomic(settingsPath, transform, options = {}) {
|
||||
const update = () => {
|
||||
const parentPath = path.dirname(path.resolve(settingsPath));
|
||||
const parentStats = fs.lstatSync(parentPath, { bigint: true });
|
||||
const validateParent = () => {
|
||||
const current = fs.lstatSync(parentPath, { bigint: true });
|
||||
if (
|
||||
!current.isDirectory() || current.isSymbolicLink()
|
||||
|| current.dev !== parentStats.dev || current.ino !== parentStats.ino
|
||||
) {
|
||||
const error = new Error(`Claude settings parent directory changed: ${parentPath}`);
|
||||
error.code = 'ECC_SETTINGS_PARENT_CHANGED';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
validateParent();
|
||||
const snapshot = readSettingsSnapshot(settingsPath);
|
||||
const result = transform(snapshot.settings);
|
||||
if (typeof options.beforeCommit === 'function') options.beforeCommit();
|
||||
@@ -399,7 +413,14 @@ function updateSettingsAtomic(settingsPath, transform, options = {}) {
|
||||
writeFileAtomic(
|
||||
settingsPath,
|
||||
`${JSON.stringify(result.settings, null, 2)}\n`,
|
||||
{ encoding: 'utf8', mode: snapshot.mode }
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: snapshot.mode,
|
||||
validateParent,
|
||||
beforeRename() {
|
||||
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
|
||||
},
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user