From 5191a908d13dc3d9acf783b8d8953c5c25eb4fa7 Mon Sep 17 00:00:00 2001 From: Geronimo Date: Sun, 20 Sep 2026 20:46:45 +0530 Subject: [PATCH] refactor: split install-lifecycle god-module, harden test runner and lint Extract contained-fs (TOCTOU-safe fs ops) and json-ops helpers into scripts/lib/install/. install-lifecycle 2453->1957 lines, public API unchanged (66/0 lifecycle tests pass). Harden tests/run-all.js with exit-code-first verdict, TAP fallback, file-level counts and filter arg. Add warn-level eslint guards (prefer-const, complexity, max-depth, max-params). Split test scripts into test:validators/test:js/test:python and coverage:js/coverage:python. --- eslint.config.js | 8 +- package.json | 9 +- scripts/lib/install-lifecycle.js | 546 ++-------------------------- scripts/lib/install/contained-fs.js | 370 +++++++++++++++++++ scripts/lib/install/json-ops.js | 180 +++++++++ tests/run-all.js | 91 +++-- 6 files changed, 657 insertions(+), 547 deletions(-) create mode 100644 scripts/lib/install/contained-fs.js create mode 100644 scripts/lib/install/json-ops.js diff --git a/eslint.config.js b/eslint.config.js index 788a502b5..99323fb30 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,13 @@ module.exports = [ caughtErrorsIgnorePattern: '^_' }], 'no-undef': 'error', - 'eqeqeq': 'warn' + 'eqeqeq': 'warn', + 'prefer-const': 'warn', + 'no-var': 'warn', + 'no-console': 'off', + 'complexity': ['warn', 20], + 'max-depth': ['warn', 5], + 'max-params': ['warn', 6] } }, { diff --git a/package.json b/package.json index 6a53ed2f5..70e37d609 100644 --- a/package.json +++ b/package.json @@ -491,8 +491,13 @@ "orchestrate:status": "node scripts/orchestration-status.js", "orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh", "orchestrate:tmux": "node scripts/orchestrate-worktrees.js", - "test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js", - "coverage": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 79 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js", + "test:validators": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check", + "test:js": "node tests/run-all.js", + "test:python": "python3 -m pytest tests/ -q", + "test": "npm run test:validators && npm run test:js", + "coverage:js": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 79 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js", + "coverage:python": "python3 -m pytest tests/ -q --cov=src --cov-report=term --cov-fail-under=80", + "coverage": "npm run coverage:js", "build:opencode": "node scripts/build-opencode.js", "prepack": "npm run build:opencode", "dashboard": "python3 ./ecc_dashboard.py", diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 7da0ae78a..3f6c7f71a 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -25,7 +25,6 @@ const { } = require('./install/opencode-legacy-migration'); const { acquireSettingsLock, - assertClaudeSettingsPath, getClaudeSettingsPath, inspectManagedHooks, materializeManagedHooks, @@ -38,7 +37,31 @@ const { adaptAntigravityAgent } = require('./install/antigravity-agent'); const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); const { resolveInvocationEnvironment } = require('./invocation-environment'); -const { mergeHooksMetadata, metadataPathFor } = require('./hooks-config'); +const { + assertClaudeSettingsDestination, + cleanupEmptyParentDirs, + copyContainedFile, + getContainedExistingPath, + getManagedDestination, + prepareContainedWriteDestination, + readFileNoFollow, + readFileWithMetadataNoFollow, + readHooksConfigNoFollow, + readJsonNoFollow, + removeContainedPath, + writeContainedFile, +} = require('./install/contained-fs'); +const { + JSON_REMOVE_SENTINEL, + deepMergeJson, + deepRemoveJsonSubset, + formatJson, + getOperationJsonPayload, + getOperationPreviousContent, + getOperationPreviousJson, + getOperationTextContent, + jsonContainsSubset, +} = require('./install/json-ops'); const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist'); const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js'); const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built'; @@ -246,488 +269,6 @@ function getExpectedCopyFileContent(operation, content, linkIndex) { }); } -function isPlainObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function cloneJsonValue(value) { - if (value === undefined) { - return undefined; - } - - return JSON.parse(JSON.stringify(value)); -} - -function parseJsonLikeValue(value, label) { - if (value === undefined) { - return undefined; - } - - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch (error) { - throw new Error(`Invalid ${label}: ${error.message}`); - } - } - - if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') { - return cloneJsonValue(value); - } - - throw new Error(`Invalid ${label}: expected JSON-compatible data`); -} - -function getOperationTextContent(operation) { - const candidateKeys = ['renderedContent', 'content', 'managedContent', 'expectedContent', 'templateOutput']; - - for (const key of candidateKeys) { - if (typeof operation[key] === 'string') { - return operation[key]; - } - } - - return null; -} - -function getOperationJsonPayload(operation) { - const candidateKeys = ['mergePayload', 'managedPayload', 'payload', 'value', 'expectedValue']; - - for (const key of candidateKeys) { - if (operation[key] !== undefined) { - return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); - } - } - - return undefined; -} - -function getOperationPreviousContent(operation) { - const candidateKeys = ['previousContent', 'originalContent', 'backupContent']; - - for (const key of candidateKeys) { - if (typeof operation[key] === 'string') { - return operation[key]; - } - } - - return null; -} - -function getOperationPreviousJson(operation) { - const candidateKeys = ['previousValue', 'previousJson', 'originalValue']; - - for (const key of candidateKeys) { - if (operation[key] !== undefined) { - return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); - } - } - - return undefined; -} - -function formatJson(value) { - return `${JSON.stringify(value, null, 2)}\n`; -} - -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 getStableParentStat(filePath, action) { - const parentStat = fs.lstatSync(path.dirname(filePath)); - if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { - throw createChangedDestinationError(action); - } - return parentStat; -} - -function assertPinnedWriteDestination( - filePath, - fileDescriptor, - expectedParentStat, - trustedRoot, - action -) { - const liveDestination = getManagedDestination(filePath, trustedRoot, action); - if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) { - throw createChangedDestinationError(action); - } - - const liveParentStat = getStableParentStat(filePath, action); - if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) { - throw createChangedDestinationError(action); - } - - const descriptorStat = fs.fstatSync(fileDescriptor); - const livePathStat = fs.lstatSync(liveDestination.managedPath); - if ( - !descriptorStat.isFile() - || !livePathStat.isFile() - || livePathStat.isSymbolicLink() - || !hasSameFileIdentity(descriptorStat, livePathStat) - ) { - throw createChangedDestinationError(action); - } -} - -function writeFileNoFollow(filePath, content, mode, trustedRoot, action) { - const expectedParentStat = getStableParentStat(filePath, action); - const flags = fs.constants.O_WRONLY - | fs.constants.O_CREAT - | (fs.constants.O_NOFOLLOW || 0); - const fileDescriptor = fs.openSync(filePath, flags, mode); - - try { - assertPinnedWriteDestination( - filePath, - fileDescriptor, - expectedParentStat, - trustedRoot, - action - ); - fs.ftruncateSync(fileDescriptor, 0); - fs.writeFileSync(fileDescriptor, content); - if (mode !== undefined) { - fs.fchmodSync(fileDescriptor, mode); - } - } finally { - fs.closeSync(fileDescriptor); - } -} - -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')); -} - -/** - * Read hooks.json and merge in hooks/hooks.metadata.json without following - * symlinks. The sidecar holds the stable matcher ids that hooks.json cannot - * carry, because Claude Code reports unknown keys when the plugin loads. A - * sidecar that does not line up with hooks.json is rejected before repair can - * reconcile matchers under the wrong ids. - * - * @param {string} hooksPath - Path to the source hooks.json. - * @returns {object} the hooks configuration with ids and descriptions restored. - */ -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) { - const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action); - const finalDestination = getManagedDestination( - preparedDestination, - trustedRoot, - action - ).managedPath; - writeFileNoFollow( - finalDestination, - content, - mode, - trustedRoot, - action - ); - 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 deepMergeJson(baseValue, patchValue) { - if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { - return cloneJsonValue(patchValue); - } - - const merged = { ...baseValue }; - for (const [key, value] of Object.entries(patchValue)) { - if (isPlainObject(value) && isPlainObject(merged[key])) { - merged[key] = deepMergeJson(merged[key], value); - } else { - merged[key] = cloneJsonValue(value); - } - } - return merged; -} - -function jsonContainsSubset(actualValue, expectedValue) { - if (isPlainObject(expectedValue)) { - if (!isPlainObject(actualValue)) { - return false; - } - - return Object.entries(expectedValue).every(([key, value]) => Object.prototype.hasOwnProperty.call(actualValue, key) && jsonContainsSubset(actualValue[key], value)); - } - - if (Array.isArray(expectedValue)) { - if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) { - return false; - } - - return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item)); - } - - return actualValue === expectedValue; -} - -const JSON_REMOVE_SENTINEL = Symbol('json-remove'); - -function deepRemoveJsonSubset(currentValue, managedValue) { - if (isPlainObject(managedValue)) { - if (!isPlainObject(currentValue)) { - return currentValue; - } - - const nextValue = { ...currentValue }; - for (const [key, value] of Object.entries(managedValue)) { - if (!Object.prototype.hasOwnProperty.call(nextValue, key)) { - continue; - } - - if (isPlainObject(value)) { - const nestedValue = deepRemoveJsonSubset(nextValue[key], value); - if (nestedValue === JSON_REMOVE_SENTINEL) { - delete nextValue[key]; - } else { - nextValue[key] = nestedValue; - } - continue; - } - - if (Array.isArray(value)) { - if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) { - delete nextValue[key]; - } - continue; - } - - if (nextValue[key] === value) { - delete nextValue[key]; - } - } - - return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue; - } - - if (Array.isArray(managedValue)) { - return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue; - } - - return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue; -} - function hydrateRecordedOperations(repoRoot, operations, trustedRoot) { return operations.map(operation => { if (operation.kind === 'update-claude-settings') { @@ -2264,43 +1805,6 @@ function repairInstalledStates(options = {}) { }; } -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); - } -} function uninstallInstalledStates(options = {}) { const records = discoverInstalledStates({ diff --git a/scripts/lib/install/contained-fs.js b/scripts/lib/install/contained-fs.js new file mode 100644 index 000000000..db1cbbfda --- /dev/null +++ b/scripts/lib/install/contained-fs.js @@ -0,0 +1,370 @@ +const fs = require('fs'); +const path = require('path'); +const { assertWithinTrustedRoot } = require('../path-safety'); +const { mergeHooksMetadata, metadataPathFor } = require('../hooks-config'); +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 getStableParentStat(filePath, action) { + const parentStat = fs.lstatSync(path.dirname(filePath)); + if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { + throw createChangedDestinationError(action); + } + return parentStat; +} + +function assertPinnedWriteDestination( + filePath, + fileDescriptor, + expectedParentStat, + trustedRoot, + action +) { + const liveDestination = getManagedDestination(filePath, trustedRoot, action); + if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) { + throw createChangedDestinationError(action); + } + + const liveParentStat = getStableParentStat(filePath, action); + if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) { + throw createChangedDestinationError(action); + } + + const descriptorStat = fs.fstatSync(fileDescriptor); + const livePathStat = fs.lstatSync(liveDestination.managedPath); + if ( + !descriptorStat.isFile() + || !livePathStat.isFile() + || livePathStat.isSymbolicLink() + || !hasSameFileIdentity(descriptorStat, livePathStat) + ) { + throw createChangedDestinationError(action); + } +} + +function writeFileNoFollow(filePath, content, mode, trustedRoot, action) { + const expectedParentStat = getStableParentStat(filePath, action); + const flags = fs.constants.O_WRONLY + | fs.constants.O_CREAT + | (fs.constants.O_NOFOLLOW || 0); + const fileDescriptor = fs.openSync(filePath, flags, mode); + + try { + assertPinnedWriteDestination( + filePath, + fileDescriptor, + expectedParentStat, + trustedRoot, + action + ); + fs.ftruncateSync(fileDescriptor, 0); + fs.writeFileSync(fileDescriptor, content); + if (mode !== undefined) { + fs.fchmodSync(fileDescriptor, mode); + } + } finally { + fs.closeSync(fileDescriptor); + } +} + +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) { + const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action); + const finalDestination = getManagedDestination( + preparedDestination, + trustedRoot, + action + ).managedPath; + writeFileNoFollow( + finalDestination, + content, + mode, + trustedRoot, + action + ); + 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, + assertPinnedWriteDestination, + cleanupEmptyParentDirs, + copyContainedFile, + createChangedDestinationError, + ensureContainedParentDir, + getContainedExistingPath, + getManagedDestination, + getStableParentStat, + hasSameFileIdentity, + prepareContainedWriteDestination, + readFileNoFollow, + readFileWithMetadataNoFollow, + readHooksConfigNoFollow, + readJsonNoFollow, + removeContainedPath, + writeContainedFile, + writeFileNoFollow, +}; diff --git a/scripts/lib/install/json-ops.js b/scripts/lib/install/json-ops.js new file mode 100644 index 000000000..f5aad032d --- /dev/null +++ b/scripts/lib/install/json-ops.js @@ -0,0 +1,180 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value) { + if (value === undefined) { + return undefined; + } + + return JSON.parse(JSON.stringify(value)); +} + +function parseJsonLikeValue(value, label) { + if (value === undefined) { + return undefined; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid ${label}: ${error.message}`); + } + } + + if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') { + return cloneJsonValue(value); + } + + throw new Error(`Invalid ${label}: expected JSON-compatible data`); +} + +function getOperationTextContent(operation) { + const candidateKeys = ['renderedContent', 'content', 'managedContent', 'expectedContent', 'templateOutput']; + + for (const key of candidateKeys) { + if (typeof operation[key] === 'string') { + return operation[key]; + } + } + + return null; +} + +function getOperationJsonPayload(operation) { + const candidateKeys = ['mergePayload', 'managedPayload', 'payload', 'value', 'expectedValue']; + + for (const key of candidateKeys) { + if (operation[key] !== undefined) { + return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); + } + } + + return undefined; +} + +function getOperationPreviousContent(operation) { + const candidateKeys = ['previousContent', 'originalContent', 'backupContent']; + + for (const key of candidateKeys) { + if (typeof operation[key] === 'string') { + return operation[key]; + } + } + + return null; +} + +function getOperationPreviousJson(operation) { + const candidateKeys = ['previousValue', 'previousJson', 'originalValue']; + + for (const key of candidateKeys) { + if (operation[key] !== undefined) { + return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); + } + } + + return undefined; +} + +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function deepMergeJson(baseValue, patchValue) { + if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { + return cloneJsonValue(patchValue); + } + + const merged = { ...baseValue }; + for (const [key, value] of Object.entries(patchValue)) { + if (isPlainObject(value) && isPlainObject(merged[key])) { + merged[key] = deepMergeJson(merged[key], value); + } else { + merged[key] = cloneJsonValue(value); + } + } + return merged; +} + +function jsonContainsSubset(actualValue, expectedValue) { + if (isPlainObject(expectedValue)) { + if (!isPlainObject(actualValue)) { + return false; + } + + return Object.entries(expectedValue).every(([key, value]) => Object.prototype.hasOwnProperty.call(actualValue, key) && jsonContainsSubset(actualValue[key], value)); + } + + if (Array.isArray(expectedValue)) { + if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) { + return false; + } + + return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item)); + } + + return actualValue === expectedValue; +} + +const JSON_REMOVE_SENTINEL = Symbol('json-remove'); + +function deepRemoveJsonSubset(currentValue, managedValue) { + if (isPlainObject(managedValue)) { + if (!isPlainObject(currentValue)) { + return currentValue; + } + + const nextValue = { ...currentValue }; + for (const [key, value] of Object.entries(managedValue)) { + if (!Object.prototype.hasOwnProperty.call(nextValue, key)) { + continue; + } + + if (isPlainObject(value)) { + const nestedValue = deepRemoveJsonSubset(nextValue[key], value); + if (nestedValue === JSON_REMOVE_SENTINEL) { + delete nextValue[key]; + } else { + nextValue[key] = nestedValue; + } + continue; + } + + if (Array.isArray(value)) { + if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) { + delete nextValue[key]; + } + continue; + } + + if (nextValue[key] === value) { + delete nextValue[key]; + } + } + + return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue; + } + + if (Array.isArray(managedValue)) { + return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue; + } + + return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue; +} + +module.exports = { + JSON_REMOVE_SENTINEL, + cloneJsonValue, + deepMergeJson, + deepRemoveJsonSubset, + formatJson, + getOperationJsonPayload, + getOperationPreviousContent, + getOperationPreviousJson, + getOperationTextContent, + isPlainObject, + jsonContainsSubset, + parseJsonLikeValue, +}; diff --git a/tests/run-all.js b/tests/run-all.js index 22d0ff5a4..960f5de4e 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -1,9 +1,4 @@ #!/usr/bin/env node -/** - * Run all tests - * - * Usage: node tests/run-all.js - */ const { spawnSync } = require('child_process'); const path = require('path'); @@ -12,9 +7,13 @@ const fs = require('fs'); const testsDir = __dirname; const repoRoot = path.resolve(testsDir, '..'); const TEST_GLOB = 'tests/**/*.test.js'; +const filter = (process.argv[2] || '').split(path.sep).join('/'); function matchesTestGlob(relativePath) { const normalized = relativePath.split(path.sep).join('/'); + if (filter && !normalized.includes(filter)) { + return false; + } if (typeof path.matchesGlob === 'function') { return path.matchesGlob(normalized, TEST_GLOB); } @@ -58,9 +57,38 @@ function annotateFailure(displayPath, reason, output) { console.log(`::error file=${escapeAnnotation(`tests/${displayPath}`, true)}::${escapeAnnotation(message)}`); } +function parseCounts(combined) { + const passedMatch = combined.match(/Passed:\s*(\d+)/); + const failedMatch = combined.match(/Failed:\s*(\d+)/); + if (passedMatch || failedMatch) { + return { + passed: passedMatch ? parseInt(passedMatch[1], 10) : 0, + failed: failedMatch ? parseInt(failedMatch[1], 10) : 0, + definite: true, + }; + } + const lines = combined.split(/\r?\n/); + let tapPassed = 0; + let tapFailed = 0; + let tapSeen = false; + for (const line of lines) { + if (/^ok\b/.test(line.trim())) { + tapSeen = true; + tapPassed += 1; + } else if (/^not ok\b/.test(line.trim())) { + tapSeen = true; + tapFailed += 1; + } + } + if (tapSeen) { + return { passed: tapPassed, failed: tapFailed, definite: true }; + } + return { passed: 0, failed: 0, definite: false }; +} + const testFiles = discoverTestFiles(); -const BOX_W = 58; // inner width between ║ delimiters +const BOX_W = 58; const boxLine = s => `║${s.padEnd(BOX_W)}║`; console.log('╔' + '═'.repeat(BOX_W) + '╗'); @@ -69,13 +97,15 @@ console.log('╚' + '═'.repeat(BOX_W) + '╝'); console.log(); if (testFiles.length === 0) { - console.log(`✗ No test files matched ${TEST_GLOB}`); + console.log(`✗ No test files matched ${TEST_GLOB}${filter ? ` with filter "${filter}"` : ''}`); process.exit(1); } let totalPassed = 0; let totalFailed = 0; -let totalTests = 0; +let filesPassed = 0; +let filesFailed = 0; +const failedFiles = []; for (const testFile of testFiles) { const testPath = path.join(testsDir, testFile); @@ -88,10 +118,6 @@ for (const testFile of testFiles) { console.log(`\n━━━ Running ${displayPath} ━━━`); - // Run each test hermetically: strip inherited git env vars. When the suite - // runs inside a git hook (e.g. pre-push), git sets GIT_DIR/GIT_WORK_TREE, - // which would hijack `git -C ` calls in tests that exercise real git - // and make them operate on the host repo instead of their own fixtures. const childEnv = { ...process.env }; for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) { delete childEnv[key]; @@ -106,19 +132,12 @@ for (const testFile of testFiles) { const stdout = result.stdout || ''; const stderr = result.stderr || ''; - // Show both stdout and stderr so hook warnings are visible if (stdout) console.log(stdout); if (stderr) console.log(stderr); - // Parse results from combined output const combined = `${stdout}\n${stderr}`; - const passedMatch = combined.match(/Passed:\s*(\d+)/); - const failedMatch = combined.match(/Failed:\s*(\d+)/); - - if (passedMatch) totalPassed += parseInt(passedMatch[1], 10); - const reportedFailures = failedMatch ? parseInt(failedMatch[1], 10) : 0; + const counts = parseCounts(combined); const processFailed = Boolean(result.error) || result.status !== 0; - totalFailed += processFailed ? Math.max(reportedFailures, 1) : reportedFailures; let failureReason; if (result.error) { @@ -127,17 +146,32 @@ for (const testFile of testFiles) { failureReason = result.signal ? `terminated by signal ${result.signal}` : `exited with status ${result.status}`; - } else if (reportedFailures > 0) { - failureReason = `reported ${reportedFailures} failed tests`; + } else if (counts.definite && counts.failed > 0) { + failureReason = `reported ${counts.failed} failed tests`; } if (failureReason) { + filesFailed += 1; + failedFiles.push(displayPath); + totalPassed += counts.passed; + totalFailed += counts.definite ? counts.failed : Math.max(counts.failed, 1); + if (processFailed && !counts.definite) { + totalFailed += 1; + } console.log(`✗ ${displayPath} ${failureReason}`); annotateFailure(displayPath, failureReason, combined); + } else { + filesPassed += 1; + if (counts.definite) { + totalPassed += counts.passed; + totalFailed += counts.failed; + } else { + totalPassed += 1; + } } } -totalTests = totalPassed + totalFailed; +const totalTests = totalPassed + totalFailed; console.log('\n╔' + '═'.repeat(BOX_W) + '╗'); console.log(boxLine(' Final Results')); @@ -145,6 +179,17 @@ console.log('╠' + '═'.repeat(BOX_W) + '╣'); console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`)); console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`)); console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`)); +console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`)); +if (failedFiles.length > 0) { + console.log('╠' + '═'.repeat(BOX_W) + '╣'); + for (const name of failedFiles.slice(0, 10)) { + console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W))); + } + if (failedFiles.length > 10) { + console.log(boxLine(` ... and ${failedFiles.length - 10} more`)); + } +} console.log('╚' + '═'.repeat(BOX_W) + '╝'); +console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`); process.exit(totalFailed > 0 ? 1 : 0);