mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-27 03:55:03 +02:00
test: cover Windows settings identity races
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Claude settings updates now tolerate a missing Windows device ID while retaining full-precision inode checks and strict matching when both device IDs are available.
|
||||||
|
|
||||||
## 2.2.0 - 2026-08-25
|
## 2.2.0 - 2026-08-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ function clone(value) {
|
|||||||
return JSON.parse(JSON.stringify(value));
|
return JSON.parse(JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveStats(stats, overrides) {
|
||||||
|
return Object.create(stats, Object.fromEntries(
|
||||||
|
Object.entries(overrides).map(([name, value]) => [name, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value,
|
||||||
|
}])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function assertAtomicParentReplacementRejected(stage) {
|
function assertAtomicParentReplacementRejected(stage) {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-parent-race-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-parent-race-'));
|
||||||
const targetRoot = path.join(tempDir, 'target');
|
const targetRoot = path.join(tempDir, 'target');
|
||||||
@@ -330,8 +340,7 @@ function runTests() {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
fs.lstatSync = function(...args) {
|
fs.lstatSync = function(...args) {
|
||||||
const stats = originalLstatSync.apply(fs, args);
|
const stats = originalLstatSync.apply(fs, args);
|
||||||
stats.dev = typeof stats.dev === 'bigint' ? 0n : 0;
|
return deriveStats(stats, { dev: typeof stats.dev === 'bigint' ? 0n : 0 });
|
||||||
return stats;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSettingsAtomic(
|
updateSettingsAtomic(
|
||||||
@@ -354,6 +363,96 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('atomic settings updates reject unequal nonzero Windows device ids', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-win-dev-mismatch-'));
|
||||||
|
const settingsPath = path.join(tempDir, 'settings.json');
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const initial = '{"theme":"initial"}\n';
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(settingsPath, initial);
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
fs.lstatSync = function(targetPath, ...args) {
|
||||||
|
const stats = originalLstatSync.call(fs, targetPath, ...args);
|
||||||
|
if (targetPath !== settingsPath) return stats;
|
||||||
|
const mismatchedDev = typeof stats.dev === 'bigint' ? stats.dev + 1n : stats.dev + 1;
|
||||||
|
return deriveStats(stats, { dev: mismatchedDev });
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => updateSettingsAtomic(
|
||||||
|
settingsPath,
|
||||||
|
settings => ({ settings: { ...settings, managed: true } })
|
||||||
|
),
|
||||||
|
error => error.code === 'ECC_SETTINGS_CHANGED'
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), initial);
|
||||||
|
assert.ok(!fs.existsSync(`${settingsPath}.ecc.lock`));
|
||||||
|
} finally {
|
||||||
|
fs.lstatSync = originalLstatSync;
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('settings snapshots request BigInt stats and reject inodes that collide as Numbers', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-bigint-identity-'));
|
||||||
|
const settingsPath = path.join(tempDir, 'settings.json');
|
||||||
|
const originalOpenSync = fs.openSync;
|
||||||
|
const originalFstatSync = fs.fstatSync;
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
let settingsDescriptor;
|
||||||
|
let sawBigIntFstat = false;
|
||||||
|
let sawBigIntLstat = false;
|
||||||
|
const descriptorIno = 9007199254740992n;
|
||||||
|
const pathIno = 9007199254740993n;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(settingsPath, '{"theme":"initial"}\n');
|
||||||
|
fs.openSync = function(targetPath, ...args) {
|
||||||
|
const descriptor = originalOpenSync.call(fs, targetPath, ...args);
|
||||||
|
if (targetPath === settingsPath) settingsDescriptor = descriptor;
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
fs.fstatSync = function(descriptor, options) {
|
||||||
|
const stats = originalFstatSync.call(fs, descriptor, options);
|
||||||
|
if (descriptor !== settingsDescriptor) return stats;
|
||||||
|
sawBigIntFstat = options && options.bigint === true;
|
||||||
|
return deriveStats(stats, {
|
||||||
|
ino: typeof stats.ino === 'bigint' ? descriptorIno : Number(descriptorIno),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fs.lstatSync = function(targetPath, options) {
|
||||||
|
const stats = originalLstatSync.call(fs, targetPath, options);
|
||||||
|
if (targetPath !== settingsPath) return stats;
|
||||||
|
sawBigIntLstat = options && options.bigint === true;
|
||||||
|
return deriveStats(stats, {
|
||||||
|
ino: typeof stats.ino === 'bigint' ? pathIno : Number(pathIno),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => updateSettingsAtomic(
|
||||||
|
settingsPath,
|
||||||
|
settings => ({ settings: { ...settings, managed: true } })
|
||||||
|
),
|
||||||
|
error => error.code === 'ECC_SETTINGS_CHANGED'
|
||||||
|
);
|
||||||
|
assert.strictEqual(sawBigIntFstat, true);
|
||||||
|
assert.strictEqual(sawBigIntLstat, true);
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
|
||||||
|
theme: 'initial',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.openSync = originalOpenSync;
|
||||||
|
fs.fstatSync = originalFstatSync;
|
||||||
|
fs.lstatSync = originalLstatSync;
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('atomic settings updates retry after a concurrent change and preserve secure mode', () => {
|
if (test('atomic settings updates retry after a concurrent change and preserve secure mode', () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-atomic-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-atomic-'));
|
||||||
const settingsPath = path.join(tempDir, 'settings.json');
|
const settingsPath = path.join(tempDir, 'settings.json');
|
||||||
@@ -504,6 +603,77 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('settings lock release preserves a lock with an unequal nonzero Windows device id', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-dev-'));
|
||||||
|
const settingsPath = path.join(tempDir, 'settings.json');
|
||||||
|
const lockPath = `${settingsPath}.ecc.lock`;
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
let lockContents;
|
||||||
|
try {
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
fs.lstatSync = function(targetPath, ...args) {
|
||||||
|
const stats = originalLstatSync.call(fs, targetPath, ...args);
|
||||||
|
if (!String(targetPath).includes('.ecc.lock.release-')) return stats;
|
||||||
|
const mismatchedDev = typeof stats.dev === 'bigint' ? stats.dev + 1n : stats.dev + 1;
|
||||||
|
return deriveStats(stats, { dev: mismatchedDev });
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => runWithSettingsLock(settingsPath, () => {
|
||||||
|
lockContents = fs.readFileSync(lockPath, 'utf8');
|
||||||
|
}),
|
||||||
|
/Refusing to release a changed Claude settings lock/
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), lockContents);
|
||||||
|
} finally {
|
||||||
|
fs.lstatSync = originalLstatSync;
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stale lock recovery preserves a lock with an unequal nonzero Windows device id', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-stale-dev-'));
|
||||||
|
const settingsPath = path.join(tempDir, 'settings.json');
|
||||||
|
const lockPath = `${settingsPath}.ecc.lock`;
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const lockContents = 'foreign stale lock\n';
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(lockPath, lockContents, { mode: 0o600 });
|
||||||
|
const stale = new Date(Date.now() - (10 * 60 * 1000));
|
||||||
|
fs.utimesSync(lockPath, stale, stale);
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||||
|
fs.lstatSync = function(targetPath, ...args) {
|
||||||
|
const stats = originalLstatSync.call(fs, targetPath, ...args);
|
||||||
|
if (!stats || !String(targetPath).includes('.ecc.lock.stale-')) return stats;
|
||||||
|
const mismatchedDev = typeof stats.dev === 'bigint' ? stats.dev + 1n : stats.dev + 1;
|
||||||
|
return deriveStats(stats, { dev: mismatchedDev });
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => updateSettingsAtomic(
|
||||||
|
settingsPath,
|
||||||
|
settings => ({ settings: { ...settings, recovered: true } })
|
||||||
|
),
|
||||||
|
/Another ECC process is updating Claude settings/
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), lockContents);
|
||||||
|
assert.ok(!fs.existsSync(`${lockPath}.recover`));
|
||||||
|
} finally {
|
||||||
|
fs.lstatSync = originalLstatSync;
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('atomic settings updates refuse a symlinked destination', () => {
|
if (test('atomic settings updates refuse a symlinked destination', () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
|
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
|
||||||
|
|||||||
Reference in New Issue
Block a user