diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index e9341c3e72..52eab0bec4 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -98,6 +98,14 @@ async function copyManifestScripts() { path.join(srcDir, 'socket-facts.plugin.scala'), path.join(destDir, 'socket-facts.plugin.scala'), ), + fs.copyFile( + path.join(srcDir, 'socket-workspaces.init.gradle'), + path.join(destDir, 'socket-workspaces.init.gradle'), + ), + fs.copyFile( + path.join(srcDir, 'socket-workspaces.plugin.scala'), + path.join(destDir, 'socket-workspaces.plugin.scala'), + ), ]) const jarPath = path.join( srcDir, diff --git a/package.json b/package.json index 6fc40e2961..61e3c4fa4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.152", + "version": "1.1.153-prerelease", "description": "CLI for Socket.dev", "contentPolicy": { "class": "dual-use" diff --git a/src/commands/manifest/cmd-manifest-auto.mts b/src/commands/manifest/cmd-manifest-auto.mts index 1289265531..e8451ba575 100644 --- a/src/commands/manifest/cmd-manifest-auto.mts +++ b/src/commands/manifest/cmd-manifest-auto.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { detectManifestActions } from './detect-manifest-actions.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' +import { excludePathsFlag } from './manifest-flags.mts' import constants from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' @@ -14,7 +15,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, diff --git a/src/commands/manifest/cmd-manifest-auto.test.mts b/src/commands/manifest/cmd-manifest-auto.test.mts index a6410f7e03..7025d5face 100644 --- a/src/commands/manifest/cmd-manifest-auto.test.mts +++ b/src/commands/manifest/cmd-manifest-auto.test.mts @@ -23,7 +23,7 @@ describe('socket manifest auto', async () => { $ socket manifest auto [options] [CWD=.] Options - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into errors Tries to figure out what language your target repo uses. If it finds a diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..61fc28864e --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,139 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { handleManifestDynamicSbomInference } from './handle-manifest-dynamic-sbom-inference.mts' +import { excludePathsFlag } from './manifest-flags.mts' +import constants, { FLAG_JSON, FLAG_MARKDOWN } from '../../constants.mts' +import { commonFlags, outputFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' + +import type { + CliCommandConfig, + CliCommandContext, +} from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'dynamic-sbom-inference', + description: + 'Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each', + // Hidden: `--dynamic-sbom-inference` already names an unrelated, root-only + // scan create/reach flag (see reachability-flags.mts). Keep this hidden + // until the naming collision between the two is resolved. + hidden: true, + flags: { + ...commonFlags, + ...outputFlags, + ...excludePathsFlag, + verbose: { + type: 'boolean', + default: false, + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Recursively walks CWD, discovers independent gradle, sbt, and maven build + roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + skipping subproject/reactor-module directories a parent build root already + covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + + Options + ${getFlagListOutput(config.flags)} + + Examples + + $ ${command} + $ ${command} ./monorepo + `, +} + +export const cmdManifestDynamicSbomInference = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: CliCommandContext, +): Promise { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { + dryRun, + json, + markdown, + verbose: verboseFlag, + } = cli.flags as { + dryRun: boolean + json: boolean + markdown: boolean + verbose: boolean | undefined + } + const verbose = !!verboseFlag + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + // This debug block prints to stdout; --json's payload does too, so skip it + // here (unlike the other manifest commands, this one supports --json). + if (verbose && !json) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- target:', cwd) + logger.groupEnd() + } + + const outputKind = getOutputKind(json, markdown) + + const wasValidInput = checkCommandInput( + outputKind, + { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + fail: `received ${cli.input.length}`, + }, + { + nook: true, + test: !json || !markdown, + message: `The \`${FLAG_JSON}\` and \`${FLAG_MARKDOWN}\` flags can not be used at the same time`, + fail: 'bad', + }, + ) + if (!wasValidInput) { + return + } + + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + + if (dryRun) { + logger.log(constants.DRY_RUN_BAILING_NOW) + return + } + + await handleManifestDynamicSbomInference({ + cwd, + excludePaths, + outputKind, + verbose, + }) +} diff --git a/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts new file mode 100644 index 0000000000..4207a94035 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts @@ -0,0 +1,76 @@ +import { describe, expect } from 'vitest' + +import constants, { + FLAG_CONFIG, + FLAG_DRY_RUN, + FLAG_HELP, +} from '../../../src/constants.mts' +import { cmdit, spawnSocketCli, testPath } from '../../../test/utils.mts' + +describe('socket manifest dynamic-sbom-inference', async () => { + const { binCliPath } = constants + + cmdit( + ['manifest', 'dynamic-sbom-inference', FLAG_HELP, FLAG_CONFIG, '{}'], + `should support ${FLAG_HELP}`, + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { + cwd: testPath, + }) + expect(stdout).toMatchInlineSnapshot(` + "Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each + + Usage + $ socket manifest dynamic-sbom-inference [options] [CWD=.] + + Recursively walks CWD, discovers independent gradle, sbt, and maven build + roots, and generates a Socket facts SBOM (.socket.facts.json) for each, + skipping subproject/reactor-module directories a parent build root already + covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself. + + Options + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --json Output as JSON + --markdown Output as Markdown + --verbose Print debug messages + + Examples + + $ socket manifest dynamic-sbom-inference + $ socket manifest dynamic-sbom-inference ./monorepo" + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest dynamic-sbom-inference`', + ) + }, + ) + + cmdit( + ['manifest', 'dynamic-sbom-inference', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should exit with dry-run message before touching disk', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { + cwd: testPath, + }) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dynamic-sbom-inference\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 650a8f5cdc..876790efca 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -206,8 +206,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.gradle?.includeConfigs + if (sockJson.defaults?.manifest?.gradle?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.gradle.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -217,8 +217,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.gradle?.excludeConfigs + if (sockJson.defaults?.manifest?.gradle?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.gradle.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -276,10 +276,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- gradle bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -302,6 +305,7 @@ async function run( gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, verbose: Boolean(verbose), }) return diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index a0efb72a69..fd2a4c23b0 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -25,7 +25,7 @@ describe('socket manifest gradle', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 1b5bf6b650..3f3c5df4c0 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -211,8 +211,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.gradle?.includeConfigs + if (sockJson.defaults?.manifest?.gradle?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.gradle.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -222,8 +222,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.gradle?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.gradle?.excludeConfigs + if (sockJson.defaults?.manifest?.gradle?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.gradle.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -279,10 +279,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- gradle bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -305,6 +308,7 @@ async function run( gradleOpts: parsedGradleOpts, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, verbose: Boolean(verbose), }) return diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 4b55415550..81906dd3e4 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -25,7 +25,7 @@ describe('socket manifest kotlin', async () => { Options --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --gradle-opts Additional options to pass on to ./gradlew, see \`./gradlew --help\` --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index f82a7ed86a..99f62029b3 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -4,6 +4,7 @@ import { debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { convertMavenToFacts } from './convert-maven-to-facts.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { SOCKET_JSON } from '../../constants.mts' @@ -15,7 +16,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -155,8 +155,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.maven?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.maven?.includeConfigs + if (sockJson.defaults?.manifest?.maven?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.maven.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -166,8 +166,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.maven?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.maven?.excludeConfigs + if (sockJson.defaults?.manifest?.maven?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.maven.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -214,10 +214,13 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.maven?.javaHome ?? undefined + if (verbose) { logger.group() logger.info('- cwd:', cwd) logger.info('- maven bin:', bin) + logger.info('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -238,6 +241,7 @@ async function run( excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, mavenOpts: parsedMavenOpts, verbose: Boolean(verbose), }) diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index 57412e4263..6388bb311d 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -24,7 +24,7 @@ describe('socket manifest maven', async () => { Options --bin Location of the maven binary to use, default: ./mvnw if present, else mvn on PATH --exclude-configs Comma-separated glob patterns; Maven scopes matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs Comma-separated glob patterns matched against Maven dependency scopes (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only scopes matching at least one pattern are resolved. e.g. \`compile,runtime\`. Default: every scope --maven-opts Additional options to pass on to maven, e.g. \`-P -s \` diff --git a/src/commands/manifest/cmd-manifest-scala.mts b/src/commands/manifest/cmd-manifest-scala.mts index 3ab01345e4..d2e4f5695a 100644 --- a/src/commands/manifest/cmd-manifest-scala.mts +++ b/src/commands/manifest/cmd-manifest-scala.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertSbtToFacts } from './convert-sbt-to-facts.mts' import { convertSbtToMaven } from './convert_sbt_to_maven.mts' +import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' @@ -16,7 +17,6 @@ import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' -import { excludePathsFlag } from '../scan/reachability-flags.mts' import type { CliCommandConfig, @@ -208,8 +208,8 @@ async function run( } } if (includeConfigs === undefined) { - if (sockJson.defaults?.manifest?.sbt?.includeConfigs !== undefined) { - includeConfigs = sockJson.defaults?.manifest?.sbt?.includeConfigs + if (sockJson.defaults?.manifest?.sbt?.includeConfigs) { + includeConfigs = sockJson.defaults.manifest.sbt.includeConfigs logger.info( `Using default --include-configs from ${SOCKET_JSON}:`, includeConfigs, @@ -219,8 +219,8 @@ async function run( } } if (excludeConfigs === undefined) { - if (sockJson.defaults?.manifest?.sbt?.excludeConfigs !== undefined) { - excludeConfigs = sockJson.defaults?.manifest?.sbt?.excludeConfigs + if (sockJson.defaults?.manifest?.sbt?.excludeConfigs) { + excludeConfigs = sockJson.defaults.manifest.sbt.excludeConfigs logger.info( `Using default --exclude-configs from ${SOCKET_JSON}:`, excludeConfigs, @@ -330,11 +330,14 @@ async function run( return } + const javaHome = sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined + if (verbose) { logger.group() logger.log('- target:', cwd) logger.log('- sbt bin:', bin) logger.log('- out:', out) + logger.log('- java home:', javaHome || '(inherited)') logger.groupEnd() } @@ -357,6 +360,7 @@ async function run( excludePaths, ignoreUnresolved: Boolean(ignoreUnresolved), includeConfigs: String(includeConfigs || ''), + javaHome, sbtOpts: parsedSbtOpts, tmpDir, verbose: Boolean(verbose), diff --git a/src/commands/manifest/cmd-manifest-scala.test.mts b/src/commands/manifest/cmd-manifest-scala.test.mts index 38131d70ee..96941b8884 100644 --- a/src/commands/manifest/cmd-manifest-scala.test.mts +++ b/src/commands/manifest/cmd-manifest-scala.test.mts @@ -25,7 +25,7 @@ describe('socket manifest scala', async () => { Options --bin Location of sbt binary to use --exclude-configs When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied after --include-configs) - --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the command working directory (\`--cwd\` if set), not the reachability target: \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --facts Emit a Socket facts JSON file (\`.socket.facts.json\`) describing the resolved dependency graph. This is the default; pass \`--pom\` to generate \`pom.xml\` files instead --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) --include-configs When generating facts: comma-separated glob patterns matched against sbt configuration names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only configurations matching at least one pattern are resolved. e.g. \`compile,test\`. Default: compile,optional,provided,runtime,test diff --git a/src/commands/manifest/cmd-manifest-setup.mts b/src/commands/manifest/cmd-manifest-setup.mts index 9294d508cc..4d0b3b9b68 100644 --- a/src/commands/manifest/cmd-manifest-setup.mts +++ b/src/commands/manifest/cmd-manifest-setup.mts @@ -5,8 +5,10 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { handleManifestSetup } from './handle-manifest-setup.mts' import constants, { SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' import type { CliCommandConfig, @@ -20,10 +22,24 @@ const config: CliCommandConfig = { hidden: false, flags: { ...commonFlags, + // Only meaningful alongside the hidden --dynamic-sbom-inference below; kept hidden too. + excludePaths: { + type: 'string', + isMultiple: true, + hidden: true, + description: + 'Build roots matching these glob patterns (and everything beneath them) are marked disabled. Patterns are anchored micromatch globs matched relative to CWD: `legacy` matches only `/legacy`; use `**/legacy` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, defaultOnReadError: { type: 'boolean', description: `If reading the ${SOCKET_JSON} fails, just use a default config? Warning: This might override the existing json file!`, }, + dynamicSbomInference: { + type: 'boolean', + hidden: true, + description: + 'Recursively scans for every gradle/sbt/maven build root beneath CWD first, so the CWD config step only asks about ecosystems actually found somewhere in the tree. A build root matching --exclude-paths is bulk-disabled with no prompt, applied unconditionally; eligible build roots found afterward can be configured individually', + }, }, help: (command, config) => ` Usage @@ -75,7 +91,7 @@ async function run( parentName, }) - const { defaultOnReadError = false } = cli.flags + const { defaultOnReadError = false, dynamicSbomInference = false } = cli.flags const dryRun = !!cli.flags['dryRun'] @@ -89,5 +105,13 @@ async function run( return } - await handleManifestSetup(cwd, Boolean(defaultOnReadError)) + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + + await handleManifestSetup( + cwd, + Boolean(defaultOnReadError), + Boolean(dynamicSbomInference), + excludePaths, + ) } diff --git a/src/commands/manifest/cmd-manifest.mts b/src/commands/manifest/cmd-manifest.mts index 518cdfddd7..fa94fe1b0d 100644 --- a/src/commands/manifest/cmd-manifest.mts +++ b/src/commands/manifest/cmd-manifest.mts @@ -2,6 +2,7 @@ import { cmdManifestBazel } from './bazel/cmd-manifest-bazel.mts' import { cmdManifestAuto } from './cmd-manifest-auto.mts' import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts' import { cmdManifestConda } from './cmd-manifest-conda.mts' +import { cmdManifestDynamicSbomInference } from './cmd-manifest-dynamic-sbom-inference.mts' import { cmdManifestGradle } from './cmd-manifest-gradle.mts' import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' import { cmdManifestMaven } from './cmd-manifest-maven.mts' @@ -73,6 +74,7 @@ async function run( bazel: cmdManifestBazel, cdxgen: cmdManifestCdxgen, conda: cmdManifestConda, + 'dynamic-sbom-inference': cmdManifestDynamicSbomInference, gradle: cmdManifestGradle, kotlin: cmdManifestKotlin, maven: cmdManifestMaven, diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 011073fadf..7f8f852ab1 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -11,6 +11,7 @@ export async function convertGradleToFacts({ gradleOpts, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, @@ -22,6 +23,7 @@ export async function convertGradleToFacts({ gradleOpts: string[] ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sidecarAcc?: SidecarAccumulator | undefined verbose: boolean withFiles?: boolean | undefined @@ -35,6 +37,7 @@ export async function convertGradleToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index a7f9c24720..a41769fcfe 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -10,6 +10,7 @@ export async function convertMavenToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, mavenOpts, sidecarAcc, verbose, @@ -21,6 +22,7 @@ export async function convertMavenToFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined mavenOpts: string[] sidecarAcc?: SidecarAccumulator | undefined verbose: boolean @@ -35,6 +37,7 @@ export async function convertMavenToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, verbose, withFiles, diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index a6f4f77acc..649b684441 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -12,6 +12,7 @@ export async function convertSbtToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sbtOpts, sidecarAcc, tmpDir, @@ -24,6 +25,7 @@ export async function convertSbtToFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sbtOpts: string[] sidecarAcc?: SidecarAccumulator | undefined // Caller-owned; see ManifestScriptOptions.tmpDir. @@ -40,6 +42,7 @@ export async function convertSbtToFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, tmpDir, verbose, diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts new file mode 100644 index 0000000000..cee91b543d --- /dev/null +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -0,0 +1,134 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import { globWithGitIgnore } from '../../utils/glob.mts' +import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +const BUILD_TOOLS: BuildTool[] = ['gradle', 'maven', 'sbt'] + +// One glob-suffix set per ecosystem's build-descriptor marker(s); `settings.gradle(.kts)` +// covers Kotlin-DSL roots that have no root `build.gradle`. +const MARKERS_BY_TOOL: Record = { + __proto__: null, + gradle: [ + 'build.gradle', + 'build.gradle.kts', + 'settings.gradle', + 'settings.gradle.kts', + ], + maven: ['pom.xml'], + sbt: ['build.sbt'], +} as unknown as Record + +const TOOL_BY_MARKER: Record = { + __proto__: null, +} as unknown as Record +for (const tool of BUILD_TOOLS) { + for (const marker of MARKERS_BY_TOOL[tool]) { + TOOL_BY_MARKER[marker] = tool + } +} + +// A disabled ecosystem is skipped entirely by the glob scan below, so a +// nested socket.json re-enabling it would never even be searched for. Callers +// that need the cascade (not just the root file) to decide skip vs. include +// must strip `disabled` here first, and let their own per-directory cascade +// check apply it instead. +export function withoutDisabledFlags(sockJson: SocketJson): SocketJson { + const manifest = sockJson.defaults?.manifest + if (!manifest) { + return sockJson + } + const stripped: Record = { ...manifest } + for (const tool of BUILD_TOOLS) { + const section = manifest[tool] + if (section?.disabled) { + stripped[tool] = { ...section, disabled: false } + } + } + return { + ...sockJson, + defaults: { ...sockJson.defaults, manifest: stripped }, + } as SocketJson +} + +export async function realpathOrResolved(dir: string): Promise { + try { + return await fs.realpath(dir) + } catch { + return path.resolve(dir) + } +} + +function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] { + return [...dirs].sort((a, b) => { + const relA = path.relative(cwd, a) + const relB = path.relative(cwd, b) + const depthA = relA === '' ? 0 : relA.split(path.sep).length + const depthB = relB === '' ? 0 : relB.split(path.sep).length + if (depthA !== depthB) { + return depthA - depthB + } + return relA < relB ? -1 : relA > relB ? 1 : 0 + }) +} + +// Depth-sorted (root-most first) so the caller, using each build root's +// `subprojectDir` facts, visits a reactor root before its own members and can +// skip subprojects a parent root already covers. +export async function findBuildToolCandidates({ + cwd, + excludePaths, + sockJson, +}: { + cwd: string + excludePaths?: string[] | undefined + sockJson: SocketJson +}): Promise> { + const enabledTools = BUILD_TOOLS.filter( + tool => !sockJson.defaults?.manifest?.[tool]?.disabled, + ) + const result = new Map() + if (!enabledTools.length) { + return result + } + + const patterns = enabledTools.flatMap(tool => + MARKERS_BY_TOOL[tool].map(marker => `**/${marker}`), + ) + const additionalIgnores = (excludePaths ?? []).flatMap( + excludePathToScanIgnores, + ) + const hits = await globWithGitIgnore(patterns, { + absolute: true, + additionalIgnores, + cwd, + }) + + const dirSetsByTool = new Map>( + enabledTools.map(tool => [tool, new Set()]), + ) + const resolvedHits = await Promise.all( + hits.map(async hit => ({ + dir: await realpathOrResolved(path.dirname(hit)), + tool: TOOL_BY_MARKER[path.basename(hit)], + })), + ) + for (const { dir, tool } of resolvedHits) { + if (tool) { + dirSetsByTool.get(tool)?.add(dir) + } + } + + const realCwd = await realpathOrResolved(cwd) + for (const tool of enabledTools) { + result.set( + tool, + sortByDepthThenPath([...(dirSetsByTool.get(tool) ?? [])], realCwd), + ) + } + return result +} diff --git a/src/commands/manifest/discover-manifest-roots.test.mts b/src/commands/manifest/discover-manifest-roots.test.mts new file mode 100644 index 0000000000..206e988d24 --- /dev/null +++ b/src/commands/manifest/discover-manifest-roots.test.mts @@ -0,0 +1,104 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { testPath } from '../../../test/utils.mts' + +import type { SocketJson } from '../../utils/socket-json.mts' + +const monorepo = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', +) + +function relDirs(dirs: string[]): string[] { + return dirs.map(d => path.relative(monorepo, d).replaceAll('\\', '/')).sort() +} + +describe('findBuildToolCandidates', () => { + it('discovers maven, gradle, and sbt candidates in depth order', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + + expect(relDirs(candidates.get('maven') ?? [])).toEqual( + [ + 'dual-marker-dir', + 'reactor', + 'reactor/moduleA', + 'reactor/moduleB', + 'reactor/moduleB/independent-submodule', + ].sort(), + ) + expect(relDirs(candidates.get('gradle') ?? [])).toEqual( + [ + 'dual-marker-dir', + 'reactor/moduleA/nested-gradle', + 'standalone-gradle', + ].sort(), + ) + expect(candidates.get('sbt') ?? []).toEqual([]) + }) + + it('lists a reactor root before its own members (depth-ascending)', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + const maven = candidates.get('maven') ?? [] + const rootIndex = maven.findIndex(d => d === path.join(monorepo, 'reactor')) + const moduleAIndex = maven.findIndex( + d => d === path.join(monorepo, 'reactor/moduleA'), + ) + expect(rootIndex).toBeGreaterThanOrEqual(0) + expect(moduleAIndex).toBeGreaterThan(rootIndex) + }) + + it('includes a gradle project nested inside a maven candidate directory tree', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + expect(candidates.get('gradle')).toContain( + path.join(monorepo, 'reactor/moduleA/nested-gradle'), + ) + }) + + it('lists a dual-marker directory on both ecosystems', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: {} as SocketJson, + }) + const dualDir = path.join(monorepo, 'dual-marker-dir') + expect(candidates.get('maven')).toContain(dualDir) + expect(candidates.get('gradle')).toContain(dualDir) + }) + + it('drops a disabled ecosystem entirely', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + sockJson: { + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson, + }) + expect(candidates.has('maven')).toBe(false) + expect(candidates.get('gradle')?.length).toBeGreaterThan(0) + }) + + it('respects --exclude-paths', async () => { + const candidates = await findBuildToolCandidates({ + cwd: monorepo, + excludePaths: ['reactor'], + sockJson: {} as SocketJson, + }) + expect(candidates.get('maven')).toEqual([ + path.join(monorepo, 'dual-marker-dir'), + ]) + expect(candidates.get('gradle')).toEqual([ + path.join(monorepo, 'dual-marker-dir'), + path.join(monorepo, 'standalone-gradle'), + ]) + }) +}) diff --git a/src/commands/manifest/enumerate-workspaces.mts b/src/commands/manifest/enumerate-workspaces.mts new file mode 100644 index 0000000000..4525291d22 --- /dev/null +++ b/src/commands/manifest/enumerate-workspaces.mts @@ -0,0 +1,86 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' +import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' +import { getErrorMessageOr } from '../../utils/errors.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketFactsSbomProject } from './scripts/facts.mts' + +export type EnumerateWorkspacesResult = { + projects: SocketFactsSbomProject[] +} + +// Cheap subproject discovery (no dependency resolution) for +// `socket manifest setup --dynamic-sbom-inference`; dynamic-sbom-inference +// itself instead gets this list for free as a side effect of its own full +// facts run. +export async function enumerateWorkspaces({ + bin, + buildOpts, + cwd, + ecosystem, + excludePaths, + javaHome, + verbose, +}: { + bin: string + buildOpts: string[] + cwd: string + ecosystem: BuildTool + excludePaths?: string[] | undefined + javaHome?: string | undefined + verbose: boolean +}): Promise { + let resolvedJavaHome: string | undefined + if (javaHome) { + const expanded = expandEnvVarRefs(javaHome) + if (expanded.missing) { + process.exitCode = 1 + logger.fail( + `javaHome (\`${javaHome}\`) ${formatMissingEnvVarRefs(expanded.missing)}.`, + ) + return + } + resolvedJavaHome = expanded.value + } + + const scriptOpts = { + bin: bin || undefined, + excludePaths: excludePaths?.length ? excludePaths : undefined, + // `env` replaces the spawned process's whole environment, not just JAVA_HOME. + env: resolvedJavaHome + ? { ...process.env, JAVA_HOME: resolvedJavaHome } + : undefined, + projectDir: cwd, + stdio: verbose ? ('inherit' as const) : ('pipe' as const), + toolOpts: buildOpts, + } + + let result + try { + result = await enumerateWorkspacesScript(ecosystem, scriptOpts) + } catch (e) { + process.exitCode = 1 + logger.fail( + `Could not run the ${ecosystem} build tool` + + (verbose + ? `: ${getErrorMessageOr(e, String(e))}` + : ' (run with --verbose for details).'), + ) + return + } + + if (!result.projects.length) { + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build produced no workspace records (exit code ${result.code}); every build reports at least its own root project, so the enumeration task did not run.`, + ) + return + } + + return { projects: result.projects } +} diff --git a/src/commands/manifest/enumerate-workspaces.test.mts b/src/commands/manifest/enumerate-workspaces.test.mts new file mode 100644 index 0000000000..6d6735f8dd --- /dev/null +++ b/src/commands/manifest/enumerate-workspaces.test.mts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./scripts/run.mts', () => ({ + enumerateWorkspaces: vi.fn(), +})) + +import { enumerateWorkspaces } from './enumerate-workspaces.mts' +import { enumerateWorkspaces as enumerateWorkspacesScript } from './scripts/run.mts' + +import type { WorkspaceEnumerationResult } from './scripts/run.mts' + +const ENV_VAR = 'SOCKET_TEST_ENUMERATE_JAVA_HOME' + +function okResult(): WorkspaceEnumerationResult { + return { + code: 0, + projects: [ + { + type: 'maven', + name: 'root', + subprojectDir: '.', + dependencies: [], + resolvedAs: [], + }, + ], + stderr: '', + stdout: '', + } +} + +const baseArgs = { + bin: 'gradle', + buildOpts: [], + cwd: '/tmp/some-project', + ecosystem: 'gradle' as const, + verbose: false, +} + +describe('enumerateWorkspaces', () => { + beforeEach(() => { + vi.mocked(enumerateWorkspacesScript).mockReset() + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + + it('returns the projects from a successful run', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + const result = await enumerateWorkspaces(baseArgs) + expect(result?.projects).toEqual(okResult().projects) + }) + + it('passes a literal javaHome straight through as JAVA_HOME', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces({ ...baseArgs, javaHome: '/opt/jdk-17' }) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') + }) + + it('expands $VAR and ${VAR} references against the CLI process env', async () => { + process.env[ENV_VAR] = '/opt/jdk-11' + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces({ ...baseArgs, javaHome: `\${${ENV_VAR}}` }) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-11') + delete process.env[ENV_VAR] + }) + + it('fails closed without invoking the build tool when the referenced var is unset', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + const result = await enumerateWorkspaces({ + ...baseArgs, + javaHome: `$${ENV_VAR}`, + }) + expect(result).toBeUndefined() + expect(enumerateWorkspacesScript).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('leaves the environment untouched when javaHome is unset', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult()) + await enumerateWorkspaces(baseArgs) + const opts = vi.mocked(enumerateWorkspacesScript).mock.calls[0]?.[1] + expect(opts?.env).toBeUndefined() + }) + + it('fails when the build crashed before producing any workspace records', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + code: 1, + projects: [], + stderr: '', + stdout: '', + }) + const result = await enumerateWorkspaces(baseArgs) + expect(result).toBeUndefined() + expect(process.exitCode).toBe(1) + }) + + it('fails even on a clean exit if no workspace records were produced - every real run reports at least its own root project', async () => { + vi.mocked(enumerateWorkspacesScript).mockResolvedValue({ + code: 0, + projects: [], + stderr: '', + stdout: '', + }) + const result = await enumerateWorkspaces(baseArgs) + expect(result).toBeUndefined() + expect(process.exitCode).toBe(1) + }) +}) diff --git a/src/commands/manifest/expand-env-var-refs.mts b/src/commands/manifest/expand-env-var-refs.mts new file mode 100644 index 0000000000..c72ef53b3d --- /dev/null +++ b/src/commands/manifest/expand-env-var-refs.mts @@ -0,0 +1,33 @@ +const ENV_VAR_REF = /\$\$|\$\{(\w+)\}|\$(\w+)/g + +// Expands `$VAR`/`${VAR}` references (e.g. a team-shared `javaHome: +// "$JAVA11_HOME"`) against the CLI process's own environment, so a socket.json +// value works across machines instead of hardcoding one developer's path. +// `$$` is a literal `$`, for a value that must contain a literal `$WORD`. +export function expandEnvVarRefs(value: string): { + missing?: string[] + value: string +} { + const missing: string[] = [] + const expanded = value.replace(ENV_VAR_REF, (match, braced, bare) => { + if (match === '$$') { + return '$' + } + const name = braced ?? bare + const resolved = process.env[name] + if (resolved === undefined) { + if (!missing.includes(name)) { + missing.push(name) + } + return '' + } + return resolved + }) + return missing.length ? { missing, value: expanded } : { value: expanded } +} + +export function formatMissingEnvVarRefs(missing: readonly string[]): string { + const names = missing.map(name => `\`${name}\``).join(', ') + const verb = missing.length > 1 ? 'are' : 'is' + return `references ${names}, which ${verb} not set in this environment` +} diff --git a/src/commands/manifest/expand-env-var-refs.test.mts b/src/commands/manifest/expand-env-var-refs.test.mts new file mode 100644 index 0000000000..f2db002784 --- /dev/null +++ b/src/commands/manifest/expand-env-var-refs.test.mts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' + +const VAR_A = 'SOCKET_TEST_EXPAND_ENV_VAR_A' +const VAR_B = 'SOCKET_TEST_EXPAND_ENV_VAR_B' + +describe('expandEnvVarRefs', () => { + beforeEach(() => { + delete process.env[VAR_A] + delete process.env[VAR_B] + }) + afterEach(() => { + delete process.env[VAR_A] + delete process.env[VAR_B] + }) + + it('expands a bare $VAR reference', () => { + process.env[VAR_A] = '/opt/jdk-17' + expect(expandEnvVarRefs(`$${VAR_A}`)).toEqual({ value: '/opt/jdk-17' }) + }) + + it('expands a braced ${VAR} reference', () => { + process.env[VAR_A] = '/opt/jdk-17' + expect(expandEnvVarRefs(`\${${VAR_A}}`)).toEqual({ value: '/opt/jdk-17' }) + }) + + it('reports every distinct missing variable, not just the first', () => { + const result = expandEnvVarRefs(`$${VAR_A}/$${VAR_B}`) + expect(result.missing).toEqual([VAR_A, VAR_B]) + expect(result.value).toBe('/') + }) + + it('reports a missing variable only once even if referenced twice', () => { + const result = expandEnvVarRefs(`$${VAR_A}:$${VAR_A}`) + expect(result.missing).toEqual([VAR_A]) + }) + + it('treats $$ as an escaped literal $, leaving the following text untouched', () => { + expect(expandEnvVarRefs('$$HOME')).toEqual({ value: '$HOME' }) + }) + + it('treats $$ as an escaped literal $ ahead of a braced-looking reference', () => { + expect(expandEnvVarRefs('$${HOME}')).toEqual({ value: '${HOME}' }) + }) + + it('does not report a missing variable for an escaped $$ reference', () => { + expect(expandEnvVarRefs(`$$${VAR_A}`).missing).toBeUndefined() + }) +}) + +describe('formatMissingEnvVarRefs', () => { + it('uses singular wording for one missing variable', () => { + expect(formatMissingEnvVarRefs(['FOO'])).toBe( + 'references `FOO`, which is not set in this environment', + ) + }) + + it('uses plural wording for multiple missing variables', () => { + expect(formatMissingEnvVarRefs(['FOO', 'BAR'])).toBe( + 'references `FOO`, `BAR`, which are not set in this environment', + ) + }) +}) diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts new file mode 100644 index 0000000000..979184f604 --- /dev/null +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -0,0 +1,261 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { + findBuildToolCandidates, + realpathOrResolved, + withoutDisabledFlags, +} from './discover-manifest-roots.mts' +import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { runManifestFacts } from './run-manifest-facts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, +} from '../../utils/socket-json.mts' +import { projectIgnorePathsToReachExcludePaths } from '../scan/exclude-paths.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +export type RecursiveManifestOutcomeStatus = + | 'aborted' + | 'empty' + | 'failed' + | 'generated' + | 'skippedCovered' + | 'skippedDisabled' + +export type RecursiveManifestOutcome = { + dir: string + ecosystem: BuildTool + factsPath?: string | undefined + status: RecursiveManifestOutcomeStatus +} + +export type EcosystemBuildConfig = { + bin: string + buildOpts: string[] + excludeConfigs: string + ignoreUnresolved: boolean + includeConfigs: string + javaHome: string | undefined + // Set when this build root should be skipped entirely (never invoked). + skipReason: string | undefined +} + +// A cascaded (not just root-level) disabled additionally skips this one build +// root here, on top of its existing root-only ecosystem-wide meaning for +// auto/gradle/etc. facts:false has no pom-mode equivalent here (facts-only), +// so it skips too. +function getSkipReason( + disabled: boolean | undefined, + facts?: boolean | undefined, +): string | undefined { + if (disabled) { + return 'defaults.manifest..disabled is true' + } + if (facts === false) { + return 'defaults.manifest..facts is false (pom mode)' + } + return undefined +} + +type DisabledRoot = { dir: string; sockJson: SocketJson } + +// Nearest already-confirmed-disabled ancestor of `dir`, if any - lets the +// caller shorten `readSocketJsonCascade`'s walk to start there instead of +// all the way back at `cwd`, without skipping any nested override. +function nearestDisabledRoot( + dir: string, + disabledRoots: readonly DisabledRoot[], +): DisabledRoot | undefined { + let nearest: DisabledRoot | undefined + for (const root of disabledRoots) { + if ( + dir.startsWith(`${root.dir}${path.sep}`) && + (!nearest || root.dir.length > nearest.dir.length) + ) { + nearest = root + } + } + return nearest +} + +// A wrapper-preferred `bin` default is resolved per-root (`dir`, not `cwd`) +// since a wrapper script only exists at the actual build root. Exported for +// reuse by the recursive setup wizard's reactor-coverage pruning. +export function resolveEcosystemConfig( + ecosystem: BuildTool, + dir: string, + sockJson: SocketJson, +): EcosystemBuildConfig { + if (ecosystem === 'sbt') { + const config = sockJson.defaults?.manifest?.sbt + const bin = config?.bin ?? undefined + return { + bin: bin ?? 'sbt', + buildOpts: parseBuildToolOpts(config?.sbtOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled, config?.facts), + } + } + if (ecosystem === 'gradle') { + const config = sockJson.defaults?.manifest?.gradle + const bin = config?.bin ?? undefined + return { + bin: bin ? path.resolve(dir, bin) : resolveBuildToolBin('gradle', dir), + buildOpts: parseBuildToolOpts(config?.gradleOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled, config?.facts), + } + } + const config = sockJson.defaults?.manifest?.maven + const bin = config?.bin ?? undefined + return { + bin: bin ?? resolveBuildToolBin('maven', dir), + buildOpts: parseBuildToolOpts(config?.mavenOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: config?.javaHome ?? undefined, + skipReason: getSkipReason(config?.disabled), + } +} + +// Generates one .socket.facts.json per independent gradle/sbt/maven build +// root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's +// own projects[].subprojectDir, not by pruning the whole discovered subtree, +// so an unrelated nested project a reactor doesn't declare still gets its +// own invocation. Fail-closed per ecosystem, not globally: a root whose +// workspace layout can't be determined aborts only that ecosystem's own +// remaining walk (marking its untried candidates 'aborted'), since coverage +// is tracked per ecosystem and an unrelated one has nothing to lose from it. +export async function generateRecursiveManifests({ + cwd, + excludePaths, + verbose, +}: { + cwd: string + excludePaths?: string[] | undefined + verbose: boolean +}): Promise { + const rootSockJson = readOrDefaultSocketJson(cwd) + // Candidate dirs come back realpath-resolved (findBuildToolCandidates); cwd + // must match or every boundary/relative-path comparison below breaks as + // soon as cwd contains a symlink (macOS /tmp -> /private/tmp, etc.). + const realCwd = await realpathOrResolved(cwd) + // A root-disabled ecosystem must still be scanned for - a nested socket.json + // may re-enable it - so the per-directory cascade below, not this scan, is + // what actually decides skip vs. include. + const candidatesByTool = await findBuildToolCandidates({ + cwd, + excludePaths, + sockJson: withoutDisabledFlags(rootSockJson), + }) + + const outcomes: RecursiveManifestOutcome[] = [] + for (const [ecosystem, dirs] of candidatesByTool) { + const covered = new Set() + const disabledRoots: DisabledRoot[] = [] + for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) { + const dir = dirs[dirIndex] as string + if (covered.has(dir)) { + outcomes.push({ dir, ecosystem, status: 'skippedCovered' }) + continue + } + + const nearestRoot = nearestDisabledRoot(dir, disabledRoots) + const sockJson = nearestRoot + ? readSocketJsonCascade(dir, nearestRoot.dir, nearestRoot.sockJson) + : readSocketJsonCascade(dir, realCwd, rootSockJson) + const { + bin, + buildOpts, + excludeConfigs, + ignoreUnresolved, + includeConfigs, + javaHome, + skipReason, + } = resolveEcosystemConfig(ecosystem, dir, sockJson) + + if (skipReason) { + // Only warn for a genuinely new disabled root, not one already + // covered by an ancestor's warning above - otherwise a big disabled + // reactor with hundreds of nested poms would spam one warning line + // per pom for what's really a single root cause. The aggregate + // count still shows up in the final summary either way. + if (!nearestRoot) { + logger.warn(`Skipping ${dir} (${ecosystem}): ${skipReason}.`) + } + outcomes.push({ dir, ecosystem, status: 'skippedDisabled' }) + disabledRoots.push({ dir, sockJson }) + continue + } + + const excludePathsForRoot = projectIgnorePathsToReachExcludePaths( + excludePaths, + { cwd: realCwd, target: dir }, + ) + + // eslint-disable-next-line no-await-in-loop + const result = await runManifestFacts({ + bin, + buildOpts, + cwd: dir, + ecosystem, + excludeConfigs, + excludePaths: excludePathsForRoot, + ignoreUnresolved, + includeConfigs, + javaHome, + verbose, + }) + + if (result === null) { + outcomes.push({ dir, ecosystem, status: 'failed' }) + logger.warn( + `Aborting ${ecosystem} discovery: ${dir}'s workspace layout could not be determined, so its remaining build roots cannot be safely classified as covered or independent.`, + ) + for (const abortedDir of dirs.slice(dirIndex + 1)) { + outcomes.push({ dir: abortedDir, ecosystem, status: 'aborted' }) + } + break + } + if (!result) { + outcomes.push({ dir, ecosystem, status: 'empty' }) + continue + } + + covered.add(dir) + // eslint-disable-next-line no-await-in-loop + const resolvedSubprojectDirs = await Promise.all( + result.projects.map(project => + realpathOrResolved(path.resolve(dir, project.subprojectDir)), + ), + ) + for (const subprojectDir of resolvedSubprojectDirs) { + covered.add(subprojectDir) + } + outcomes.push({ + dir, + ecosystem, + factsPath: result.factsPath, + status: 'generated', + }) + } + } + + if (verbose) { + logger.info(`Discovered ${outcomes.length} build-tool candidate(s).`) + } + + return outcomes +} diff --git a/src/commands/manifest/generate-recursive-manifests.test.mts b/src/commands/manifest/generate-recursive-manifests.test.mts new file mode 100644 index 0000000000..8f4cb97ca1 --- /dev/null +++ b/src/commands/manifest/generate-recursive-manifests.test.mts @@ -0,0 +1,544 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { logger } from '@socketsecurity/registry/lib/logger' + +vi.mock('../../utils/socket-json.mts', () => ({ + readOrDefaultSocketJson: vi.fn(() => ({})), + // Default: no per-root override found, fall back to the root config - matches + // there being no nested socket.json anywhere in the fixture tree. + readSocketJsonCascade: vi.fn((_dir, _boundaryDir, fallback) => fallback), +})) +vi.mock('./run-manifest-facts.mts', () => ({ + runManifestFacts: vi.fn(), +})) + +import { generateRecursiveManifests } from './generate-recursive-manifests.mts' +import { runManifestFacts } from './run-manifest-facts.mts' +import { testPath } from '../../../test/utils.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, +} from '../../utils/socket-json.mts' + +import type { SocketJson } from '../../utils/socket-json.mts' + +const monorepo = path.join( + testPath, + 'fixtures/commands/manifest/dynamic-sbom-inference/monorepo', +) +const reactor = path.join(monorepo, 'reactor') +const dualMarkerDir = path.join(monorepo, 'dual-marker-dir') + +function relOf(dir: string): string { + return path.relative(monorepo, dir).replaceAll('\\', '/') +} + +describe('generateRecursiveManifests', () => { + beforeEach(() => { + vi.mocked(runManifestFacts).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) + vi.mocked(readSocketJsonCascade).mockImplementation( + (_dir, _boundaryDir, fallback) => fallback, + ) + }) + afterEach(() => { + process.exitCode = undefined + }) + + it('invokes the reactor root once and skips its declared members, but still visits an undeclared nested submodule and a differently-tooled nested project', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (cwd === reactor && ecosystem === 'maven') { + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'moduleB', + subprojectDir: 'moduleB', + dependencies: [], + resolvedAs: [], + }, + ], + } + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const calledDirs = vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => `${opts.ecosystem}:${relOf(opts.cwd)}`) + .sort() + expect(calledDirs).toEqual( + [ + 'gradle:dual-marker-dir', + 'gradle:reactor/moduleA/nested-gradle', + 'gradle:standalone-gradle', + 'maven:dual-marker-dir', + 'maven:reactor', + 'maven:reactor/moduleB/independent-submodule', + ].sort(), + ) + + const skipped = outcomes + .filter(o => o.status === 'skippedCovered') + .map(o => `${o.ecosystem}:${relOf(o.dir)}`) + .sort() + expect(skipped).toEqual( + ['maven:reactor/moduleA', 'maven:reactor/moduleB'].sort(), + ) + }) + + it("runs both ecosystems unconditionally at a dual-marker directory (matches auto's existing behavior)", async () => { + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const atDualMarkerDir = outcomes.filter(o => o.dir === dualMarkerDir) + expect(atDualMarkerDir.map(o => o.ecosystem).sort()).toEqual([ + 'gradle', + 'maven', + ]) + expect(atDualMarkerDir.every(o => o.status === 'generated')).toBe(true) + }) + + it('aborts only the failing ecosystem (fail-closed), marking its remaining candidates aborted, while an unrelated ecosystem proceeds normally', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + process.exitCode = 1 + return null + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('failed') + // Without dual-marker-dir's own projects[], reactor's still-undiscovered + // members can't be safely told apart from independent projects - so + // nothing else in the maven ecosystem gets attempted, but that's now + // reported explicitly rather than silently omitted. + expect(byKey.get('maven:reactor')).toBe('aborted') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'aborted', + ) + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => opts.ecosystem === 'maven' && opts.cwd === reactor, + ), + ).toBe(false) + // Coverage is tracked per ecosystem, so maven's failure has nothing to + // do with gradle's - it still runs to completion at the same directory. + expect(byKey.get('gradle:dual-marker-dir')).toBe('generated') + expect( + warnSpy.mock.calls.some(c => + /Aborting maven discovery/.test(String(c[0])), + ), + ).toBe(true) + } finally { + warnSpy.mockRestore() + } + }) + + it('still classifies a failure as failed (not empty) when process.exitCode was already non-zero beforehand', async () => { + process.exitCode = 1 + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + process.exitCode = 1 + return null + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('failed') + } finally { + warnSpy.mockRestore() + } + }) + + it('reports a non-fatal empty result distinctly from a failure', async () => { + vi.mocked(runManifestFacts).mockImplementation( + async ({ cwd, ecosystem }) => { + if (ecosystem === 'maven' && cwd === dualMarkerDir) { + // No resolvable dependencies; runManifestFacts warns but does not fail. + return undefined + } + return { factsPath: path.join(cwd, '.socket.facts.json'), projects: [] } + }, + ) + + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:dual-marker-dir')).toBe('empty') + }) + + it('resolves each build root its own nearest socket.json instead of only the root config', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + + const javaHomeByCall = new Map( + vi + .mocked(runManifestFacts) + .mock.calls.map(([opts]) => [ + `${opts.ecosystem}:${relOf(opts.cwd)}`, + opts.javaHome, + ]), + ) + expect(javaHomeByCall.get('maven:dual-marker-dir')).toBe('/opt/jdk-11') + expect(javaHomeByCall.get('gradle:dual-marker-dir')).toBeUndefined() + expect(javaHomeByCall.get('maven:reactor')).toBeUndefined() + }) + + it('resolves an explicit null override back to the no-restriction default, not a literal null', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { + defaults: { + manifest: { maven: { excludeConfigs: null, javaHome: null } }, + }, + } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + + await generateRecursiveManifests({ cwd: monorepo, verbose: false }) + + const call = vi + .mocked(runManifestFacts) + .mock.calls.find( + ([opts]) => opts.cwd === dualMarkerDir && opts.ecosystem === 'maven', + ) + expect(call?.[0].excludeConfigs).toBe('') + expect(call?.[0].javaHome).toBeUndefined() + }) + + it('skips (with a warning) a resolved config that sets facts: false, never invoking the build tool', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === dualMarkerDir + ? { defaults: { manifest: { gradle: { facts: false } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(warned).toMatch(/facts is false/) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('gradle:dual-marker-dir')).toBe('skippedDisabled') + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => + opts.cwd === dualMarkerDir && opts.ecosystem === 'gradle', + ), + ).toBe(false) + } finally { + warnSpy.mockRestore() + } + }) + + it('shortens the cascade walk for a nested candidate under an already-disabled root, instead of re-walking from cwd', async () => { + const independentSubmodule = path.join( + reactor, + 'moduleB', + 'independent-submodule', + ) + // Maven-only candidates nested under reactor; excludes the differently- + // tooled reactor/moduleA/nested-gradle, which is its own gradle-ecosystem + // candidate never marked disabled and correctly still walks from cwd. + const nestedMavenDirs = new Set([ + path.join(reactor, 'moduleA'), + path.join(reactor, 'moduleB'), + independentSubmodule, + ]) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, boundaryDir, fallback) => { + if (dir === reactor && boundaryDir === monorepo) { + // The one full walk: reactor's own socket.json disables maven. + return { defaults: { manifest: { maven: { disabled: true } } } } + } + // Every other maven candidate nested under reactor must use a + // boundary nearer than the overall recursion root - never re-walk + // all the way back to monorepo/rootSockJson once an ancestor is + // already confirmed disabled. + if (nestedMavenDirs.has(dir)) { + expect(boundaryDir).not.toBe(monorepo) + } + return fallback + }, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'skippedDisabled', + ) + expect( + vi + .mocked(runManifestFacts) + .mock.calls.some( + ([opts]) => + opts.ecosystem === 'maven' && + opts.cwd.startsWith(reactor) && + opts.cwd !== independentSubmodule, + ), + ).toBe(false) + // Four maven candidates end up skippedDisabled (reactor + moduleA + + // moduleB + independent-submodule), but only the root cause should + // warn - otherwise a big disabled reactor spams one line per pom. + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0]?.[0])).toMatch(/disabled is true/) + } finally { + warnSpy.mockRestore() + } + }) + + it('still honors a nested override that re-enables a build root under an otherwise-disabled ancestor', async () => { + const independentSubmodule = path.join( + reactor, + 'moduleB', + 'independent-submodule', + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => { + if (dir === reactor) { + return { defaults: { manifest: { maven: { disabled: true } } } } + } + if (dir === independentSubmodule) { + // Its own socket.json explicitly clears the inherited disable. + return { defaults: { manifest: { maven: { disabled: false } } } } + } + return fallback + }, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') + expect(byKey.get('maven:reactor/moduleB/independent-submodule')).toBe( + 'generated', + ) + } finally { + warnSpy.mockRestore() + } + }) + + it('still discovers a build root when the root socket.json disables its whole ecosystem, so a nested override can re-enable it', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === reactor + ? { defaults: { manifest: { maven: { disabled: false } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + // Root-disabled maven is still scanned for at all (not dropped + // entirely), so the nested override is actually found and generated. + expect(byKey.get('maven:reactor')).toBe('generated') + expect(byKey.get('maven:dual-marker-dir')).toBe('skippedDisabled') + } finally { + warnSpy.mockRestore() + } + }) + + it('skips (with a warning) a resolved config that sets a cascaded disabled: true', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation( + (dir, _boundaryDir, fallback) => + dir === reactor + ? { defaults: { manifest: { maven: { disabled: true } } } } + : fallback, + ) + vi.mocked(runManifestFacts).mockImplementation(async ({ cwd }) => ({ + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + })) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger) + + try { + const outcomes = await generateRecursiveManifests({ + cwd: monorepo, + verbose: false, + }) + + const warned = warnSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(warned).toMatch(/disabled is true/) + + const byKey = new Map( + outcomes.map(o => [`${o.ecosystem}:${relOf(o.dir)}`, o.status]), + ) + expect(byKey.get('maven:reactor')).toBe('skippedDisabled') + } finally { + warnSpy.mockRestore() + } + }) + + it('bounds the socket.json cascade at a symlinked cwd instead of walking past it to the real filesystem root', async () => { + const actual = await vi.importActual< + typeof import('../../utils/socket-json.mts') + >('../../utils/socket-json.mts') + vi.mocked(readOrDefaultSocketJson).mockImplementation( + actual.readOrDefaultSocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + actual.readSocketJsonCascade, + ) + + const outer = await fs.mkdtemp(path.join(tmpdir(), 'symlink-cascade-')) + const realCwd = path.join(outer, 'real-cwd') + const cwdLink = path.join(outer, 'cwd-link') + const project = path.join(realCwd, 'project') + try { + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(project, 'pom.xml'), '') + // Sits strictly above the intended recursion root - must never be read. + await fs.writeFile( + path.join(outer, 'socket.json'), + JSON.stringify({ + version: 1, + defaults: { manifest: { maven: { bin: 'LEAKED-BIN' } } }, + }), + ) + await fs.symlink(realCwd, cwdLink) + + vi.mocked(runManifestFacts).mockImplementation(async ({ bin, cwd }) => { + expect(bin).not.toBe('LEAKED-BIN') + return { + factsPath: path.join(cwd, '.socket.facts.json'), + projects: [], + } + }) + + const outcomes = await generateRecursiveManifests({ + cwd: cwdLink, + verbose: false, + }) + + expect(outcomes.some(o => o.status === 'generated')).toBe(true) + expect(runManifestFacts).toHaveBeenCalled() + } finally { + await fs.rm(outer, { recursive: true, force: true }) + } + }) +}) diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 96fc74bdd6..947df2983b 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -88,7 +88,9 @@ export async function generateAutoManifest({ // Note: `sbt` is more likely to be resolved against PATH env. bin: sockJson.defaults?.manifest?.sbt?.bin ?? 'sbt', cwd, - sbtOpts: parseBuildToolOpts(sockJson.defaults?.manifest?.sbt?.sbtOpts), + sbtOpts: parseBuildToolOpts( + sockJson.defaults?.manifest?.sbt?.sbtOpts ?? undefined, + ), verbose: Boolean(sockJson.defaults?.manifest?.sbt?.verbose), } // Socket facts is the default; opt into pom generation with @@ -104,6 +106,7 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined, sidecarAcc, tmpDir, withFiles: computeArtifactsSidecar, @@ -129,7 +132,7 @@ export async function generateAutoManifest({ cwd, verbose: Boolean(sockJson.defaults?.manifest?.gradle?.verbose), gradleOpts: parseBuildToolOpts( - sockJson.defaults?.manifest?.gradle?.gradleOpts, + sockJson.defaults?.manifest?.gradle?.gradleOpts ?? undefined, ), } // Socket facts is the default; opt into pom generation with @@ -149,6 +152,7 @@ export async function generateAutoManifest({ ), includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined, sidecarAcc, withFiles: computeArtifactsSidecar, }) @@ -178,8 +182,9 @@ export async function generateAutoManifest({ sockJson.defaults?.manifest?.maven?.ignoreUnresolved, ), includeConfigs: sockJson.defaults?.manifest?.maven?.includeConfigs ?? '', + javaHome: sockJson.defaults?.manifest?.maven?.javaHome ?? undefined, mavenOpts: parseBuildToolOpts( - sockJson.defaults?.manifest?.maven?.mavenOpts, + sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined, ), sidecarAcc, verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose), diff --git a/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..7e46495c3e --- /dev/null +++ b/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,36 @@ +import { generateRecursiveManifests } from './generate-recursive-manifests.mts' +import { outputManifestDynamicSbomInference } from './output-manifest-dynamic-sbom-inference.mts' + +import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mts' +import type { CResult, OutputKind } from '../../types.mts' + +export async function handleManifestDynamicSbomInference({ + cwd, + excludePaths, + outputKind, + verbose, +}: { + cwd: string + excludePaths: string[] + outputKind: OutputKind + verbose: boolean +}): Promise { + const outcomes = await generateRecursiveManifests({ + cwd, + excludePaths, + verbose, + }) + + const result: CResult = outcomes.some( + o => o.status === 'failed', + ) + ? { + ok: false, + code: 1, + message: 'One or more build roots failed to generate Socket facts.', + data: outcomes, + } + : { ok: true, data: outcomes } + + await outputManifestDynamicSbomInference(result, outputKind) +} diff --git a/src/commands/manifest/handle-manifest-setup.mts b/src/commands/manifest/handle-manifest-setup.mts index f4697e67ce..eba05a21da 100644 --- a/src/commands/manifest/handle-manifest-setup.mts +++ b/src/commands/manifest/handle-manifest-setup.mts @@ -1,11 +1,16 @@ import { outputManifestSetup } from './output-manifest-setup.mts' import { setupManifestConfig } from './setup-manifest-config.mts' +import { setupRecursiveManifestConfig } from './setup-recursive-manifest-config.mts' export async function handleManifestSetup( cwd: string, defaultOnReadError: boolean, + dynamicSbomInference = false, + excludePaths?: string[] | undefined, ): Promise { - const result = await setupManifestConfig(cwd, defaultOnReadError) + const result = dynamicSbomInference + ? await setupRecursiveManifestConfig(cwd, defaultOnReadError, excludePaths) + : await setupManifestConfig(cwd, defaultOnReadError) await outputManifestSetup(result) } diff --git a/src/commands/manifest/manifest-flags.mts b/src/commands/manifest/manifest-flags.mts new file mode 100644 index 0000000000..5eab076535 --- /dev/null +++ b/src/commands/manifest/manifest-flags.mts @@ -0,0 +1,13 @@ +import type { MeowFlags } from '../../flags.mts' + +// Manifest-scoped variant of `../scan/reachability-flags.mts`'s +// `excludePathsFlag`: description avoids "scan"/reachability wording since +// these commands only ever generate a manifest/facts file. +export const excludePathsFlag: MeowFlags = { + excludePaths: { + type: 'string', + isMultiple: true, + description: + 'List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (`--cwd` if set): `tests` matches only `/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, +} diff --git a/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts new file mode 100644 index 0000000000..3112b76683 --- /dev/null +++ b/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts @@ -0,0 +1,69 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { serializeResultJson } from '../../utils/serialize-result-json.mts' + +import type { RecursiveManifestOutcome } from './generate-recursive-manifests.mts' +import type { CResult, OutputKind } from '../../types.mts' + +function renderTable(outcomes: readonly RecursiveManifestOutcome[]): string { + // A covered reactor member is implied by its parent's line above it. + return outcomes + .filter(o => o.status !== 'skippedCovered') + .map( + o => + `- ${o.dir} (${o.ecosystem}): ${o.status}${o.factsPath ? ` -> ${o.factsPath}` : ''}`, + ) + .join('\n') +} + +function summarize(outcomes: readonly RecursiveManifestOutcome[]): string { + const generated = outcomes.filter(o => o.status === 'generated').length + return `Generated ${generated} Socket facts file(s).` +} + +export async function outputManifestDynamicSbomInference( + result: CResult, + outputKind: OutputKind, +): Promise { + if (!result.ok) { + process.exitCode = result.code ?? 1 + } + + if (outputKind === 'json') { + logger.log(serializeResultJson(result)) + return + } + + if (!result.ok) { + logger.fail(failMsgWithBadge(result.message, result.cause)) + const data = result.data as RecursiveManifestOutcome[] | undefined + if (Array.isArray(data)) { + const table = renderTable(data) + if (table) { + logger.log(table) + } + logger.log(summarize(data)) + } + return + } + + const table = renderTable(result.data) + + if (outputKind === 'markdown') { + logger.log( + [ + '# Dynamic SBOM inference', + '', + ...(table ? [table, ''] : []), + summarize(result.data), + ].join('\n'), + ) + return + } + + if (table) { + logger.log(table) + } + logger.log(summarize(result.data)) +} diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 42bcc416a2..a3816840f8 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -3,17 +3,28 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' +import { + expandEnvVarRefs, + formatMissingEnvVarRefs, +} from './expand-env-var-refs.mts' import { renderResolutionErrorReport } from './scripts/resolution-report-render.mts' import { runManifestScript } from './scripts/run.mts' import { accumulateSidecar } from './scripts/sidecar.mts' import constants from '../../constants.mts' +import { getErrorMessageOr } from '../../utils/errors.mts' import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketFactsSbomProject } from './scripts/facts.mts' import type { ManifestRunResult } from './scripts/run.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' const MAX_FAILURE_OUTPUT_LINES = 40 +export type RunManifestFactsResult = { + factsPath: string + projects: SocketFactsSbomProject[] +} + // Last N non-empty lines of the captured build output, for diagnosing a crash // without forcing a --verbose rebuild. function tailBuildOutput(stdout: string, stderr: string): string { @@ -24,6 +35,12 @@ function tailBuildOutput(stdout: string, stderr: string): string { return combined.split('\n').slice(-MAX_FAILURE_OUTPUT_LINES).join('\n') } +// `null` = a real failure (crash, missing config, blocking unresolved +// dependency); `undefined` = genuinely nothing to resolve, not a failure. +// Distinguishing the two here means callers never have to infer it from +// `process.exitCode`, which can already be non-zero for an unrelated reason. +export type RunManifestFactsOutcome = RunManifestFactsResult | null | undefined + // Runs the bundled build-tool resolution script for a JVM project and writes // `.socket.facts.json`. `withFiles` (reachability only) additionally folds // resolved artifact paths into `sidecarAcc`. A blocking resolution failure sets @@ -39,6 +56,7 @@ export async function runManifestFacts({ excludePaths, ignoreUnresolved, includeConfigs, + javaHome, sidecarAcc, tmpDir, verbose, @@ -52,15 +70,29 @@ export async function runManifestFacts({ excludePaths?: string[] | undefined ignoreUnresolved: boolean includeConfigs: string + javaHome?: string | undefined sidecarAcc?: SidecarAccumulator | undefined // sbt only; see ManifestScriptOptions.tmpDir. tmpDir?: string | undefined verbose: boolean withFiles?: boolean | undefined -}): Promise { +}): Promise { const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) - logger.log( + let resolvedJavaHome: string | undefined + if (javaHome) { + const expanded = expandEnvVarRefs(javaHome) + if (expanded.missing) { + process.exitCode = 1 + logger.fail( + `javaHome (\`${javaHome}\`) ${formatMissingEnvVarRefs(expanded.missing)}.`, + ) + return null + } + resolvedJavaHome = expanded.value + } + + logger.info( `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, ) @@ -68,6 +100,10 @@ export async function runManifestFacts({ bin: bin || undefined, excludeConfigs: excludeConfigs || undefined, excludePaths: excludePaths?.length ? excludePaths : undefined, + // `env` replaces the spawned process's whole environment, not just JAVA_HOME. + env: resolvedJavaHome + ? { ...process.env, JAVA_HOME: resolvedJavaHome } + : undefined, includeConfigs: includeConfigs || undefined, projectDir: cwd, // Stream the build tool's output only when asked; otherwise capture it and @@ -108,9 +144,11 @@ export async function runManifestFacts({ process.exitCode = 1 logger.fail( `Could not run the ${ecosystem} build tool` + - (verbose ? `: ${e}` : ' (run with --verbose for details).'), + (verbose + ? `: ${getErrorMessageOr(e, String(e))}` + : ' (run with --verbose for details).'), ) - return + return null } const { artifactPaths, code, facts, report, stderr, stdout } = result @@ -128,16 +166,16 @@ export async function runManifestFacts({ process.exitCode = 1 logger.fail(rendered.summary) if (verbose && rendered.details) { - logger.log(rendered.details) + logger.info(rendered.details) } - return + return null } } if (rendered.nonBlockingNotice) { logger.info(rendered.nonBlockingNotice) } if (verbose && rendered.details) { - logger.log(rendered.details) + logger.info(rendered.details) } // A non-zero build exit with no usable output (no graph, no first-party @@ -169,7 +207,7 @@ export async function runManifestFacts({ logger.fail( `The ${ecosystem} build failed (exit code ${code}) before producing any Socket facts.`, ) - return + return null } // Nothing resolved at all — no dependencies and no first-party modules. A @@ -189,4 +227,5 @@ export async function runManifestFacts({ } logger.success('Generated Socket facts') + return { factsPath, projects: facts.projects ?? [] } } diff --git a/src/commands/manifest/run-manifest-facts.test.mts b/src/commands/manifest/run-manifest-facts.test.mts new file mode 100644 index 0000000000..3320707fe7 --- /dev/null +++ b/src/commands/manifest/run-manifest-facts.test.mts @@ -0,0 +1,99 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./scripts/run.mts', () => ({ + runManifestScript: vi.fn(), +})) + +import { runManifestFacts } from './run-manifest-facts.mts' +import { runManifestScript } from './scripts/run.mts' + +import type { ManifestRunResult } from './scripts/run.mts' + +const ENV_VAR = 'SOCKET_TEST_JAVA_HOME' + +function okResult(): ManifestRunResult { + return { + code: 0, + facts: { + components: [{ id: 'a', type: 'maven', name: 'a' }], + projects: [], + }, + report: { failures: [], scannedConfigs: [], unscannable: [] }, + artifactPaths: { + targetsByCoord: new Map(), + targetsByGav: new Map(), + sourcesByCoord: new Map(), + coords: new Set(), + }, + stderr: '', + stdout: '', + } +} + +const baseArgs = { + bin: 'mvn', + buildOpts: [], + ecosystem: 'maven' as const, + excludeConfigs: '', + ignoreUnresolved: false, + includeConfigs: '', + verbose: false, +} + +describe('runManifestFacts - javaHome', () => { + let cwd = '' + + beforeEach(async () => { + cwd = await fs.mkdtemp(path.join(tmpdir(), 'run-manifest-facts-')) + vi.mocked(runManifestScript).mockReset() + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + afterEach(async () => { + await fs.rm(cwd, { recursive: true, force: true }) + delete process.env[ENV_VAR] + process.exitCode = undefined + }) + + it('passes a literal javaHome straight through as JAVA_HOME', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + await runManifestFacts({ ...baseArgs, cwd, javaHome: '/opt/jdk-17' }) + const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-17') + }) + + it('expands $VAR and ${VAR} references against the CLI process env', async () => { + process.env[ENV_VAR] = '/opt/jdk-11' + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + await runManifestFacts({ + ...baseArgs, + cwd, + javaHome: `\${${ENV_VAR}}`, + }) + const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] + expect(opts?.env?.['JAVA_HOME']).toBe('/opt/jdk-11') + }) + + it('fails closed without invoking the build tool when the referenced var is unset', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + const result = await runManifestFacts({ + ...baseArgs, + cwd, + javaHome: `$${ENV_VAR}`, + }) + expect(result).toBeNull() + expect(runManifestScript).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('leaves the environment untouched when javaHome is unset', async () => { + vi.mocked(runManifestScript).mockResolvedValue(okResult()) + await runManifestFacts({ ...baseArgs, cwd }) + const opts = vi.mocked(runManifestScript).mock.calls[0]?.[1] + expect(opts?.env).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java new file mode 100644 index 0000000000..2e73b72d00 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java @@ -0,0 +1,58 @@ +package tech.coana.ext; + +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.rtinfo.RuntimeInformation; +import tech.coana.socket.SocketWorkspacesRecordsEngine; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.util.Properties; + +/** + * Sibling of {@link CoanaFactsLifecycleParticipant}, gated by {@code -Dcoana.task=socket-workspaces}. + * Hooks {@code afterProjectsRead} (fires once the reactor project list is known, before any + * lifecycle phase runs) since it only needs that list, never a dependency graph. + */ +@Named("coana-workspaces") +@Singleton +public class CoanaWorkspacesLifecycleParticipant extends AbstractMavenLifecycleParticipant { + + private final RuntimeInformation runtimeInformation; + + @Inject + public CoanaWorkspacesLifecycleParticipant(RuntimeInformation runtimeInformation) { + this.runtimeInformation = runtimeInformation; + } + + @Override + public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + if (!"socket-workspaces".equals(opt(session, "coana.task"))) { + return; + } + String recordsFile = opt(session, "socket.recordsFile"); + if (recordsFile == null || recordsFile.isEmpty()) { + throw new MavenExecutionException("socket-workspaces requires -Dsocket.recordsFile", new IllegalStateException()); + } + SocketWorkspacesRecordsEngine.Options opts = new SocketWorkspacesRecordsEngine.Options(); + opts.recordsFile = recordsFile; + opts.excludePaths = opt(session, "socket.excludePaths"); + File rootDir = new File(session.getExecutionRootDirectory()); + try { + SocketWorkspacesRecordsEngine.run(session.getProjects(), rootDir, opts, runtimeInformation.getMavenVersion()); + } catch (IOException exception) { + throw new MavenExecutionException("Cannot write socket workspace records", exception); + } + } + + // -D values arrive as both session user-properties and JVM system properties; prefer the former. + private static String opt(MavenSession session, String key) { + Properties user = session.getUserProperties(); + if (user != null && user.getProperty(key) != null) return user.getProperty(key); + return System.getProperty(key); + } +} diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java new file mode 100644 index 0000000000..fb32202274 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java @@ -0,0 +1,61 @@ +package tech.coana.socket; + +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.List; + +/** + * Sibling of {@link SocketFactsRecordsEngine} that emits only `meta`/`project` records from the + * already-populated reactor list, building no dependency graph - cheap workspace discovery for + * `socket manifest setup --dynamic-sbom-inference` without a full facts-generation build. + */ +public final class SocketWorkspacesRecordsEngine { + + public static final class Options { + // Scan-root-relative `--exclude-paths` (CSV): a wholly excluded reactor module is skipped. + public String excludePaths; + public String recordsFile; + } + + private SocketWorkspacesRecordsEngine() {} + + public static void run(List reactor, File rootDir, Options opts, String mavenVersion) + throws IOException { + List excludes = SocketSupport.parseExcludeMatchers(opts.excludePaths); + + List lines = new ArrayList<>(); + rec(lines, "meta", "maven", mavenVersion, System.getProperty("java.version")); + + for (MavenProject module : reactor) { + String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + if (SocketSupport.isExcludedPath(ws, excludes)) continue; + rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + } + + write(opts.recordsFile, lines); + } + + private static void rec(List lines, String... fields) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) sb.append('\t'); + sb.append(SocketSupport.escapeField(fields[i])); + } + lines.add(sb.toString()); + } + + private static void write(String recordsFile, List lines) throws IOException { + File out = new File(recordsFile); + if (out.getParentFile() != null) Files.createDirectories(out.getParentFile().toPath()); + try (PrintWriter writer = new PrintWriter(out, StandardCharsets.UTF_8.name())) { + for (String line : lines) writer.print(line + "\n"); + } + } +} diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index d738cd2e65..898f6f7ff1 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -10,7 +10,11 @@ import constants from '../../../constants.mts' import { withTmpDir } from '../../../utils/fs.mts' import type { BuildTool } from './build-tool.mts' -import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { + ResolvedArtifactPaths, + SocketFactsSbom, + SocketFactsSbomProject, +} from './facts.mts' import type { ResolutionReport } from './resolution-report.mts' export type ManifestScriptOptions = { @@ -54,7 +58,18 @@ export type ManifestRunResult = { type RunOutput = { code: number; stdout: string; stderr: string } const FACTS_TASK = 'socketFacts' -const SBT_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' +const WORKSPACES_TASK = 'socketWorkspaces' +// Destination filename inside sbt's isolated plugins/ dir; unrelated to the source filename, which +// is whatever socket-facts.plugin.scala / socket-workspaces.plugin.scala are called on disk. +const SBT_FACTS_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' +const SBT_WORKSPACES_PLUGIN_FILENAME = 'SocketWorkspacesPlugin.scala' + +export type WorkspaceEnumerationResult = { + code: number + projects: SocketFactsSbomProject[] + stderr: string + stdout: string +} // Bundled emitter assets, copied into dist by the rollup build. function manifestScriptsPath(...parts: string[]): string { @@ -105,14 +120,15 @@ async function runNeverThrow( } } -async function writeSbtPlugin(globalBase: string): Promise { - const src = await fs.readFile( - manifestScriptsPath('socket-facts.plugin.scala'), - 'utf8', - ) +async function writeSbtPlugin( + globalBase: string, + sourceFilename: string, + destFilename: string, +): Promise { + const src = await fs.readFile(manifestScriptsPath(sourceFilename), 'utf8') const pluginsDir = path.join(globalBase, 'plugins') await fs.mkdir(pluginsDir, { recursive: true }) - await fs.writeFile(path.join(pluginsDir, SBT_PLUGIN_FILENAME), src) + await fs.writeFile(path.join(pluginsDir, destFilename), src) } async function assembleFromRecords( @@ -160,6 +176,25 @@ export async function runManifestScript( } } +// Emits only `project` records, never resolving dependencies, for discovery +// before committing to a full `runManifestScript` resolution cost. +export async function enumerateWorkspaces( + tool: BuildTool, + opts: ManifestScriptOptions, +): Promise { + const result = await (tool === 'gradle' + ? enumerateGradleWorkspaces(opts) + : tool === 'sbt' + ? enumerateSbtWorkspaces(opts) + : enumerateMavenWorkspaces(opts)) + return { + code: result.code, + projects: result.facts.projects ?? [], + stderr: result.stderr, + stdout: result.stdout, + } +} + function commonProps( opts: ManifestScriptOptions, prefix: '-D' | '-P', @@ -185,11 +220,14 @@ function commonProps( return props } -async function runGradle( +async function invokeGradle( opts: ManifestScriptOptions, + initScriptFilename: string, + task: string, + tmpDirPrefix: string, ): Promise { - const initScript = manifestScriptsPath('socket-facts.init.gradle') - return await withTmpDir('socket-gradle-facts-', async tmp => { + const initScript = manifestScriptsPath(initScriptFilename) + return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('gradle', opts.projectDir, opts.bin) // Disable the configuration cache: the init script's legacy @@ -201,7 +239,7 @@ async function runGradle( `-Psocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-P'), ...(opts.toolOpts ?? []), - FACTS_TASK, + task, '--no-daemon', '--console=plain', ] @@ -210,11 +248,36 @@ async function runGradle( }) } -async function runSbtIn( +async function runGradle( + opts: ManifestScriptOptions, +): Promise { + return await invokeGradle( + opts, + 'socket-facts.init.gradle', + FACTS_TASK, + 'socket-gradle-facts-', + ) +} + +async function enumerateGradleWorkspaces( + opts: ManifestScriptOptions, +): Promise { + return await invokeGradle( + opts, + 'socket-workspaces.init.gradle', + WORKSPACES_TASK, + 'socket-gradle-workspaces-', + ) +} + +async function invokeSbtIn( globalBase: string, opts: ManifestScriptOptions, + pluginSourceFilename: string, + pluginDestFilename: string, + task: string, ): Promise { - await writeSbtPlugin(globalBase) + await writeSbtPlugin(globalBase, pluginSourceFilename, pluginDestFilename) const recordsFile = path.join(globalBase, 'records.tsv') const bin = resolveBuildToolBin('sbt', opts.projectDir, opts.bin) // Fresh per-run global base (not ~/.sbt): sbt executes everything under @@ -241,37 +304,62 @@ async function runSbtIn( ...props, ...(opts.toolOpts ?? []), '--batch', - FACTS_TASK, + task, ] const out = await runNeverThrow(bin, args, opts) return await assembleFromRecords(out, recordsFile) } async function runSbt(opts: ManifestScriptOptions): Promise { + const run = (globalBase: string) => + invokeSbtIn( + globalBase, + opts, + 'socket-facts.plugin.scala', + SBT_FACTS_PLUGIN_FILENAME, + FACTS_TASK, + ) if (opts.tmpDir) { - return await runSbtIn(opts.tmpDir, opts) + return await run(opts.tmpDir) } - return await withTmpDir('socket-sbt-facts-', globalBase => - runSbtIn(globalBase, opts), - ) + return await withTmpDir('socket-sbt-facts-', run) } -async function runMaven( +async function enumerateSbtWorkspaces( opts: ManifestScriptOptions, +): Promise { + const run = (globalBase: string) => + invokeSbtIn( + globalBase, + opts, + 'socket-workspaces.plugin.scala', + SBT_WORKSPACES_PLUGIN_FILENAME, + WORKSPACES_TASK, + ) + if (opts.tmpDir) { + return await run(opts.tmpDir) + } + return await withTmpDir('socket-sbt-workspaces-', run) +} + +async function invokeMaven( + opts: ManifestScriptOptions, + coanaTask: string, + tmpDirPrefix: string, ): Promise { const jarPath = manifestScriptsPath( 'maven-extension', 'coana-maven-extension.jar', ) assertMavenExtensionBuilt(jarPath) - return await withTmpDir('socket-maven-facts-', async tmp => { + return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('maven', opts.projectDir, opts.bin) - // `validate` is the cheapest phase that triggers the afterSessionEnd - // extension; no compile needed (analysis uses configured paths, not classes). + // `validate` is the cheapest phase that triggers the extension; no compile + // needed (analysis uses configured paths, not classes). const props = [ `-Dmaven.ext.class.path=${jarPath}`, - '-Dcoana.task=socket-facts', + `-Dcoana.task=${coanaTask}`, `-Dsocket.recordsFile=${recordsFile}`, ...commonProps(opts, '-D'), ] @@ -285,3 +373,19 @@ async function runMaven( return await assembleFromRecords(out, recordsFile) }) } + +async function runMaven( + opts: ManifestScriptOptions, +): Promise { + return await invokeMaven(opts, 'socket-facts', 'socket-maven-facts-') +} + +async function enumerateMavenWorkspaces( + opts: ManifestScriptOptions, +): Promise { + return await invokeMaven( + opts, + 'socket-workspaces', + 'socket-maven-workspaces-', + ) +} diff --git a/src/commands/manifest/scripts/socket-workspaces.init.gradle b/src/commands/manifest/scripts/socket-workspaces.init.gradle new file mode 100644 index 0000000000..0691ca12d1 --- /dev/null +++ b/src/commands/manifest/scripts/socket-workspaces.init.gradle @@ -0,0 +1,130 @@ +// Invoke via: +// ./gradlew --init-script socket-workspaces.init.gradle socketWorkspaces + +// Sibling of socket-facts.init.gradle: emits only `meta`/`project` records, no dependency +// resolution. Kept as a separate script so it can't affect that file's already-verified, +// wide Gradle-version compatibility (1.0+). + +// `Project.findProperty` only exists since Gradle 2.13; fall back to hasProperty/property for older Gradle. +gradle.ext.socketProp = { proj, name -> proj.hasProperty(name) ? proj.property(name) : null } + +// `-Psocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each +// entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same +// expansion as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` +// still excludes the `dir` directory itself, not only its contents. Standard glob semantics +// (anchored to the scan root, matching the CLI flag): `x` is root-level, `**/x` matches at any +// depth. Mirrors socket-facts.init.gradle / the sbt / maven producers. +// NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's +// micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` +// occurrences dropped so both semantics hold. +gradle.ext.socketZeroDepthVariants = { String glob -> + def out = new LinkedHashSet() + def work = new ArrayDeque() + work.add(glob) + while (!work.isEmpty()) { + def cur = work.poll() + if (!out.add(cur)) { continue } + int idx = cur.indexOf('**/') + while (idx >= 0) { + if (idx == 0 || cur[idx - 1] == '/') { + def collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (!collapsed.isEmpty()) { work.add(collapsed) } + } + idx = cur.indexOf('**/', idx + 1) + } + } + out +} +gradle.ext.socketExcludeMatchersCache = null +gradle.ext.socketExcludeMatchers = { + if (gradle.ext.socketExcludeMatchersCache != null) return gradle.ext.socketExcludeMatchersCache + def raw = gradle.socketProp.call(gradle.rootProject, 'socket.excludePaths')?.toString() + def matchers = [] + if (raw != null && !raw.trim().isEmpty()) { + def fs = java.nio.file.FileSystems.getDefault() + raw.split(',').each { r -> + def g = r.trim().replace('\\', '/') + while (g.startsWith('/')) { g = g.substring(1) } + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + while (g.endsWith('/**')) { + g = g.substring(0, g.length() - 3) + while (g.endsWith('/')) { g = g.substring(0, g.length() - 1) } + } + if (!g.isEmpty()) { + gradle.ext.socketZeroDepthVariants.call(g).each { v -> + matchers << fs.getPathMatcher('glob:' + v) + matchers << fs.getPathMatcher('glob:' + v + '/**') + } + } + } + } + gradle.ext.socketExcludeMatchersCache = matchers + matchers +} +gradle.ext.socketIsExcluded = { String rel -> + def c = (rel == null ? '' : rel).replace('\\', '/') + while (c.startsWith('./')) { c = c.substring(2) } + while (c.startsWith('/')) { c = c.substring(1) } + while (c.endsWith('/')) { c = c.substring(0, c.length() - 1) } + if (c.isEmpty()) { return false } + def p = java.nio.file.Paths.get(c) + gradle.ext.socketExcludeMatchers.call().any { m -> m.matches(p) } +} + +gradle.ext.socketWorkspacesInfo = Collections.synchronizedList([]) + +// Collect every non-excluded project's identity at configuration time - cheap, no dependency +// resolution, no sourceSets/jar introspection (unlike the full facts script, which also stamps +// sources/targets/artifact-ext for resolution-time use). +gradle.projectsEvaluated { g -> + def rootPath = g.rootProject.projectDir.toPath() + def rel = { java.io.File f -> + def r = rootPath.relativize(f.toPath()).toString().replace(File.separator, '/') + r.isEmpty() ? '.' : r + } + g.rootProject.allprojects.each { p -> + if (g.ext.socketIsExcluded.call(rel(p.projectDir))) { + return + } + g.socketWorkspacesInfo.add([ + path : p.path, + group : (p.group ?: '').toString(), + name : p.name, + version: (p.version ?: '').toString(), + dir : rel(p.projectDir), + ]) + } +} + +rootProject { rp -> + def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() + def defaultRecordsFile = new File(rp.projectDir, '.socket.workspaces.records.tsv').absolutePath + + rp.tasks.create('socketWorkspaces') { + group = 'socket' + description = 'Emits Socket workspace records (project list only; no dependency resolution)' + outputs.upToDateWhen { false } + + doLast { + def esc = { v -> + (v == null ? '' : v.toString()) + .replace('\\', '\\\\').replace('\t', '\\t').replace('\n', '\\n').replace('\r', '\\r') + } + def lines = [] + def rec = { List fields -> lines << fields.collect { esc(it) }.join('\t') } + + rec(['meta', 'gradle', gradle.gradleVersion, System.getProperty('java.version')]) + + def info + synchronized (gradle.socketWorkspacesInfo) { info = new ArrayList(gradle.socketWorkspacesInfo) } + info.each { pi -> + rec(['project', pi.path, pi.group, pi.name, pi.version, pi.dir]) + } + + def outFile = new File(recordsFileOverride ?: defaultRecordsFile) + outFile.parentFile?.mkdirs() + outFile.withWriter('UTF-8') { it.write(lines.join('\n') + '\n') } + println "Socket workspace records written to: ${outFile.absolutePath}" + } + } +} diff --git a/src/commands/manifest/scripts/socket-workspaces.plugin.scala b/src/commands/manifest/scripts/socket-workspaces.plugin.scala new file mode 100644 index 0000000000..6e5b7f998f --- /dev/null +++ b/src/commands/manifest/scripts/socket-workspaces.plugin.scala @@ -0,0 +1,146 @@ +package socket + +import sbt._ +import sbt.Keys._ + +import scala.collection.mutable + +/** + * Sibling of SocketFactsPlugin (socket-facts.plugin.scala): emits only `meta`/`project` + * records, no dependency resolution. Kept as a separate plugin so it can't affect that + * file's already-verified wide sbt-version compatibility (0.13.x+). + * + * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x, same constraint as the facts plugin. + */ +object SocketWorkspacesPlugin extends AutoPlugin { + override def trigger = allRequirements + + object autoImport { + val socketWorkspaces = + taskKey[Unit]("Emit Socket workspace records (project list only; no dependency resolution)") + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + aggregate in socketWorkspaces := false, + socketWorkspaces := { + val st = state.value + val buildRoot = (baseDirectory in ThisBuild).value + + val extracted = Project.extract(st) + val allRefs = extracted.structure.allProjectRefs + + // `-Dsocket.excludePaths` (scan-root-relative globs): a subproject whose dir is wholly excluded + // emits no project record. Mirrors socket-facts.plugin.scala / the gradle / maven producers. + val excludeMatchers = parseExcludeMatchers() + val rootCanonPath = buildRoot.getCanonicalFile.toPath + def relOf(f: File): String = { + val r = rootCanonPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") + if (r.isEmpty) "." else r + } + def isExcludedRef(ref: ProjectRef): Boolean = + isExcludedPath(relOf(extracted.get(baseDirectory.in(ref))), excludeMatchers) + + def rootIdOf(ref: ProjectRef): ModuleID = { + val sv = extracted.get(scalaVersion.in(ref)) + val sbv = extracted.get(scalaBinaryVersion.in(ref)) + CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) + } + + val sb = new StringBuilder + def rec(fields: String*): Unit = { + sb.append(fields.map(esc).mkString("\t")); sb.append('\n') + } + + rec("meta", "sbt", extracted.getOpt(sbtVersion).getOrElse(""), sys.props.getOrElse("java.version", "")) + + allRefs.foreach { ref => + if (!isExcludedRef(ref)) { + val mid = rootIdOf(ref) + val ver = if (mid.revision == null) "" else mid.revision + rec("project", ref.project, mid.organization, mid.name, ver, relOf(extracted.get(baseDirectory.in(ref)))) + } + } + + val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match { + case Some(p) => new File(p) + case None => new File(buildRoot, ".socket.workspaces.records.tsv") + } + Option(recordsFile.getParentFile).foreach(_.mkdirs()) + IO.write(recordsFile, sb.toString) + println("Socket workspace records written to: " + recordsFile.getAbsolutePath) + } + ) + + // ---- config selection / path exclusion (mirrors socket-facts.plugin.scala) ---------------- + + // `-Dsocket.excludePaths` → glob PathMatchers, used only to skip whole excluded subprojects. Each + // entry variant yields the entry itself and `entry/**` so it matches the dir and its subtree (same + // expansion as the SCA ignore path). A trailing `/**` is stripped first, so a user-written `dir/**` + // still excludes the `dir` directory itself, not only its contents. Standard glob semantics + // (anchored to the scan root, matching the CLI flag): `x` is root-level; `**`/`x` matches at any + // depth. Mirrors the gradle/maven producers. + private def parseExcludeMatchers(): Seq[java.nio.file.PathMatcher] = { + sys.props.get("socket.excludePaths").map(_.trim).filter(_.nonEmpty) match { + case None => Nil + case Some(raw) => + val fs = java.nio.file.FileSystems.getDefault + raw.split(",").toSeq.flatMap { r => + var g = r.trim.replace("\\", "/") + while (g.startsWith("/")) g = g.substring(1) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + while (g.endsWith("/**")) { + g = g.substring(0, g.length - 3) + while (g.endsWith("/")) g = g.substring(0, g.length - 1) + } + if (g.isEmpty) Nil + else zeroDepthVariants(g).flatMap { v => + Seq(fs.getPathMatcher("glob:" + v), fs.getPathMatcher("glob:" + v + "/**")) + } + } + } + } + + // NIO glob requires a slash-adjacent `**` to consume at least one path segment, but the CLI's + // micromatch lets it match zero (`**/x` matches root-level `x`). Emit every variant with `**/` + // occurrences dropped so both semantics hold. + private def zeroDepthVariants(glob: String): Seq[String] = { + val out = mutable.LinkedHashSet[String]() + val work = mutable.Queue(glob) + while (work.nonEmpty) { + val cur = work.dequeue() + if (out.add(cur)) { + var idx = cur.indexOf("**/") + while (idx >= 0) { + if (idx == 0 || cur.charAt(idx - 1) == '/') { + val collapsed = cur.substring(0, idx) + cur.substring(idx + 3) + if (collapsed.nonEmpty) work.enqueue(collapsed) + } + idx = cur.indexOf("**/", idx + 1) + } + } + } + out.toSeq + } + + private def isExcludedPath(rel: String, matchers: Seq[java.nio.file.PathMatcher]): Boolean = { + if (matchers.isEmpty) false + else { + var c = (if (rel == null) "" else rel).replace("\\", "/") + while (c.startsWith("./")) c = c.substring(2) + while (c.startsWith("/")) c = c.substring(1) + while (c.endsWith("/")) c = c.substring(0, c.length - 1) + if (c.isEmpty) false + else { + val p = java.nio.file.Paths.get(c) + matchers.exists(_.matches(p)) + } + } + } + + // Backslash-escape so a value can never break line/field framing (see records.ts unescape). + private def esc(v: String): String = { + if (v == null) "" + else v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r") + } +} diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index 572062a1ad..2996366644 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -1,9 +1,12 @@ # JVM manifest-script compatibility tests These exercise the bundled build-tool scripts — the Gradle init script -(`socket-facts.init.gradle`), the sbt plugin (`socket-facts.plugin.scala`), and -the Maven extension (`maven-extension/`) — against a matrix of build-tool -versions, asserting they still emit the expected line-protocol records. +(`socket-facts.init.gradle`), the sbt plugin (`socket-facts.plugin.scala`), the +Maven extension (`maven-extension/`), and each ecosystem's lightweight +workspace-enumeration sibling (`socket-workspaces.init.gradle`, +`socket-workspaces.plugin.scala`, `CoanaWorkspacesLifecycleParticipant`) — +against a matrix of build-tool versions, asserting they still emit the +expected line-protocol records. ## Run locally, on demand diff --git a/src/commands/manifest/scripts/test/gradle-compat/.gitignore b/src/commands/manifest/scripts/test/gradle-compat/.gitignore index a60b93e601..f721b94c92 100644 --- a/src/commands/manifest/scripts/test/gradle-compat/.gitignore +++ b/src/commands/manifest/scripts/test/gradle-compat/.gitignore @@ -1,6 +1,7 @@ # generated at test time project/localrepo/ project/records.tsv +project/workspaces-records.tsv project/.socket.facts.json project/.gradle/ project/build/ diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..7e197d6d3f --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Run socket-workspaces.init.gradle (the lightweight workspace-enumeration sibling of +# socket-facts.init.gradle) against the smoke project and assert it emits exactly the expected +# project record - no dependency resolution, no node/root/file records at all. Guards, across the +# same Gradle compat matrix as the facts script, that the socketWorkspaces task registers and runs +# cleanly. +# +# Usage: smoke-test-workspaces.sh /path/to/gradle +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +GRADLE="${1:?usage: smoke-test-workspaces.sh }" +INIT="$HERE/../../socket-workspaces.init.gradle" +PROJECT="$HERE/project" +GUH="$HERE/.gradle-home" # isolated Gradle user home -> hermetic, no global init scripts +RECORDS="$PROJECT/workspaces-records.tsv" + +rm -rf "$GUH" "$RECORDS" "$PROJECT/.gradle" "$PROJECT/build" + +echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1) (workspaces)" +( cd "$PROJECT" && "$GRADLE" --no-daemon --offline -g "$GUH" \ + --init-script "$INIT" -Psocket.recordsFile="$RECORDS" socketWorkspaces -q ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +errors = [] +tool = None +projects = [] +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects.append(r) + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'gradle': + errors.append(f"meta tool {tool!r} != 'gradle'") +if len(projects) != 1: + errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") +else: + _, path, group, name, _version, dir_ = projects[0] + if path != ':': errors.append(f"root project path {path!r} != ':'") + if group != 'demo': errors.append(f"root project group {group!r} != 'demo'") + if name != 'gradle-compat-smoke': errors.append(f"root project name {name!r} != 'gradle-compat-smoke'") + if dir_ != '.': errors.append(f"root project dir {dir_!r} != '.'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS: tool=gradle; 1 project record, no dependency-resolution records") +PY diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index 1d6ccc626c..8d703e2373 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -1,3 +1,4 @@ project/records.tsv +project/workspaces-records.tsv project/target/ project/*/target/ diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..2c72ee66f5 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Load the Coana Maven workspaces participant (CoanaWorkspacesLifecycleParticipant, the lightweight +# sibling of the facts participant) on a given Maven binary and assert it emits exactly the +# expected reactor project records - no dependency graph at all. Guards, across the same Maven +# compat matrix as the facts extension, that the participant registers and fires correctly. +# +# Usage: smoke-test-workspaces.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-workspaces.sh }" +JAR="${2:?usage: smoke-test-workspaces.sh }" +PROJECT="$HERE/project" +RECORDS="$PROJECT/workspaces-records.tsv" + +rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target + +echo "+ $("$MVN" -v 2>/dev/null | head -1) (workspaces)" +( cd "$PROJECT" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-workspaces \ + "-Dsocket.recordsFile=$RECORDS" \ + validate ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +errors = [] +tool = None +projects = {} +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects[r[3]] = r # keyed by artifactId + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'maven': + errors.append(f"meta tool {tool!r} != 'maven'") +expected = {'root', 'lib', 'app'} +if set(projects) != expected: + errors.append(f"expected reactor modules {sorted(expected)}, got {sorted(projects)}") +for artifact in expected & set(projects): + group = projects[artifact][2] + if group != 'demo': + errors.append(f"{artifact} group {group!r} != 'demo'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS: tool=maven; reactor modules {sorted(projects)}, no dependency-resolution records") +PY diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index e6279e75cd..96390940f8 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -52,6 +52,7 @@ run_gradle() { unzip -q -o "$CACHE/gradle.zip" -d "$CACHE" fi bash "$HERE/gradle-compat/smoke-test.sh" "$dir/bin/gradle" + bash "$HERE/gradle-compat/smoke-test-workspaces.sh" "$dir/bin/gradle" done } @@ -71,6 +72,7 @@ run_maven() { unzip -q -o "$CACHE/maven.zip" -d "$CACHE" fi bash "$HERE/maven-compat/smoke-test.sh" "$dir/bin/mvn" "$jar" + bash "$HERE/maven-compat/smoke-test-workspaces.sh" "$dir/bin/mvn" "$jar" done } @@ -86,6 +88,7 @@ run_sbt() { echo "== sbt $ver / scala $scala (wants JDK $java) ==" use_jdk "$java" bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" + bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala" done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore index 8b58940dc3..4f108a5bb5 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/.gitignore +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -4,4 +4,5 @@ project/project/target/ project/project/build.properties project/scala-version.sbt project/records.tsv +project/workspaces-records.tsv project/target diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh new file mode 100755 index 0000000000..e198d86a56 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run socket-workspaces.plugin.scala (the lightweight workspace-enumeration sibling of +# socket-facts.plugin.scala) against the smoke project on a given sbt version and assert it emits +# exactly the expected project record - no dependency resolution at all. Guards, across the same +# sbt/scala compat matrix as the facts plugin, that SocketWorkspacesPlugin registers and runs. +# +# The plugin is activated exactly as run.ts does it: dropped into a fresh sbt global base's plugins/. +# Usage: smoke-test-workspaces.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-workspaces.sh }" +SCALA_VERSION="${2:?usage: smoke-test-workspaces.sh }" +PLUGIN="$HERE/../../socket-workspaces.plugin.scala" +PROJECT="$HERE/project" +RECORDS="$PROJECT/workspaces-records.tsv" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketWorkspacesPlugin.scala" + +# Pin the sbt + scala versions for this matrix entry (the launcher downloads the sbt version). +# `project/` (the meta-build dir) is an empty dir in git, so it's absent on a fresh checkout. +mkdir -p "$PROJECT/project" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (workspaces)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsocket.recordsFile="$RECORDS" --batch socketWorkspaces ) + +python3 - "$RECORDS" "$SCALA_VERSION" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +# CrossVersion.apply appends _ (major.minor) to the artifact name - the same +# identity computation socket-facts.plugin.scala's rootIdOf already does, so this isn't optional. +binary_version = '.'.join(sys.argv[2].split('.')[:2]) +expected_name = f"sbt-compat-smoke_{binary_version}" +errors = [] +tool = None +projects = [] +for r in rows: + if r[0] == 'meta': + tool = r[1] + elif r[0] == 'project': + projects.append(r) + else: + errors.append(f"unexpected record kind {r[0]!r} - workspaces must never resolve dependencies") + +if tool != 'sbt': + errors.append(f"meta tool {tool!r} != 'sbt'") +if len(projects) != 1: + errors.append(f"expected exactly 1 project record, got {len(projects)}: {projects}") +else: + _, _ref, org, name, _ver, dir_ = projects[0] + if org != 'demo': errors.append(f"root project org {org!r} != 'demo'") + if name != expected_name: errors.append(f"root project name {name!r} != {expected_name!r}") + if dir_ != '.': errors.append(f"root project dir {dir_!r} != '.'") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS: tool=sbt; 1 project record, no dependency-resolution records") +PY diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 1e13a0f90a..ee68a0fdc6 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -269,20 +269,39 @@ async function setupConda( return notCanceled() } -async function setupGradle( +export async function setupGradle( config: NonNullable< NonNullable['manifest']>['gradle'] >, + // Set by the recursive dynamic-sbom-inference wizard, which has no + // pom-mode equivalent - skips the facts/pom question entirely. + { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { - const bin = await askForBin(config.bin || './gradlew') + const priorBin = config.bin + const bin = await askForBin(config.bin || '', './gradlew') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin !== undefined) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else if (priorJavaHome !== undefined) { + config.javaHome = null + } else { + delete config.javaHome + } + + const priorGradleOpts = config.gradleOpts const opts = await input({ message: '(--gradle-opts) Enter gradle options to pass through', default: config.gradleOpts || '', @@ -293,22 +312,26 @@ async function setupGradle( return canceledByUser() } else if (opts) { config.gradleOpts = opts + } else if (priorGradleOpts !== undefined) { + config.gradleOpts = null } else { delete config.gradleOpts } - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { - return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts + if (!factsOnly) { + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { + return canceledByUser() + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' + } else { + delete config.facts + } } // The config filters and --ignore-unresolved only apply to facts generation // (the default); skip them when pom generation (--pom) is selected. - if (config.facts !== false) { + if (factsOnly || config.facts !== false) { const factsOptions = await setupFactsOptions(config) if (!factsOptions.ok || factsOptions.data.canceled) { return factsOptions @@ -327,20 +350,36 @@ async function setupGradle( return notCanceled() } -async function setupMaven( +export async function setupMaven( config: NonNullable< NonNullable['manifest']>['maven'] >, ): Promise> { - const bin = await askForBin(config.bin || 'mvn') + const priorBin = config.bin + const bin = await askForBin(config.bin || '', 'mvn') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin !== undefined) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else if (priorJavaHome !== undefined) { + config.javaHome = null + } else { + delete config.javaHome + } + + const priorMavenOpts = config.mavenOpts const opts = await input({ message: '(--maven-opts) Enter maven options to pass through', default: config.mavenOpts || '', @@ -350,6 +389,8 @@ async function setupMaven( return canceledByUser() } else if (opts) { config.mavenOpts = opts + } else if (priorMavenOpts !== undefined) { + config.mavenOpts = null } else { delete config.mavenOpts } @@ -373,20 +414,38 @@ async function setupMaven( return notCanceled() } -async function setupSbt( +export async function setupSbt( config: NonNullable< NonNullable['manifest']>['sbt'] >, + // See setupGradle's matching parameter for why. + { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { - const bin = await askForBin(config.bin || 'sbt') + const priorBin = config.bin + const bin = await askForBin(config.bin || '', 'sbt') if (bin === undefined) { return canceledByUser() } else if (bin) { config.bin = bin + } else if (priorBin !== undefined) { + config.bin = null } else { delete config.bin } + const priorJavaHome = config.javaHome + const javaHome = await askForJavaHome(config.javaHome || '') + if (javaHome === undefined) { + return canceledByUser() + } else if (javaHome) { + config.javaHome = javaHome + } else if (priorJavaHome !== undefined) { + config.javaHome = null + } else { + delete config.javaHome + } + + const priorSbtOpts = config.sbtOpts const opts = await input({ message: '(--sbt-opts) Enter sbt options to pass through', default: config.sbtOpts || '', @@ -397,23 +456,27 @@ async function setupSbt( return canceledByUser() } else if (opts) { config.sbtOpts = opts + } else if (priorSbtOpts !== undefined) { + config.sbtOpts = null } else { delete config.sbtOpts } - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { - return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts + if (!factsOnly) { + const facts = await askForFactsFlag(config.facts) + if (facts === undefined) { + return canceledByUser() + } else if (facts === 'yes' || facts === 'no') { + config.facts = facts === 'yes' + } else { + delete config.facts + } } // Socket facts is the default. The pom output questions (stdout/outfile) // only apply when pom generation (--pom) is explicitly selected; otherwise // ask the facts-only options. - if (config.facts === false) { + if (!factsOnly && config.facts === false) { const stdout = await askForStdout(config.stdout) if (stdout === undefined) { return canceledByUser() @@ -540,17 +603,37 @@ async function askForOutputFile(defaultName = ''): Promise { }) } -async function askForBin(defaultName = ''): Promise { +// `defaultName` must be a prior explicit/cascaded value, never the tool's +// hardcoded fallback (e.g. `mvn`) - pre-filling that would freeze it into +// socket.json on a bare Enter. `fallbackHint` is informational text only. +async function askForBin( + defaultName = '', + fallbackHint = '', +): Promise { return await input({ message: '(--bin) What should be the command to execute? Usually your build binary.' + - (defaultName ? ' (Backspace to leave default)' : ''), + (defaultName + ? ' (Backspace to leave default)' + : fallbackHint + ? ` (blank = ${fallbackHint})` + : ''), default: defaultName, required: false, // validate: async string => bool }) } +async function askForJavaHome(defaultName = ''): Promise { + return await input({ + message: + 'What JDK should this build tool use? Leave blank to use the JDK already on PATH/JAVA_HOME. Supports $VAR/${VAR}.' + + (defaultName ? ' (Backspace to leave default)' : ''), + default: defaultName, + required: false, + }) +} + async function askForVerboseFlag( current: boolean | undefined, ): Promise { @@ -635,10 +718,11 @@ async function askForIgnoreUnresolvedFlag( // Prompts for the facts-only options shared by gradle and sbt: the config // include/exclude filters and --ignore-unresolved. Mutates `config` in place. async function setupFactsOptions(config: { - excludeConfigs?: string | undefined + excludeConfigs?: string | undefined | null ignoreUnresolved?: boolean | undefined - includeConfigs?: string | undefined + includeConfigs?: string | undefined | null }): Promise> { + const priorIncludeConfigs = config.includeConfigs const includeConfigs = await input({ message: '(--include-configs) Comma-separated config-name globs to resolve (blank = all configurations)', @@ -649,10 +733,15 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (includeConfigs) { config.includeConfigs = includeConfigs + } else if (priorIncludeConfigs !== undefined) { + // Was previously set; clear it explicitly instead of just deleting the + // key, so it doesn't silently start inheriting an ancestor's value again. + config.includeConfigs = null } else { delete config.includeConfigs } + const priorExcludeConfigs = config.excludeConfigs const excludeConfigs = await input({ message: '(--exclude-configs) Comma-separated config-name globs to skip (blank = none)', @@ -663,6 +752,8 @@ async function setupFactsOptions(config: { return canceledByUser() } else if (excludeConfigs) { config.excludeConfigs = excludeConfigs + } else if (priorExcludeConfigs !== undefined) { + config.excludeConfigs = null } else { delete config.excludeConfigs } diff --git a/src/commands/manifest/setup-manifest-config.test.mts b/src/commands/manifest/setup-manifest-config.test.mts new file mode 100644 index 0000000000..ed47c2c09a --- /dev/null +++ b/src/commands/manifest/setup-manifest-config.test.mts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' + +// Simulates a real prompt: pressing Enter with nothing typed returns +// whatever `default` was shown - the same contract inquirer honors. This is +// exactly the behavior that made `askForBin` freeze a hardcoded tool +// fallback (e.g. 'mvn') into socket.json when the user never typed anything. +vi.mock('@socketsecurity/registry/lib/prompts', () => ({ + input: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), + select: vi.fn(async ({ default: def }: { default?: string }) => def ?? ''), +})) + +import { select } from '@socketsecurity/registry/lib/prompts' + +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' + +describe('setupGradle/setupMaven/setupSbt', () => { + it('leaves bin unset on a fresh config when every prompt is left blank, instead of freezing the hardcoded fallback', async () => { + const gradleConfig: Record = {} + const mavenConfig: Record = {} + const sbtConfig: Record = {} + + const gradleResult = await setupGradle(gradleConfig) + const mavenResult = await setupMaven(mavenConfig) + const sbtResult = await setupSbt(sbtConfig) + + expect(gradleResult).toEqual({ ok: true, data: { canceled: false } }) + expect(mavenResult).toEqual({ ok: true, data: { canceled: false } }) + expect(sbtResult).toEqual({ ok: true, data: { canceled: false } }) + expect(gradleConfig['bin']).toBeUndefined() + expect(mavenConfig['bin']).toBeUndefined() + expect(sbtConfig['bin']).toBeUndefined() + }) + + it('still shows and accepts a prior explicit bin value unchanged', async () => { + const config: Record = { bin: './custom-gradlew' } + + await setupGradle(config) + + expect(config['bin']).toBe('./custom-gradlew') + }) + + it('preserves an explicit null (already-cleared) field on a blank re-prompt, instead of deleting it back into inheriting', async () => { + const config: Record = { + bin: null, + gradleOpts: null, + javaHome: null, + } + + await setupGradle(config) + + expect(config['bin']).toBeNull() + expect(config['javaHome']).toBeNull() + expect(config['gradleOpts']).toBeNull() + }) + + it('asks the facts/pom question by default', async () => { + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ default: def, message }) => { + messages.push(message) + return def ?? '' + }) + + await setupGradle({}) + await setupSbt({}) + + expect(messages.some(m => m.includes('--facts / --pom'))).toBe(true) + }) + + it('skips the facts/pom question entirely when factsOnly is set, going straight to facts-only options', async () => { + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ default: def, message }) => { + messages.push(message) + return def ?? '' + }) + + await setupGradle({}, { factsOnly: true }) + await setupSbt({}, { factsOnly: true }) + + expect(messages.some(m => m.includes('--facts / --pom'))).toBe(false) + // The facts-only options (config filters) still ask, just without the + // facts/pom choice gating them. + expect(messages.some(m => m.includes('--ignore-unresolved'))).toBe(true) + }) +}) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts new file mode 100644 index 0000000000..62ccc06174 --- /dev/null +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -0,0 +1,899 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import micromatch from 'micromatch' + +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { + findBuildToolCandidates, + realpathOrResolved, + withoutDisabledFlags, +} from './discover-manifest-roots.mts' +import { enumerateWorkspaces } from './enumerate-workspaces.mts' +import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { SOCKET_JSON } from '../../constants.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, + readSocketJsonSync, + writeSocketJson, +} from '../../utils/socket-json.mts' +import { + excludePathToScanIgnores, + projectIgnorePathsToReachExcludePaths, +} from '../scan/exclude-paths.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { CResult } from '../../types.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +// A single discovered build root - a directory with its own gradle/sbt/maven +// marker file. +type Candidate = { dir: string; ecosystem: BuildTool } + +const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt'] + +const ECOSYSTEM_LABELS: Record = { + __proto__: null, + gradle: 'Gradle', + maven: 'Maven', + sbt: 'sbt', +} as unknown as Record + +// The shallowest directory matching `--exclude-paths`, not necessarily a +// project dir itself - one write here covers every project beneath it. +type ExclusionRoot = { dir: string; ecosystems: BuildTool[] } + +function canceledByUser(): CResult<{ canceled: boolean }> { + logger.log('') + logger.info('User canceled') + logger.log('') + return { ok: true, data: { canceled: true } } +} + +function notCanceled(): CResult<{ canceled: boolean }> { + return { ok: true, data: { canceled: false } } +} + +function getEcosystemSection( + sockJson: SocketJson, + ecosystem: BuildTool, +): Record { + return ( + (sockJson.defaults?.manifest?.[ecosystem] as + | Record + | undefined) ?? {} + ) +} + +// Depth-then-path sort: for exclusion roots it's just stable log ordering; +// for candidates it's load-bearing, since a parent must be prompted before +// its children so a child's shown default reflects a parent's just-written +// cascade instead of its pre-run value. +export function sortCandidatesForDisplay( + candidates: readonly T[], + cwd: string, +): T[] { + return [...candidates].sort((a, b) => { + const relA = path.relative(cwd, a.dir) + const relB = path.relative(cwd, b.dir) + const depthA = relA.split(path.sep).length + const depthB = relB.split(path.sep).length + if (depthA !== depthB) { + return depthA - depthB + } + return relA < relB ? -1 : relA > relB ? 1 : 0 + }) +} + +function toPosixRelative(cwd: string, dir: string): string { + return path.relative(cwd, dir).split(path.sep).join('/') +} + +// Shallowest prefix of `relDir` that matches an --exclude-paths pattern - +// the directory to write `disabled` to, so one write covers everything +// beneath it, even if that directory isn't itself a build root. +function findExclusionRoot( + relDir: string, + ignorePatterns: readonly string[], +): string { + const segments = relDir.split('/') + for (let depth = 1; depth <= segments.length; depth += 1) { + const prefix = segments.slice(0, depth).join('/') + if (micromatch.isMatch(prefix, ignorePatterns, { dot: true })) { + return prefix + } + } + return relDir +} + +export type ScannedBuildRoots = { + fullByTool: Map + includedByTool: Map +} + +// Both the unfiltered and --exclude-paths-filtered scans, once, so ecosystem +// detection and later include/exclude grouping (see discoverBuildRoots) +// never re-walk the same tree. +export async function scanBuildRoots({ + cwd, + excludePaths, + sockJson, +}: { + cwd: string + excludePaths?: string[] | undefined + sockJson: SocketJson +}): Promise { + const [fullByTool, includedByTool] = await Promise.all([ + findBuildToolCandidates({ cwd, sockJson }), + findBuildToolCandidates({ cwd, excludePaths, sockJson }), + ]) + return { fullByTool, includedByTool } +} + +export type DiscoveredBuildRoots = { + excluded: ExclusionRoot[] + // Every candidate --exclude-paths didn't rule out - these get the + // interactive configure-or-inherit walk instead of a bulk write. + included: Candidate[] +} + +// Splits an already-scanned candidate set into `included` (the interactive +// walk) and `excluded`, grouped by the shallowest --exclude-paths match (see +// findExclusionRoot). `cwd` is excluded from both (it got its own wizard +// pass) and realpath-resolved to match findBuildToolCandidates' own +// resolution - otherwise macOS's /tmp -> /private/tmp breaks the comparison. +export async function discoverBuildRoots({ + cwd, + excludePaths, + fullByTool, + includedByTool, +}: { + cwd: string + excludePaths?: string[] | undefined + fullByTool: Map + includedByTool: Map +}): Promise { + const realCwd = await realpathOrResolved(cwd) + const included: Candidate[] = [] + const ignorePatterns = (excludePaths ?? []).flatMap(excludePathToScanIgnores) + const ecosystemsByRoot = new Map>() + for (const [ecosystem, fullDirs] of fullByTool) { + const includedDirs = new Set(includedByTool.get(ecosystem) ?? []) + for (const dir of fullDirs) { + if (dir === realCwd) { + continue + } + if (includedDirs.has(dir)) { + included.push({ dir, ecosystem }) + continue + } + const relDir = toPosixRelative(realCwd, dir) + const rootRelDir = findExclusionRoot(relDir, ignorePatterns) + const rootDir = path.join(realCwd, rootRelDir) + const ecosystems = ecosystemsByRoot.get(rootDir) ?? new Set() + ecosystems.add(ecosystem) + ecosystemsByRoot.set(rootDir, ecosystems) + } + } + + return { + excluded: [...ecosystemsByRoot].map(([dir, ecosystems]) => ({ + dir, + ecosystems: [...ecosystems].sort(), + })), + included, + } +} + +// Enumerates one build root's declared workspace members (see +// enumerate-workspaces.mts) and folds them into `coveredByEcosystem`, so a +// later candidate matching one is recognized as a reactor member rather than +// independent. Called per-candidate right after its own prompt, not as a +// bulk pass, so the build invocation never blocks candidates that don't need +// it. A disabled candidate is skipped (no point invoking an off build tool); +// otherwise this fails closed, same reasoning as generateRecursiveManifests - +// if enumeration fails there's no way to tell covered from independent, so +// the caller aborts rather than guess. +export async function markWorkspaceCoverage({ + candidate, + coveredByEcosystem, + cwd, + excludePaths, + rootSockJson, +}: { + candidate: Candidate + coveredByEcosystem: Map> + cwd: string + excludePaths?: string[] | undefined + rootSockJson: SocketJson +}): Promise> { + const cascade = readSocketJsonCascade(candidate.dir, cwd, rootSockJson) + const { bin, buildOpts, javaHome, skipReason } = resolveEcosystemConfig( + candidate.ecosystem, + candidate.dir, + cascade, + ) + if (skipReason) { + return { ok: true, data: undefined } + } + + const excludePathsForCandidate = projectIgnorePathsToReachExcludePaths( + excludePaths, + { cwd, target: candidate.dir }, + ) + + const enumResult = await enumerateWorkspaces({ + bin, + buildOpts, + cwd: candidate.dir, + ecosystem: candidate.ecosystem, + excludePaths: excludePathsForCandidate, + javaHome, + verbose: false, + }) + if (!enumResult) { + const relDir = path.relative(cwd, candidate.dir) || '.' + return { + ok: false, + message: `Could not determine ${candidate.ecosystem} workspace layout for ${relDir}; aborting rather than risk misclassifying its members.`, + } + } + + const set = coveredByEcosystem.get(candidate.ecosystem) ?? new Set() + set.add(candidate.dir) + const resolvedSubprojectDirs = await Promise.all( + enumResult.projects.map(project => + realpathOrResolved(path.resolve(candidate.dir, project.subprojectDir)), + ), + ) + for (const subprojectDir of resolvedSubprojectDirs) { + set.add(subprojectDir) + } + coveredByEcosystem.set(candidate.ecosystem, set) + return { ok: true, data: undefined } +} + +// Marks one exclusion root's own socket.json `disabled: true` for whichever +// of its ecosystems aren't already disabled via cascade (an already-disabled +// ancestor from a prior run) - a no-op write is skipped entirely so re-running +// the wizard doesn't keep rewriting already-disabled roots. +export async function disableExclusionRoot({ + cwd, + dir, + ecosystems, + rootSockJson, +}: { + cwd: string + dir: string + ecosystems: readonly BuildTool[] + rootSockJson: SocketJson +}): Promise> { + const relDir = path.relative(cwd, dir) || '.' + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + const needsWrite = ecosystems.filter( + ecosystem => getEcosystemSection(cascade, ecosystem)['disabled'] !== true, + ) + if (!needsWrite.length) { + return notCanceled() + } + + const ownSockJson = readOrDefaultSocketJson(dir) + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + const manifest = ownSockJson.defaults.manifest as Record + for (const ecosystem of needsWrite) { + manifest[ecosystem] = { + ...getEcosystemSection(ownSockJson, ecosystem), + disabled: true, + } + } + + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Disabled ${relDir} (${needsWrite.join(', ')})`) + return notCanceled() +} + +// Dispatches to the right ecosystem-specific wizard. `factsOnly` skips the +// facts/pom question - dynamic-sbom-inference never generates a pom.xml, it +// just skips a project resolved to `facts: false` (see +// generateRecursiveManifests' skipReason), so offering that choice here +// would be misleading. +async function runEcosystemWizard( + ecosystem: BuildTool, + config: Record, +): Promise> { + if (ecosystem === 'gradle') { + return await setupGradle( + config as NonNullable< + NonNullable['manifest']>['gradle'] + >, + { factsOnly: true }, + ) + } + if (ecosystem === 'maven') { + return await setupMaven( + config as NonNullable< + NonNullable['manifest']>['maven'] + >, + ) + } + return await setupSbt( + config as NonNullable< + NonNullable['manifest']>['sbt'] + >, + { factsOnly: true }, + ) +} + +// Seeds the wizard with this candidate's effective (cascaded) value for any +// field its own file doesn't already set, so accepting every prompt +// unchanged preserves what it currently inherits, while an actual change +// writes an explicit override. +export async function configureCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise> { + const relDir = path.relative(cwd, dir) || '.' + const ownSockJson = readOrDefaultSocketJson(dir) + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + + // Ancestor-only baseline (excludes dir's own file, unlike the cascade below) + // so a field left exactly as shown can be told apart from one the user + // actually set - otherwise every accepted default gets written verbatim, + // permanently pinning it against future changes to an ancestor's value. + const inherited = getEcosystemSection( + readSocketJsonCascade(path.dirname(dir), cwd, rootSockJson), + ecosystem, + ) + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + const seed: Record = { + ...getEcosystemSection(cascade, ecosystem), + ...getEcosystemSection(ownSockJson, ecosystem), + } + + const result = await runEcosystemWizard(ecosystem, seed) + if (!result.ok || result.data.canceled) { + return result + } + + const toWrite: Record = {} + for (const key of Object.keys(seed)) { + if (seed[key] !== inherited[key]) { + toWrite[key] = seed[key] + } + } + // Every field equals what dir would already inherit - writing it would just + // be noise (own file unaffected, dir keeps inheriting exactly as before). + if (!Object.keys(toWrite).length) { + logger.log(`No changes for ${relDir} (${ecosystem}); nothing written.`) + return notCanceled() + } + + const manifest = ownSockJson.defaults.manifest as Record + manifest[ecosystem] = toWrite + + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Configured ${relDir} (${ecosystem})`) + return notCanceled() +} + +type CandidateAction = 'configure' | 'inherit' + +// No disable choice here - that's --exclude-paths' job (see +// findExclusionRoot), so a whole subtree still collapses to one write +// instead of one per candidate. +async function askCandidateAction( + relDir: string, + ecosystem: BuildTool, +): Promise { + return (await select({ + message: `${relDir} (${ecosystem})`, + choices: [ + { + name: 'Leave as-is', + value: 'inherit', + description: + "Make no change - keep this project's current effective configuration", + }, + { + name: 'Configure', + value: 'configure', + description: 'Set bin/JDK/opts/etc. for this project specifically', + }, + ], + default: 'inherit', + })) as CandidateAction | null +} + +type CandidateOutcome = 'configured' | 'inherited' | 'skipped' + +// Asks the standard configure-or-leave-as-is question and applies the +// answer. Shared by the normal path and the post-re-enable path below, so +// re-enabling a candidate isn't a dead end that skips straight past its own +// bin/JDK/opts configuration. +async function askAndApplyAction({ + cwd, + dir, + ecosystem, + relDir, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + relDir: string + rootSockJson: SocketJson +}): Promise< + CResult<{ canceled: boolean; outcome: 'configured' | 'inherited' }> +> { + const action = await askCandidateAction(relDir, ecosystem) + if (action === undefined || action === null) { + canceledByUser() + return { ok: true, data: { canceled: true, outcome: 'inherited' } } + } + if (action === 'configure') { + const result = await configureCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, outcome: 'configured' } } + } + // 'inherit', or any unexpected value - the safe no-op default. + return { ok: true, data: { canceled: false, outcome: 'inherited' } } +} + +// Prompts for configure/inherit, unless the cascade shows the candidate +// disabled: offer to re-enable if that's its own file, then continue into +// the normal prompt; stay silent if it's only inherited from an ancestor's +// bulk write (that's noise for a large excluded subtree). +export async function processCandidate({ + cwd, + dir, + ecosystem, + rootSockJson, +}: { + cwd: string + dir: string + ecosystem: BuildTool + rootSockJson: SocketJson +}): Promise< + CResult<{ canceled: boolean; outcome: CandidateOutcome; reenabled: boolean }> +> { + const relDir = path.relative(cwd, dir) || '.' + const cascade = readSocketJsonCascade(dir, cwd, rootSockJson) + if (getEcosystemSection(cascade, ecosystem)['disabled'] === true) { + const ownSockJson = readOrDefaultSocketJson(dir) + if (getEcosystemSection(ownSockJson, ecosystem)['disabled'] !== true) { + // Inherited from an ancestor, not this candidate's own file - stay silent. + return { + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + } + } + + const wantsReenable = await askYesNo( + `${relDir} (${ecosystem}) is disabled - re-enable it?`, + ) + if (wantsReenable === undefined || wantsReenable === null) { + canceledByUser() + return { + ok: true, + data: { canceled: true, outcome: 'skipped', reenabled: false }, + } + } + if (!wantsReenable) { + return { + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + } + } + if (!ownSockJson.defaults) { + ownSockJson.defaults = {} + } + if (!ownSockJson.defaults.manifest) { + ownSockJson.defaults.manifest = {} + } + const manifest = ownSockJson.defaults.manifest as Record + manifest[ecosystem] = { + ...getEcosystemSection(ownSockJson, ecosystem), + disabled: false, + } + const writeResult = await writeSocketJson(dir, ownSockJson) + if (!writeResult.ok) { + return writeResult + } + logger.success(`Re-enabled ${relDir} (${ecosystem})`) + + const result = await askAndApplyAction({ + cwd, + dir, + ecosystem, + relDir, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, reenabled: true } } + } + + const result = await askAndApplyAction({ + cwd, + dir, + ecosystem, + relDir, + rootSockJson, + }) + if (!result.ok) { + return result + } + return { ok: true, data: { ...result.data, reenabled: false } } +} + +// Accepting every prompt's default leaves an ecosystem's section genuinely +// empty (see askForBin in setup-manifest-config.mts) - drop it so +// `configuredAny` reflects what was actually configured, not just which +// ecosystems the user said "yes" to. +function dropIfEmpty( + manifest: Record, + ecosystem: BuildTool, +): boolean { + const section = manifest[ecosystem] as Record | undefined + if (section && Object.keys(section).length) { + return true + } + delete manifest[ecosystem] + return false +} + +async function askYesNo(message: string): Promise { + return (await select({ + message, + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + })) as boolean | null +} + +// Unlike the plain single-project wizard, cwd here is often just a common +// ancestor - so `ecosystemsAtCwd` (a subset of `ecosystems`) picks the +// wording: cwd's own build root gets "configure this project's settings", +// everything else gets "configure defaults for nested projects". A +// pre-existing `disabled: true` gets a re-enable question first, otherwise +// there'd be no way back from a prior disable. Declining everything is +// normal, not an abort. +async function setupRecursiveRootDefaults( + cwd: string, + defaultOnReadError: boolean, + ecosystems: readonly BuildTool[], + ecosystemsAtCwd: readonly BuildTool[], +): Promise> { + const jsonPath = path.join(cwd, SOCKET_JSON) + if (existsSync(jsonPath)) { + logger.info(`Found ${SOCKET_JSON} at ${jsonPath}`) + } else { + logger.info(`No ${SOCKET_JSON} found at ${cwd}, will generate a new one`) + } + + logger.log('') + logger.log( + 'Note: This tool will set up flag and argument defaults for certain', + ) + logger.log(' CLI commands. You can still override them by explicitly') + logger.log(' setting the flag.') + logger.log('') + logger.log(`This command will generate a ${SOCKET_JSON} file in ${cwd}.`) + logger.log('socket.json properties are inherited by nested paths.') + logger.log('') + + const sockJsonCResult = readSocketJsonSync(cwd, defaultOnReadError) + if (!sockJsonCResult.ok) { + return sockJsonCResult + } + const sockJson = sockJsonCResult.data + if (!sockJson.defaults) { + sockJson.defaults = {} + } + if (!sockJson.defaults.manifest) { + sockJson.defaults.manifest = {} + } + const manifest = sockJson.defaults.manifest as Record + + let configuredAny = false + + for (const ecosystem of ecosystems) { + const label = ECOSYSTEM_LABELS[ecosystem] + const existingSection = manifest[ecosystem] as + | Record + | undefined + if (existingSection?.['disabled'] === true) { + // eslint-disable-next-line no-await-in-loop + const wantsReenable = await askYesNo( + `${label} is currently disabled here - re-enable it?`, + ) + if (wantsReenable === undefined || wantsReenable === null) { + return canceledByUser() + } + if (!wantsReenable) { + continue + } + existingSection['disabled'] = false + configuredAny = true + } + + const message = ecosystemsAtCwd.includes(ecosystem) + ? `Configure ${label} settings for this project?` + : `Configure ${label} defaults for any nested projects?` + // eslint-disable-next-line no-await-in-loop + const wants = await askYesNo(message) + if (wants === undefined || wants === null) { + return canceledByUser() + } + if (!wants) { + continue + } + if (!manifest[ecosystem]) { + manifest[ecosystem] = {} + } + // eslint-disable-next-line no-await-in-loop + const result = await runEcosystemWizard( + ecosystem, + manifest[ecosystem] as Record, + ) + if (!result.ok || result.data.canceled) { + return result + } + if (dropIfEmpty(manifest, ecosystem)) { + configuredAny = true + } + } + + if (!configuredAny) { + logger.log('') + logger.log('No root-level defaults configured.') + return notCanceled() + } + + logger.log('') + logger.log(`Writing ${SOCKET_JSON} to ${jsonPath}`) + logger.log('') + + const writeResult = await writeSocketJson(cwd, sockJson) + if (!writeResult.ok) { + return writeResult + } + return notCanceled() +} + +// `socket manifest setup --dynamic-sbom-inference`: the up-front scan (see +// scanBuildRoots) only ever answers "which build tools exist", never "which +// projects to configure". `--exclude-paths` matches are bulk-disabled +// unconditionally, independent of the interactive walk below, so re-running +// later with one more entry (or none) never re-answers settled decisions. +// Everything else gets a configure-or-inherit prompt (see processCandidate), +// except a workspace member a parent build already declares - learned via +// markWorkspaceCoverage right after the parent's own prompt, not a separate +// bulk pass, so a reactor member is silently skipped instead of prompted. +export async function setupRecursiveManifestConfig( + cwd: string, + defaultOnReadError: boolean, + excludePaths?: string[] | undefined, +): Promise> { + logger.log('') + logger.log(`Configuring ${cwd} ...`) + + const initialSockJson = readOrDefaultSocketJson(cwd) + // Resolved once here for sortCandidatesForDisplay/disableExclusionRoot's + // relative-path math, consistent with discoverBuildRoots' own internal + // resolution (see its comment for why this matters). + const realCwd = await realpathOrResolved(cwd) + + logger.log('') + logger.log('Scanning for build roots ...') + const { fullByTool, includedByTool } = await scanBuildRoots({ + cwd, + excludePaths, + sockJson: withoutDisabledFlags(initialSockJson), + }) + // includedByTool (not the unfiltered fullByTool), so an ecosystem whose + // only candidate is about to be excluded this same run isn't offered + // either - there'd be nothing left for its root defaults to apply to. + const detectedEcosystems = ROOT_ECOSYSTEMS.filter( + ecosystem => (includedByTool.get(ecosystem)?.length ?? 0) > 0, + ) + if (detectedEcosystems.length) { + logger.log( + `Detected: ${detectedEcosystems.map(ecosystem => ECOSYSTEM_LABELS[ecosystem]).join(', ')}.`, + ) + } else { + logger.log(`No gradle/maven/sbt build roots found beneath ${cwd}.`) + } + logger.log('') + + // Which detected ecosystems cwd is itself a build root for - see + // setupRecursiveRootDefaults' wording split. + const ecosystemsAtCwd = ROOT_ECOSYSTEMS.filter(ecosystem => + (fullByTool.get(ecosystem) ?? []).includes(realCwd), + ) + + const rootResult = await setupRecursiveRootDefaults( + cwd, + defaultOnReadError, + detectedEcosystems, + ecosystemsAtCwd, + ) + if (!rootResult.ok) { + return rootResult + } + if (rootResult.data.canceled) { + return canceledByUser() + } + + // Re-read: the root wizard may have just written a new socket.json. + const rootSockJson = readOrDefaultSocketJson(cwd) + const { excluded, included } = await discoverBuildRoots({ + cwd, + excludePaths, + fullByTool, + includedByTool, + }) + + // Applies regardless of the interactive walk below - see the module doc + // comment on why --exclude-paths is unconditional. + if (excludePaths?.length) { + if (!excluded.length) { + logger.log('None matched --exclude-paths; nothing disabled.') + } else { + const orderedExcluded = sortCandidatesForDisplay(excluded, realCwd) + for (const candidate of orderedExcluded) { + // eslint-disable-next-line no-await-in-loop + const result = await disableExclusionRoot({ + cwd: realCwd, + dir: candidate.dir, + ecosystems: candidate.ecosystems, + rootSockJson, + }) + if (!result.ok) { + return result + } + } + } + } + + if (!included.length) { + logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() + } + + logger.log('') + const wantsIndividual = await askYesNo( + 'Configure other build roots found beneath this one, individually?', + ) + if (wantsIndividual === undefined || wantsIndividual === null) { + return canceledByUser() + } + if (!wantsIndividual) { + logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() + } + + logger.log('') + logger.log( + 'For each one, choose to configure it or leave it as-is. To disable one,', + ) + logger.log('re-run with --exclude-paths instead.') + logger.log('') + + const coveredByEcosystem = new Map>() + // cwd never appears in `included`, but a reactor root often sits exactly + // at cwd - seed coverage from it first so its declared members still get + // recognized. + for (const ecosystem of ecosystemsAtCwd) { + // eslint-disable-next-line no-await-in-loop + const coverageResult = await markWorkspaceCoverage({ + candidate: { dir: realCwd, ecosystem }, + coveredByEcosystem, + cwd: realCwd, + excludePaths, + rootSockJson, + }) + if (!coverageResult.ok) { + return coverageResult + } + } + + const counts = { + configured: 0, + covered: 0, + inherited: 0, + reenabled: 0, + skipped: 0, + } + const orderedIncluded = sortCandidatesForDisplay(included, realCwd) + for (const candidate of orderedIncluded) { + const covered = coveredByEcosystem.get(candidate.ecosystem) + if (covered?.has(candidate.dir)) { + counts.covered += 1 + continue + } + + // eslint-disable-next-line no-await-in-loop + const result = await processCandidate({ + cwd: realCwd, + dir: candidate.dir, + ecosystem: candidate.ecosystem, + rootSockJson, + }) + if (!result.ok) { + return result + } + if (result.data.canceled) { + // The cancellation itself (select Esc/Ctrl+C, or a sub-wizard's own + // cancel) already logged "User canceled" - don't log it twice. + return { ok: true, data: { canceled: true } } + } + counts[result.data.outcome] += 1 + if (result.data.reenabled) { + // A qualifier on the 'configured'/'inherited' bucket just incremented + // above, not a separate bucket - avoids double-counting the candidate. + counts.reenabled += 1 + } + + // Interleaved rather than a bulk pre-pass, so this (possibly slow) + // build-tool invocation only runs for a project already prompted about. + // eslint-disable-next-line no-await-in-loop + const coverageResult = await markWorkspaceCoverage({ + candidate, + coveredByEcosystem, + cwd: realCwd, + excludePaths, + rootSockJson, + }) + if (!coverageResult.ok) { + return coverageResult + } + } + + logger.log('') + logger.log( + `${counts.configured} configured (${counts.reenabled} re-enabled), ${counts.inherited} left as-is, ${counts.skipped} left disabled, ${counts.covered} covered by a parent build.`, + ) + + logger.log('') + logger.success('Recursive setup complete.') + return notCanceled() +} diff --git a/src/commands/manifest/setup-recursive-manifest-config.test.mts b/src/commands/manifest/setup-recursive-manifest-config.test.mts new file mode 100644 index 0000000000..0e7d53ea94 --- /dev/null +++ b/src/commands/manifest/setup-recursive-manifest-config.test.mts @@ -0,0 +1,1192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./discover-manifest-roots.mts', async importOriginal => { + const actual = + await importOriginal() + return { + findBuildToolCandidates: vi.fn(), + // Identity: test dirs are already-absolute plain strings, no symlinks involved. + realpathOrResolved: vi.fn(async (dir: string) => dir), + // Real (pure) implementation - no need to mock it. + withoutDisabledFlags: actual.withoutDisabledFlags, + } +}) +vi.mock('./enumerate-workspaces.mts', () => ({ + enumerateWorkspaces: vi.fn(), +})) +vi.mock('@socketsecurity/registry/lib/prompts', () => ({ + select: vi.fn(), +})) +vi.mock('./setup-manifest-config.mts', () => ({ + setupGradle: vi.fn(), + setupMaven: vi.fn(), + setupSbt: vi.fn(), +})) +vi.mock('../../utils/socket-json.mts', () => ({ + readOrDefaultSocketJson: vi.fn(), + readSocketJsonCascade: vi.fn(), + readSocketJsonSync: vi.fn(), + writeSocketJson: vi.fn(), +})) + +import { logger } from '@socketsecurity/registry/lib/logger' +import { select } from '@socketsecurity/registry/lib/prompts' + +import { findBuildToolCandidates } from './discover-manifest-roots.mts' +import { enumerateWorkspaces } from './enumerate-workspaces.mts' +import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { + configureCandidate, + disableExclusionRoot, + discoverBuildRoots, + markWorkspaceCoverage, + processCandidate, + scanBuildRoots, + setupRecursiveManifestConfig, + sortCandidatesForDisplay, +} from './setup-recursive-manifest-config.mts' +import { + readOrDefaultSocketJson, + readSocketJsonCascade, + readSocketJsonSync, + writeSocketJson, +} from '../../utils/socket-json.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SocketJson } from '../../utils/socket-json.mts' + +function emptySockJson(): SocketJson { + return { version: 1 } as SocketJson +} + +// In-memory `dir -> own gradle section` store backing readSocketJsonCascade/ +// readOrDefaultSocketJson/writeSocketJson, so a write made while processing +// one candidate is visible (via cascade) to a later candidate nested under +// it - the exact ordering behavior the "topmost only" tests exercise. +function makeFakeGradleDisk(cwd: string) { + const store = new Map>() + const ancestorsFarToNear = (dir: string): string[] => { + const chain: string[] = [] + let current = dir + while (current !== cwd) { + chain.unshift(current) + const parent = current.slice(0, current.lastIndexOf('/')) + if (!parent || parent === current) { + break + } + current = parent + } + return chain + } + return { + readOrDefaultSocketJson: vi.fn((dir: string) => { + const own = store.get(dir) + return own + ? ({ + version: 1, + defaults: { manifest: { gradle: own } }, + } as SocketJson) + : emptySockJson() + }), + readSocketJsonCascade: vi.fn((dir: string) => { + let merged: Record = {} + for (const ancestor of ancestorsFarToNear(dir)) { + const own = store.get(ancestor) + if (own) { + merged = { ...merged, ...own } + } + } + return { + version: 1, + defaults: { manifest: { gradle: merged } }, + } as SocketJson + }), + writeSocketJson: vi.fn(async (dir: string, sockJson: SocketJson) => { + store.set( + dir, + (sockJson.defaults?.manifest?.gradle as Record) ?? {}, + ) + return { ok: true, data: undefined } + }), + } +} + +describe('sortCandidatesForDisplay', () => { + const cwd = '/repo' + + it('sorts shallower dirs before deeper ones, parent before child', () => { + const sorted = sortCandidatesForDisplay( + [ + { dir: '/repo/module-b/standalone-gradle-lib', ecosystems: ['gradle'] }, + { dir: '/repo/independent-service', ecosystems: ['maven'] }, + ], + cwd, + ) + expect(sorted).toEqual([ + { dir: '/repo/independent-service', ecosystems: ['maven'] }, + { dir: '/repo/module-b/standalone-gradle-lib', ecosystems: ['gradle'] }, + ]) + }) +}) + +describe('scanBuildRoots', () => { + const cwd = '/repo' + + beforeEach(() => { + vi.mocked(findBuildToolCandidates).mockReset() + }) + + it('runs the unfiltered and excludePaths-filtered walks once each and returns both', async () => { + const unfiltered = new Map([['gradle', ['/repo/a', '/repo/legacy']]]) + const filtered = new Map([['gradle', ['/repo/a']]]) + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length ? filtered : unfiltered, + ) + + const result = await scanBuildRoots({ + cwd, + excludePaths: ['legacy'], + sockJson: emptySockJson(), + }) + + expect(result).toEqual({ + fullByTool: unfiltered, + includedByTool: filtered, + }) + expect(findBuildToolCandidates).toHaveBeenCalledTimes(2) + }) +}) + +describe('discoverBuildRoots', () => { + const cwd = '/repo' + + it('excludes cwd itself', async () => { + const result = await discoverBuildRoots({ + cwd, + fullByTool: new Map([['gradle', [cwd]]]), + includedByTool: new Map([['gradle', [cwd]]]), + }) + + expect(result).toEqual({ excluded: [], included: [] }) + }) + + it('marks a dir excluded when it is present in the full walk but absent from the excludePaths-filtered walk', async () => { + const legacy = '/repo/legacy' + const active = '/repo/active' + + const result = await discoverBuildRoots({ + cwd, + excludePaths: ['legacy'], + fullByTool: new Map([['gradle', [legacy, active]]]), + includedByTool: new Map([['gradle', [active]]]), + }) + + expect(result).toEqual({ + excluded: [{ dir: legacy, ecosystems: ['gradle'] }], + included: [{ dir: active, ecosystem: 'gradle' }], + }) + }) + + it('groups sibling projects under a non-project excluded ancestor into a single exclusion root', async () => { + // legacy/ itself has no build file of its own - only its two children do - + // so it never appears as a candidate, but --exclude-paths=legacy should + // still collapse both into one write at legacy, not two writes at + // legacy/a and legacy/b. + const a = '/repo/legacy/a' + const b = '/repo/legacy/b' + + const result = await discoverBuildRoots({ + cwd, + excludePaths: ['legacy'], + fullByTool: new Map([ + ['maven', [a]], + ['gradle', [b]], + ]), + includedByTool: new Map([ + ['maven', []], + ['gradle', []], + ]), + }) + + expect(result).toEqual({ + excluded: [{ dir: '/repo/legacy', ecosystems: ['gradle', 'maven'] }], + included: [], + }) + }) +}) + +describe('markWorkspaceCoverage', () => { + const cwd = '/repo' + const reactor = '/repo/reactor' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readSocketJsonCascade).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { maven: { bin: 'mvn' } } }, + }) as SocketJson, + ) + vi.mocked(enumerateWorkspaces).mockReset() + }) + + it('marks the candidate itself plus its declared members as covered', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ + projects: [ + { + type: 'maven', + name: 'moduleA', + subprojectDir: 'moduleA', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'moduleB', + subprojectDir: 'moduleB', + dependencies: [], + resolvedAs: [], + }, + ], + }) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: undefined }) + expect(coveredByEcosystem.get('maven')).toEqual( + new Set([reactor, `${reactor}/moduleA`, `${reactor}/moduleB`]), + ) + }) + + it('does not enumerate, and marks nothing covered, for a disabled candidate', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: undefined }) + expect(enumerateWorkspaces).not.toHaveBeenCalled() + expect(coveredByEcosystem.size).toBe(0) + }) + + it('fails closed - marks nothing covered and reports failure - when enumeration fails', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue(undefined) + const coveredByEcosystem = new Map>() + + const result = await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + rootSockJson: emptySockJson(), + }) + + expect(result.ok).toBe(false) + expect(coveredByEcosystem.size).toBe(0) + }) + + it('forwards --exclude-paths, re-anchored to the candidate dir, so a member excluded specifically to dodge a broken resolution is actually skipped', async () => { + vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] }) + const coveredByEcosystem = new Map>() + + await markWorkspaceCoverage({ + candidate: { dir: reactor, ecosystem: 'maven' }, + coveredByEcosystem, + cwd, + excludePaths: ['reactor/moduleB'], + rootSockJson: emptySockJson(), + }) + + expect(enumerateWorkspaces).toHaveBeenCalledWith( + expect.objectContaining({ excludePaths: ['moduleB'] }), + ) + }) +}) + +describe('disableExclusionRoot', () => { + const cwd = '/repo' + const dir = '/repo/legacy' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + }) + + it('no-ops when the cascade already shows every ecosystem disabled', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + + await disableExclusionRoot({ + cwd, + dir, + ecosystems: ['gradle'], + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('writes disabled:true, preserving other own-file content', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { bin: './gradlew' } } }, + }) as SocketJson, + ) + + await disableExclusionRoot({ + cwd, + dir, + ecosystems: ['gradle'], + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { gradle: { bin: './gradlew', disabled: true } }, + }, + }), + ) + }) + + it('writes only the ecosystems not already covered by cascade', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + + await disableExclusionRoot({ + cwd, + dir, + ecosystems: ['gradle', 'maven'], + rootSockJson: emptySockJson(), + }) + + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { manifest: { maven: { disabled: true } } }, + }), + ) + }) +}) + +describe('configureCandidate', () => { + const cwd = '/repo' + const dir = '/repo/nested-gradle' + + beforeEach(() => { + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(setupGradle).mockReset() + }) + + it('seeds the sub-wizard with the cascaded value, own-file value winning, but only writes fields that actually differ from what dir would inherit', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { javaHome: '/opt/jdk-17' } } }, + }) as SocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + () => + ({ + version: 1, + defaults: { + manifest: { gradle: { bin: './gradlew', javaHome: '/opt/jdk-11' } }, + }, + }) as SocketJson, + ) + vi.mocked(setupGradle).mockImplementation(async config => { + expect(config).toEqual({ bin: './gradlew', javaHome: '/opt/jdk-17' }) + ;(config as Record)['gradleOpts'] = '--offline' + return { ok: true, data: { canceled: false } } + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { + gradle: { + // `bin` is omitted: it's identical to what dir already inherits + // from its ancestors, so writing it would only pin a value that + // should keep tracking the ancestor's own if that ever changes. + javaHome: '/opt/jdk-17', + gradleOpts: '--offline', + }, + }, + }, + }), + ) + }) + + it('propagates a cancellation from the sub-wizard without writing', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(setupGradle).mockResolvedValue({ + ok: true, + data: { canceled: true }, + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: true } }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('writes nothing when nothing is inherited and every prompt is left blank', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + // A no-op wizard: doesn't set a single field on the seed it's handed. + vi.mocked(setupGradle).mockResolvedValue({ + ok: true, + data: { canceled: false }, + }) + + const result = await configureCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) +}) + +describe('processCandidate', () => { + const cwd = '/repo' + const dir = '/repo/legacy/nested' + + beforeEach(() => { + vi.mocked(select).mockReset() + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(setupGradle).mockReset() + }) + + it('skips silently, without prompting, when the cascade already shows it disabled', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + }) + expect(select).not.toHaveBeenCalled() + }) + + it('offers to re-enable when the candidate own file (not just an ancestor) sets disabled:true, then still asks to configure it', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { + manifest: { gradle: { disabled: true, bin: './gradlew' } }, + }, + }) as SocketJson, + ) + vi.mocked(select).mockImplementation(async ({ message }) => { + if (message.includes('re-enable')) { + return true + } + // The configure-or-leave-as-is question, asked right after re-enabling. + return 'inherit' + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited', reenabled: true }, + }) + expect(writeSocketJson).toHaveBeenCalledWith( + dir, + expect.objectContaining({ + defaults: { + manifest: { gradle: { disabled: false, bin: './gradlew' } }, + }, + }), + ) + }) + + it('declining the re-enable offer leaves the candidate disabled and writes nothing', async () => { + vi.mocked(readSocketJsonCascade).mockReturnValue({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + } as SocketJson) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { gradle: { disabled: true } } }, + }) as SocketJson, + ) + vi.mocked(select).mockResolvedValue(false) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'skipped', reenabled: false }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('does not offer a disable choice - that stays --exclude-paths-only', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockImplementation(async ({ choices }) => { + expect(choices.map((c: { value: unknown }) => c.value)).toEqual([ + 'inherit', + 'configure', + ]) + return 'inherit' + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited', reenabled: false }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('configures the candidate when the user picks configure', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue('configure') + vi.mocked(setupGradle).mockImplementation(async config => { + ;(config as Record)['bin'] = './gradlew' + return { ok: true, data: { canceled: false } } + }) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'configured', reenabled: false }, + }) + expect(setupGradle).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalled() + }) + + it('leaves the candidate untouched when the user picks inherit', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue('inherit') + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result).toEqual({ + ok: true, + data: { canceled: false, outcome: 'inherited', reenabled: false }, + }) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('reports a cancellation from the action prompt itself', async () => { + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(select).mockResolvedValue(null) + + const result = await processCandidate({ + cwd, + dir, + ecosystem: 'gradle', + rootSockJson: emptySockJson(), + }) + + expect(result.ok && result.data.canceled).toBe(true) + expect(writeSocketJson).not.toHaveBeenCalled() + }) +}) + +describe('setupRecursiveManifestConfig', () => { + const cwd = '/repo' + + beforeEach(() => { + vi.mocked(select).mockReset() + vi.mocked(select).mockResolvedValue(false) + vi.mocked(setupGradle).mockReset() + vi.mocked(setupMaven).mockReset() + vi.mocked(setupSbt).mockReset() + vi.mocked(readSocketJsonSync).mockReset() + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: true, + data: emptySockJson(), + })) + vi.mocked(findBuildToolCandidates).mockReset() + // Default: nothing found anywhere - most tests override this per-scenario. + vi.mocked(findBuildToolCandidates).mockResolvedValue(new Map()) + vi.mocked(readOrDefaultSocketJson).mockReset() + vi.mocked(readOrDefaultSocketJson).mockImplementation(() => emptySockJson()) + vi.mocked(readSocketJsonCascade).mockReset() + vi.mocked(readSocketJsonCascade).mockImplementation(() => emptySockJson()) + vi.mocked(writeSocketJson).mockReset() + vi.mocked(writeSocketJson).mockResolvedValue({ ok: true, data: undefined }) + vi.mocked(enumerateWorkspaces).mockReset() + // Default: no reactor members declared anywhere - most tests don't care + // about pruning specifically, so nothing should get collapsed. + vi.mocked(enumerateWorkspaces).mockResolvedValue({ projects: [] }) + }) + + it('asks about nothing and finishes immediately when the scan finds no build roots anywhere', async () => { + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(select).not.toHaveBeenCalled() + expect(setupGradle).not.toHaveBeenCalled() + expect(setupMaven).not.toHaveBeenCalled() + expect(setupSbt).not.toHaveBeenCalled() + expect(writeSocketJson).not.toHaveBeenCalled() + expect(findBuildToolCandidates).toHaveBeenCalled() + }) + + it('only asks about ecosystems detected somewhere in the tree, with plain phrasing', async () => { + // Maven is detected (a candidate exists, elsewhere in the tree); gradle + // and sbt have none anywhere, so neither should ever be asked about. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [`${cwd}/service`]]]), + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + return false + }) + + await setupRecursiveManifestConfig(cwd, false) + + expect(setupGradle).not.toHaveBeenCalled() + expect(setupSbt).not.toHaveBeenCalled() + // Exactly one root question (Maven) - phrased as a nested-project default + // since the only maven candidate is elsewhere in the tree, not at cwd + // itself - no "detected"/"root"/"anyway" wording either way. + expect(messages[0]).toBe( + 'Configure Maven defaults for any nested projects?', + ) + expect(messages[0]).not.toMatch(/detected|root|anyway/i) + }) + + it('distinguishes a project-specific ecosystem from a purely inherited one at the root', async () => { + // cwd IS a maven project (e.g. a reactor root); gradle only exists in a + // project nested somewhere beneath it (e.g. a submodule's own gradle + // build) - the two questions must read differently, since the maven one + // configures settings for cwd itself while the gradle one only ever sets + // an inheritable default for something else. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + ['maven', [cwd]], + ['gradle', [`${cwd}/module-b/standalone-gradle-lib`]], + ]), + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + return false + }) + + await setupRecursiveManifestConfig(cwd, false) + + expect(messages).toEqual([ + 'Configure Maven settings for this project?', + 'Configure Gradle defaults for any nested projects?', + 'Configure other build roots found beneath this one, individually?', + ]) + }) + + it('scans with disabled flags stripped, so an already-disabled ecosystem is still detected', async () => { + vi.mocked(readOrDefaultSocketJson).mockImplementation( + () => + ({ + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + }) as SocketJson, + ) + const seenSockJsons: SocketJson[] = [] + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ sockJson }) => { + seenSockJsons.push(sockJson) + return new Map([['maven', [cwd]]]) + }, + ) + + await setupRecursiveManifestConfig(cwd, false) + + expect(seenSockJsons.length).toBeGreaterThan(0) + for (const seen of seenSockJsons) { + expect(seen.defaults?.manifest?.maven?.disabled).toBe(false) + } + }) + + it('offers to re-enable an ecosystem disabled at the root, before asking to configure it', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: true, + data: { + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + } as SocketJson, + })) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + if (message.includes('re-enable')) { + return true + } + if (message.startsWith('Configure Maven')) { + return false + } + return false + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(messages[0]).toBe('Maven is currently disabled here - re-enable it?') + expect(writeSocketJson).toHaveBeenCalledWith( + cwd, + expect.objectContaining({ + defaults: { manifest: { maven: { disabled: false, bin: 'mvn' } } }, + }), + ) + }) + + it('declining the root re-enable question leaves the ecosystem disabled and skips configuring it', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: true, + data: { + version: 1, + defaults: { manifest: { maven: { disabled: true, bin: 'mvn' } } }, + } as SocketJson, + })) + vi.mocked(select).mockResolvedValueOnce(false) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).not.toHaveBeenCalled() + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('stops when canceling the only detected root ecosystem question', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(select).mockResolvedValueOnce(null) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok && result.data.canceled).toBe(true) + }) + + it('stops when a root ecosystem sub-wizard is canceled', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(select).mockResolvedValueOnce(true) + vi.mocked(setupMaven).mockResolvedValue({ + ok: true, + data: { canceled: true }, + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok && result.data.canceled).toBe(true) + }) + + it('propagates a hard failure reading the root socket.json', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(readSocketJsonSync).mockImplementation(() => ({ + ok: false, + message: 'boom', + })) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok).toBe(false) + expect(findBuildToolCandidates).toHaveBeenCalled() + }) + + it('configures maven at the root (itself the only candidate) and writes it, with no individual-configure gate', async () => { + // The only maven candidate anywhere IS cwd itself, so there's nothing + // left to configure individually afterward. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(select) + // Configure Maven? -> yes. + .mockResolvedValueOnce(true) + vi.mocked(setupMaven).mockImplementation(async config => { + ;(config as Record)['bin'] = './mvnw' + return { ok: true, data: { canceled: false } } + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith(cwd, expect.any(Object)) + // No individual-configure gate: nothing else was found to ask about. + expect(select).toHaveBeenCalledTimes(1) + }) + + it('does not write when the user says yes to configure Maven but leaves every prompt blank', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd]]]), + ) + vi.mocked(select).mockResolvedValueOnce(true) + // A no-op wizard: doesn't set a single field. + vi.mocked(setupMaven).mockResolvedValue({ + ok: true, + data: { canceled: false }, + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(setupMaven).toHaveBeenCalledTimes(1) + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('applies --exclude-paths unconditionally, without asking to configure anything individually', async () => { + const legacy = `${cwd}/legacy` + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([['gradle', []]]) + : new Map([['gradle', [legacy]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledWith( + legacy, + expect.objectContaining({ + defaults: { manifest: { gradle: { disabled: true } } }, + }), + ) + // Nothing left to configure individually (the only candidate was + // excluded), so the individual-configure gate is never asked. + expect(select).not.toHaveBeenCalled() + }) + + it('reports nothing excluded without writing anything, then asks about the remaining candidate individually', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [`${cwd}/active`]]]), + ) + vi.mocked(select) + // Configure Gradle defaults? -> no (gradle is detected via `active`). + .mockResolvedValueOnce(false) + // Configure other build roots individually? -> no. + .mockResolvedValueOnce(false) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(findBuildToolCandidates).toHaveBeenCalled() + expect(writeSocketJson).not.toHaveBeenCalled() + }) + + it('only writes disabled:true to the topmost of an excluded subtree', async () => { + const legacy = `${cwd}/legacy` + const nested = `${legacy}/nested` + const fake = makeFakeGradleDisk(cwd) + vi.mocked(readOrDefaultSocketJson).mockImplementation( + fake.readOrDefaultSocketJson, + ) + vi.mocked(readSocketJsonCascade).mockImplementation( + fake.readSocketJsonCascade, + ) + vi.mocked(writeSocketJson).mockImplementation(fake.writeSocketJson) + vi.mocked(findBuildToolCandidates).mockImplementation( + async ({ excludePaths }) => + excludePaths?.length + ? new Map([['gradle', []]]) + : new Map([['gradle', [legacy, nested]]]), + ) + + const result = await setupRecursiveManifestConfig(cwd, false, ['legacy']) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith( + legacy, + expect.objectContaining({ + defaults: { manifest: { gradle: { disabled: true } } }, + }), + ) + // Everything was excluded, so the individual-configure gate is skipped. + expect(select).not.toHaveBeenCalled() + }) + + it('reports the discovered count and walks the remaining candidate even when no --exclude-paths is given', async () => { + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['gradle', [`${cwd}/active`]]]), + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + // Configure Gradle defaults? -> no (gradle is detected via `active`, + // a nested project, not cwd itself). + if (message.startsWith('Configure Gradle defaults')) { + return false + } + // Configure other build roots individually? -> yes. + if (message.startsWith('Configure other build roots')) { + return true + } + // Candidate action prompt -> anything but 'configure' is a safe no-op + // (inherit), so this never touches writeSocketJson. + return 'inherit' + }) + const logSpy = vi.spyOn(logger, 'log') + + try { + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(writeSocketJson).not.toHaveBeenCalled() + expect(messages).toContain( + 'Configure other build roots found beneath this one, individually?', + ) + const logged = logSpy.mock.calls.map(c => String(c[0])).join('\n') + expect(logged).toMatch(/Detected: Gradle/) + expect(logged).toMatch(/0 configured \(0 re-enabled\), 1 left as-is/) + } finally { + logSpy.mockRestore() + } + }) + + it('configures and leaves candidates inheriting in a single pass, skipping candidates already disabled via cascade', async () => { + const serviceA = `${cwd}/serviceA` + const serviceB = `${cwd}/serviceB` + const serviceBSubmodule = `${serviceB}/submodule` + const serviceC = `${cwd}/serviceC` + + vi.mocked(select) + // Configure Maven? -> no. + .mockResolvedValueOnce(false) + // Configure Gradle? -> no. + .mockResolvedValueOnce(false) + // Configure other build roots individually? -> yes. + .mockResolvedValueOnce(true) + // serviceA (depth 1) -> configure. + .mockResolvedValueOnce('configure') + // serviceC (depth 1) -> inherit. + .mockResolvedValueOnce('inherit') + // serviceB and serviceB/submodule are already disabled - via a prior + // --exclude-paths run, say - so neither is ever prompted. + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([ + ['maven', [serviceA, serviceB, serviceBSubmodule]], + ['gradle', [serviceC]], + ]), + ) + vi.mocked(readSocketJsonCascade).mockImplementation(dir => + dir === serviceB || dir === serviceBSubmodule + ? ({ + version: 1, + defaults: { manifest: { maven: { disabled: true } } }, + } as SocketJson) + : emptySockJson(), + ) + vi.mocked(setupMaven).mockImplementation(async config => { + ;(config as Record)['bin'] = './mvnw' + return { ok: true, data: { canceled: false } } + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + expect(select).toHaveBeenCalledTimes(5) + expect(writeSocketJson).toHaveBeenCalledTimes(1) + expect(writeSocketJson).toHaveBeenCalledWith( + serviceA, + expect.objectContaining({ + defaults: { manifest: { maven: { bin: './mvnw' } } }, + }), + ) + }) + + it('seeds coverage from cwd itself, so a reactor rooted exactly at cwd still gets its declared members pruned', async () => { + // cwd IS the maven reactor root (has its own pom.xml) - discoverBuildRoots + // always excludes cwd from `included` (it already got its own root + // wizard), so without seeding coverage from cwd specifically, module-a + // and module-b would never be recognized as reactor members. + const independentService = `${cwd}/independent-service` + const moduleA = `${cwd}/module-a` + const moduleB = `${cwd}/module-b` + + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [cwd, independentService, moduleA, moduleB]]]), + ) + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => + dir === cwd + ? { + projects: [ + { + type: 'maven', + name: 'module-a', + subprojectDir: 'module-a', + dependencies: [], + resolvedAs: [], + }, + { + type: 'maven', + name: 'module-b', + subprojectDir: 'module-b', + dependencies: [], + resolvedAs: [], + }, + ], + } + : { projects: [] }, + ) + const messages: string[] = [] + vi.mocked(select).mockImplementation(async ({ message }) => { + messages.push(message) + if (message.startsWith('Configure Maven settings')) { + return false + } + if (message.startsWith('Configure other build roots')) { + return true + } + return 'inherit' + }) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result).toEqual({ ok: true, data: { canceled: false } }) + // Only independent-service is ever asked about - module-a/module-b are + // recognized as covered via cwd's own enumeration and never prompted. + // cwd itself is a maven project (the reactor root), so the root question + // is phrased as this project's own settings, not a nested-project default. + expect(messages).toEqual([ + 'Configure Maven settings for this project?', + 'Configure other build roots found beneath this one, individually?', + 'independent-service (maven)', + ]) + }) + + it("aborts the whole walk (fail-closed) when a candidate's workspace layout cannot be determined", async () => { + const independentService = `${cwd}/independent-service` + const laterCandidate = `${cwd}/zzz-later` + + vi.mocked(findBuildToolCandidates).mockResolvedValue( + new Map([['maven', [independentService, laterCandidate]]]), + ) + vi.mocked(select).mockImplementation(async ({ message }) => { + if (message.startsWith('Configure Maven defaults')) { + return false + } + if (message.startsWith('Configure other build roots')) { + return true + } + return 'inherit' + }) + // Simulates a missing $JAVA8_HOME env var reference: enumeration fails + // for independent-service specifically. + vi.mocked(enumerateWorkspaces).mockImplementation(async ({ cwd: dir }) => + dir === independentService ? undefined : { projects: [] }, + ) + + const result = await setupRecursiveManifestConfig(cwd, false) + + expect(result.ok).toBe(false) + // The later candidate is never reached - the walk aborted right after + // independent-service's own enumeration failed, not after processing + // everything and reporting failures at the end. + expect(enumerateWorkspaces).not.toHaveBeenCalledWith( + expect.objectContaining({ cwd: laterCandidate }), + ) + }) +}) diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 3cbbfee23f..a20ace474b 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -60,35 +60,55 @@ export interface SocketJson { verbose?: boolean | undefined } gradle?: { + // Root-only: gates auto-detection for socket manifest auto/gradle. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + // A field set to null explicitly clears an inherited cascade value + // (widens back to the tool default) instead of restating it. + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null facts?: boolean | undefined - gradleOpts?: string | undefined + gradleOpts?: string | undefined | null ignoreUnresolved?: boolean | undefined + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. + javaHome?: string | undefined | null verbose?: boolean | undefined } maven?: { + // Root-only: gates auto-detection for socket manifest auto/maven. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null ignoreUnresolved?: boolean | undefined - mavenOpts?: string | undefined + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. + javaHome?: string | undefined | null + mavenOpts?: string | undefined | null verbose?: boolean | undefined } sbt?: { + // Root-only: gates auto-detection for socket manifest auto/scala. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. disabled?: boolean | undefined - infile?: string | undefined + infile?: string | undefined | null stdin?: boolean | undefined - bin?: string | undefined - excludeConfigs?: string | undefined - includeConfigs?: string | undefined + bin?: string | undefined | null + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null facts?: boolean | undefined ignoreUnresolved?: boolean | undefined - outfile?: string | undefined - sbtOpts?: string | undefined + // JDK path; sets JAVA_HOME for this ecosystem's build tool. Supports + // $VAR/${VAR} expansion against the CLI's own environment. + javaHome?: string | undefined | null + outfile?: string | undefined | null + sbtOpts?: string | undefined | null stdout?: boolean | undefined verbose?: boolean | undefined } @@ -137,6 +157,74 @@ export async function readOrDefaultSocketJsonUp( return getDefaultSocketJson() } +// Shallow-merges `defaults.manifest.` per ecosystem: fields set in +// `override` win, others fall through to `base`. Only this section cascades; +// everything else in `defaults` comes from `base` only. +function mergeManifestDefaults( + base: SocketJson, + override: SocketJson, +): SocketJson { + const overrideManifest = override.defaults?.manifest + if (!overrideManifest) { + return base + } + const baseManifest = base.defaults?.manifest + const mergedManifest: NonNullable< + NonNullable['manifest'] + > = { ...baseManifest } + if (overrideManifest.bazel) { + mergedManifest.bazel = { ...baseManifest?.bazel, ...overrideManifest.bazel } + } + if (overrideManifest.conda) { + mergedManifest.conda = { ...baseManifest?.conda, ...overrideManifest.conda } + } + if (overrideManifest.gradle) { + mergedManifest.gradle = { + ...baseManifest?.gradle, + ...overrideManifest.gradle, + } + } + if (overrideManifest.maven) { + mergedManifest.maven = { ...baseManifest?.maven, ...overrideManifest.maven } + } + if (overrideManifest.sbt) { + mergedManifest.sbt = { ...baseManifest?.sbt, ...overrideManifest.sbt } + } + return { + ...base, + defaults: { ...base.defaults, manifest: mergedManifest }, + } +} + +// Cascades `defaults.manifest.*` from `rootSockJson` down to `dir`: each +// ancestor's own socket.json, between `dir` and `boundaryDir` (exclusive), +// merges in, nearest-to-`dir` winning field by field. +export function readSocketJsonCascade( + dir: string, + boundaryDir: string, + rootSockJson: SocketJson, +): SocketJson { + const boundary = path.resolve(boundaryDir) + let current = path.resolve(dir) + // Farthest-from-dir first, so the merge loop below applies overrides in + // increasing precedence and the nearest-to-dir file wins last. + const ancestorsFarToNear: SocketJson[] = [] + while (current !== boundary) { + if (existsSync(path.join(current, SOCKET_JSON))) { + const jsonCResult = readSocketJsonSync(current, true) + if (jsonCResult.ok) { + ancestorsFarToNear.unshift(jsonCResult.data) + } + } + const parent = path.dirname(current) + if (parent === current) { + break + } + current = parent + } + return ancestorsFarToNear.reduce(mergeManifestDefaults, rootSockJson) +} + export function getDefaultSocketJson(): SocketJson { return { ' _____ _ _ ': `Local config file for Socket CLI tool ( ${SOCKET_WEBSITE_URL}/npm/package/${SOCKET_JSON.replace('.json', '')} ), to work with ${SOCKET_WEBSITE_URL}`, diff --git a/src/utils/socket-json.test.mts b/src/utils/socket-json.test.mts new file mode 100644 index 0000000000..f7b61e92f3 --- /dev/null +++ b/src/utils/socket-json.test.mts @@ -0,0 +1,154 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { readSocketJsonCascade } from './socket-json.mts' + +import type { SocketJson } from './socket-json.mts' + +async function writeSocketJson(dir: string, data: unknown): Promise { + await fs.writeFile( + path.join(dir, 'socket.json'), + JSON.stringify(data), + 'utf8', + ) +} + +function mavenConfig(sockJson: SocketJson) { + return sockJson.defaults?.manifest?.maven +} + +describe('readSocketJsonCascade', () => { + let root = '' + let rootSockJson: SocketJson + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(tmpdir(), 'socket-json-cascade-')) + rootSockJson = { + version: 1, + defaults: { + manifest: { + maven: { bin: 'mvn', excludeConfigs: 'root-exclude' }, + }, + }, + } as SocketJson + }) + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) + }) + + it('returns rootSockJson unchanged when dir is the boundary itself', () => { + expect(readSocketJsonCascade(root, root, rootSockJson)).toBe(rootSockJson) + }) + + it('returns rootSockJson unchanged when nothing is found between dir and the boundary', async () => { + const buildRoot = path.join(root, 'workspace', 'project') + await fs.mkdir(buildRoot, { recursive: true }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result).toBe(rootSockJson) + }) + + it("merges the build root's own socket.json over the root config instead of replacing it", async () => { + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + bin: 'mvn', + excludeConfigs: 'root-exclude', + javaHome: '/opt/jdk-11', + }) + }) + + it('does not touch an ecosystem the override never mentions', async () => { + rootSockJson = { + version: 1, + defaults: { + manifest: { + gradle: { bin: './gradlew' }, + maven: { bin: 'mvn' }, + }, + }, + } as SocketJson + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result.defaults?.manifest?.gradle).toEqual({ bin: './gradlew' }) + }) + + it('cascades multiple levels, nearest-to-dir winning per field', async () => { + const workspace = path.join(root, 'workspace') + const buildRoot = path.join(workspace, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(workspace, { + version: 1, + defaults: { + manifest: { + maven: { + excludeConfigs: 'workspace-exclude', + includeConfigs: 'workspace-include', + }, + }, + }, + }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { javaHome: '/opt/jdk-11' } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + // From root, untouched by either override. + bin: 'mvn', + // Workspace overrides root; project doesn't mention it. + excludeConfigs: 'workspace-exclude', + // From workspace only. + includeConfigs: 'workspace-include', + // From the nearest file only. + javaHome: '/opt/jdk-11', + }) + }) + + it('treats an explicit null as clearing an inherited value, not restating it', async () => { + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + await writeSocketJson(buildRoot, { + version: 1, + defaults: { manifest: { maven: { excludeConfigs: null } } }, + }) + + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(mavenConfig(result)).toEqual({ + bin: 'mvn', + excludeConfigs: null, + }) + }) + + it('does not walk past the boundary even if an ancestor above it has one', async () => { + await writeSocketJson(tmpdir(), { + version: 1, + defaults: { manifest: { maven: { javaHome: '/outside-scope' } } }, + }) + const buildRoot = path.join(root, 'project') + await fs.mkdir(buildRoot, { recursive: true }) + + try { + const result = readSocketJsonCascade(buildRoot, root, rootSockJson) + expect(result).toBe(rootSockJson) + } finally { + await fs.rm(path.join(tmpdir(), 'socket.json'), { force: true }) + } + }) +}) diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle new file mode 100644 index 0000000000..075ba3d563 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml new file mode 100644 index 0000000000..a2405658c6 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + dual-marker-dir + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle new file mode 100644 index 0000000000..075ba3d563 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'java' +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml new file mode 100644 index 0000000000..64a4821efe --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + reactor + 1.0.0 + + moduleA + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml new file mode 100644 index 0000000000..d3cdbf46ae --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + independent-submodule + 1.0.0 + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml new file mode 100644 index 0000000000..a4b77f171b --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + reactor + 1.0.0 + + moduleB + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml new file mode 100644 index 0000000000..98b6e4f97f --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + reactor + 1.0.0 + pom + + moduleA + moduleB + + diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts new file mode 100644 index 0000000000..3b371f30bb --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + java +} diff --git a/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts new file mode 100644 index 0000000000..5aff8a1e56 --- /dev/null +++ b/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "standalone-gradle" diff --git a/test/utils.mts b/test/utils.mts index 5badbc0bf7..8c814c6536 100644 --- a/test/utils.mts +++ b/test/utils.mts @@ -69,8 +69,14 @@ function normalizeCoanaVersion(str: string): string { function normalizeBanner(str: string): string { return ( str - // Replace CLI version like "v1.1.67" with "". - .replace(/\| CLI: v[\d.]+/g, '| CLI: ') + // Replace a version like "v1.1.67" or "v1.1.67-prerelease" with + // "" - also matches an already-redacted value, since whether + // the CLI itself redacts depends on env baked in at build time, not + // just this test run. + .replace( + /\| CLI: (?:v[\d.]+(?:[-+][\w.]+)?|)/g, + '| CLI: ', + ) // Replace token and org info with "". .replace( /\| (?:Node: [^,]+, )?token: [^,]+, (?:org: [^\n"]+)/g,