diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 0ce44fae0..7da51910c 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -96,13 +96,22 @@ function readInstalledFileNoFollow(plan, operation) { } try { + const openedStat = fs.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fs.lstatSync(operation.destinationPath, { bigint: true }); + if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile()) { + return null; + } + const identityMatches = openedStat.ino === finalPathStat.ino + && (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev); + if (!openedStat.isFile() || !identityMatches) { + throw new Error( + `Refusing to hash changed install destination: ${operation.destinationPath}` + ); + } // Revalidate the full path after opening. The descriptor pins the file so // the digest and metadata refer to the same object. assertSafeInstallOperation(plan, operation); assertSafeClaudeSkillOperation(plan, operation); - if (!fs.fstatSync(descriptor).isFile()) { - return null; - } return fs.readFileSync(descriptor); } finally { fs.closeSync(descriptor); @@ -457,7 +466,10 @@ function applyInstallPlan(plan, dependencies = {}) { stateWithContentDigests(migration.bridgeState, appliedPlan) ); } catch (checkpointError) { - error.message += ` Install-state checkpoint also failed: ${checkpointError.message}`; + throw new Error( + `${error.message} Install-state checkpoint also failed: ${checkpointError.message}`, + { cause: error } + ); } } throw error; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 8f72ebc66..7ddd8d48f 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -188,7 +188,16 @@ function managedOperation(kind, destinationPath, overrides = {}) { destinationPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) ); - if (fs.fstatSync(descriptor).isFile()) { + const openedStat = fs.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fs.lstatSync(destinationPath, { bigint: true }); + const identityMatches = openedStat.ino === finalPathStat.ino + && (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev); + if ( + openedStat.isFile() + && finalPathStat.isFile() + && !finalPathStat.isSymbolicLink() + && identityMatches + ) { operation.contentSha256 = crypto.createHash('sha256') .update(fs.readFileSync(descriptor)) .digest('hex'); @@ -212,6 +221,25 @@ function runTests() { let passed = 0; let failed = 0; + if (test('managed-operation digest never follows a final symlink', () => { + const tempDir = createTempDir('install-lifecycle-symlink-digest-'); + const victimPath = path.join(tempDir, 'victim.md'); + const symlinkPath = path.join(tempDir, 'managed.md'); + try { + fs.writeFileSync(victimPath, 'user content\n'); + try { + fs.symlinkSync(victimPath, symlinkPath, 'file'); + } catch { + console.log(' (file symlink unsupported on this platform; skipping)'); + return; + } + const operation = managedOperation('copy-file', symlinkPath); + assert.strictEqual(operation.contentSha256, undefined); + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('normalizes default targets and dedupes adapter aliases', () => { const defaultTargets = normalizeTargets();