diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 682341856..0ce44fae0 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -58,7 +58,9 @@ function readJsonObject(filePath, label) { try { parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (error) { - throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); + const wrappedError = new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); + wrappedError.code = error.code; + throw wrappedError; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -68,6 +70,45 @@ function readJsonObject(filePath, label) { return parsed; } +function readOptionalJsonObject(filePath, label) { + try { + return readJsonObject(filePath, label); + } catch (error) { + if (error.code === 'ENOENT') { + return {}; + } + throw error; + } +} + +function readInstalledFileNoFollow(plan, operation) { + assertSafeInstallOperation(plan, operation); + assertSafeClaudeSkillOperation(plan, operation); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + let descriptor; + try { + descriptor = fs.openSync(operation.destinationPath, flags); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } + + try { + // 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); + } +} + function stateWithContentDigests(state, plan) { return { ...state, @@ -75,26 +116,14 @@ function stateWithContentDigests(state, plan) { if (!operation.destinationPath) { return { ...operation }; } - if (plan) { - assertSafeInstallOperation(plan, operation); - assertSafeClaudeSkillOperation(plan, operation); - } - let destinationStat; - try { - destinationStat = fs.lstatSync(operation.destinationPath); - } catch (error) { - if (error.code === 'ENOENT') { - return { ...operation }; - } - throw error; - } - if (!destinationStat.isFile() || destinationStat.isSymbolicLink()) { + const installedContent = readInstalledFileNoFollow(plan, operation); + if (installedContent === null) { return { ...operation }; } return { ...operation, contentSha256: crypto.createHash('sha256') - .update(fs.readFileSync(operation.destinationPath)) + .update(installedContent) .digest('hex'), }; }), @@ -349,9 +378,10 @@ function applyInstallPlan(plan, dependencies = {}) { ? filterMcpConfig(payload, disabledServers).config : payload; - const currentValue = fs.existsSync(operation.destinationPath) - ? readJsonObject(operation.destinationPath, 'existing JSON config') - : {}; + const currentValue = readOptionalJsonObject( + operation.destinationPath, + 'existing JSON config' + ); const mergedValue = deepMergeJson(currentValue, filteredPayload); fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); continue; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 132221401..8f72ebc66 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -181,13 +181,27 @@ function managedOperation(kind, destinationPath, overrides = {}) { if ( kind === 'copy-file' && !Object.prototype.hasOwnProperty.call(overrides, 'contentSha256') - && fs.existsSync(destinationPath) - && fs.lstatSync(destinationPath).isFile() - && !fs.lstatSync(destinationPath).isSymbolicLink() ) { - operation.contentSha256 = crypto.createHash('sha256') - .update(fs.readFileSync(destinationPath)) - .digest('hex'); + let descriptor; + try { + descriptor = fs.openSync( + destinationPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) + ); + if (fs.fstatSync(descriptor).isFile()) { + operation.contentSha256 = crypto.createHash('sha256') + .update(fs.readFileSync(descriptor)) + .digest('hex'); + } + } catch (error) { + if (!['ENOENT', 'ELOOP'].includes(error.code)) { + throw error; + } + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } } return operation; }