diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index ecb676aa0..e9341c3e7 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -34,6 +34,7 @@ import { const { CONSTANTS, INLINED_SOCKET_CLI_LEGACY_BUILD, + INLINED_SOCKET_CLI_PUBLISHED_BUILD, INLINED_SOCKET_CLI_SENTRY_BUILD, INSTRUMENT_WITH_SENTRY, NODE_MODULES, @@ -79,6 +80,48 @@ async function copyInitGradle() { await fs.copyFile(filepath, destPath) } +// Copy the JVM build-tool resolution assets (Gradle init script, sbt plugin, +// Maven extension jar) into dist/manifest-scripts, where run.mts resolves them +// at runtime. The Maven jar is compiled by maven-extension/build-jar.sh (run in +// CI / local dev) and is absent from a fresh checkout — copy it only if present; +// run.mts surfaces a build hint when it's missing. +async function copyManifestScripts() { + const srcDir = path.join(constants.srcPath, 'commands/manifest/scripts') + const destDir = path.join(constants.distPath, 'manifest-scripts') + await fs.mkdir(path.join(destDir, 'maven-extension'), { recursive: true }) + await Promise.all([ + fs.copyFile( + path.join(srcDir, 'socket-facts.init.gradle'), + path.join(destDir, 'socket-facts.init.gradle'), + ), + fs.copyFile( + path.join(srcDir, 'socket-facts.plugin.scala'), + path.join(destDir, 'socket-facts.plugin.scala'), + ), + ]) + const jarPath = path.join( + srcDir, + 'maven-extension', + 'coana-maven-extension.jar', + ) + if (existsSync(jarPath)) { + await fs.copyFile( + jarPath, + path.join(destDir, 'maven-extension', 'coana-maven-extension.jar'), + ) + } else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) { + // Fail closed: a published build without the jar would ship a package whose + // `socket manifest maven` / Maven reachability silently produces an empty + // SBOM. Run `pnpm run build:maven-extension` before `build:dist`. (A local + // dev build tolerates a missing jar; run.mts surfaces a hint at runtime.) + throw new Error( + 'Maven manifest extension jar not found at ' + + jarPath + + ' for a published build. Build it first: pnpm run build:maven-extension', + ) + } +} + async function copyBashCompletion() { const filepath = path.join( constants.srcPath, @@ -464,6 +507,7 @@ export default async () => { async writeBundle() { await Promise.all([ copyInitGradle(), + copyManifestScripts(), copyBashCompletion(), updatePackageJson(), // Remove dist/vendor.js.map file. diff --git a/.github/workflows/maven-extension-jar.yml b/.github/workflows/maven-extension-jar.yml new file mode 100644 index 000000000..258dc331c --- /dev/null +++ b/.github/workflows/maven-extension-jar.yml @@ -0,0 +1,46 @@ +name: Maven extension jar + +# Builds (and smoke-tests) the Maven manifest extension jar in CI, separately +# from release. Uses only allowlisted actions — notably NOT actions/setup-java +# (the org allowlist forbids it), so it relies on a JDK pre-installed on the +# runner via JAVA_HOME_17_X64, the same approach provenance.yml uses to build +# the jar at release. Runs on changes to the extension and on demand. + +on: + pull_request: + paths: + - 'src/commands/manifest/scripts/maven-extension/**' + - 'src/commands/manifest/scripts/test/maven-compat/**' + - '.github/workflows/maven-extension-jar.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Build the Maven extension jar (pre-installed JDK; no setup-java) + run: | + if [ -n "${JAVA_HOME_17_X64:-}" ]; then + export JAVA_HOME="$JAVA_HOME_17_X64" + fi + java -version + bash src/commands/manifest/scripts/maven-extension/build-jar.sh + - name: Verify the jar was produced + run: test -f src/commands/manifest/scripts/maven-extension/coana-maven-extension.jar + - name: Smoke-test the extension on Maven 3.9.9 + run: | + if [ -n "${JAVA_HOME_17_X64:-}" ]; then + export JAVA_HOME="$JAVA_HOME_17_X64" + fi + ver=3.9.9 + curl -fsSL "https://archive.apache.org/dist/maven/maven-3/$ver/binaries/apache-maven-$ver-bin.zip" -o maven.zip + unzip -q maven.zip + bash src/commands/manifest/scripts/test/maven-compat/smoke-test.sh \ + "$PWD/apache-maven-$ver/bin/mvn" \ + "$PWD/src/commands/manifest/scripts/maven-extension/coana-maven-extension.jar" diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 4de4ff206..3cc35b6de 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -204,6 +204,18 @@ jobs: - name: Install dependencies run: pnpm install --loglevel error + # Compile the Maven manifest extension jar so the dist build bundles it + # into dist/manifest-scripts (the jar is never committed; it ships only in + # the published package). The org action allowlist forbids actions/setup-java, + # so use a JDK pre-installed on the runner image (JAVA_HOME_17_X64), falling + # back to the runner's default `java`. build-jar.sh uses the Maven wrapper. + - name: Build Maven manifest extension jar + run: | + if [ -n "${JAVA_HOME_17_X64:-}" ]; then + export JAVA_HOME="$JAVA_HOME_17_X64" + fi + pnpm run build:maven-extension + - run: INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 pnpm run build:dist - name: Publish socket id: publish_socket diff --git a/CHANGELOG.md b/CHANGELOG.md index 269694c53..ce273d8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.132](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.132) - 2026-06-30 + +### Changed +- More reliable reachability for Gradle, sbt, and Maven projects with dynamic versions (git versions, CI build numbers, timestamps): the build is resolved once and its artifact paths reused, avoiding spurious "failed to install" errors. +- `socket manifest` and `--auto-manifest` now prefer your project's build-tool wrapper (`./gradlew`, `./mvnw`) when present, falling back to `gradle`/`mvn` on PATH. +- Updated the Coana CLI to v `15.6.3`. + ## [1.1.131](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.131) - 2026-06-29 ### Changed diff --git a/package.json b/package.json index e7849d99d..c0467e3a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.131", + "version": "1.1.132", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT", @@ -36,6 +36,7 @@ "build:dist": "pnpm build:dist:src && pnpm build:dist:types", "build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs", "build:dist:types": "pnpm clean:dist:types && tsgo --project tsconfig.dts.json", + "build:maven-extension": "bash src/commands/manifest/scripts/maven-extension/build-jar.sh", "build:sea": "node src/sea/build-sea.mts", "build:sea:internal:bootstrap": "rollup -c .config/rollup.sea.config.mjs", "check": "pnpm check:lint && pnpm check:tsc", @@ -96,7 +97,7 @@ "@babel/preset-typescript": "7.27.1", "@babel/runtime": "7.28.4", "@biomejs/biome": "2.2.4", - "@coana-tech/cli": "15.6.2", + "@coana-tech/cli": "15.6.3", "@cyclonedx/cdxgen": "12.1.2", "@dotenvx/dotenvx": "1.49.0", "@eslint/compat": "1.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe9ffe8ba..7d6adcf9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,8 +128,8 @@ importers: specifier: 2.2.4 version: 2.2.4 '@coana-tech/cli': - specifier: 15.6.2 - version: 15.6.2 + specifier: 15.6.3 + version: 15.6.3 '@cyclonedx/cdxgen': specifier: 12.1.2 version: 12.1.2 @@ -749,8 +749,8 @@ packages: resolution: {integrity: sha512-hAs5PPKPCQ3/Nha+1fo4A4/gL85fIfxZwHPehsjCJ+BhQH2/yw6/xReuaPA/RfNQr6iz1PcD7BZcE3ctyyl3EA==} cpu: [x64] - '@coana-tech/cli@15.6.2': - resolution: {integrity: sha512-vykL9NpnKz2ZfN6Pp7afRLmfCwICptMcEt1ZkX+eUhEhPjtbQT/0g8PegNuDmrF+m1PzSavqC9PPmRWGfEMGCw==} + '@coana-tech/cli@15.6.3': + resolution: {integrity: sha512-Z2gfuZURKd7fmYuyBgy/WsxGUKbSjCcI5nNU4Hjrk5/DjP1ihxHJK3sJp4/zcU/TwdCaqU4J13ZhuaXO4nkYPw==} hasBin: true '@colors/colors@1.5.0': @@ -5388,7 +5388,7 @@ snapshots: '@cdxgen/cdxgen-plugins-bin@2.0.2': optional: true - '@coana-tech/cli@15.6.2': {} + '@coana-tech/cli@15.6.3': {} '@colors/colors@1.5.0': optional: true diff --git a/socket.yml b/socket.yml index ad9c526bf..3d684efe6 100644 --- a/socket.yml +++ b/socket.yml @@ -2,3 +2,8 @@ version: 2 projectIgnorePaths: - "test/fixtures/" + # Build-tooling and test fixtures for the JVM manifest scripts, not socket-cli's + # own supply chain: the Maven extension's build pom and the compat-test + # projects' pom.xml / build.gradle / build.sbt. + - "src/commands/manifest/scripts/maven-extension/" + - "src/commands/manifest/scripts/test/" diff --git a/src/commands/manifest/cmd-manifest-gradle.mts b/src/commands/manifest/cmd-manifest-gradle.mts index 5f8b1d43b..e09ac7de7 100644 --- a/src/commands/manifest/cmd-manifest-gradle.mts +++ b/src/commands/manifest/cmd-manifest-gradle.mts @@ -6,6 +6,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 { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -28,7 +29,8 @@ const config: CliCommandConfig = { ...commonFlags, bin: { type: 'string', - description: 'Location of gradlew binary to use, default: CWD/gradlew', + description: + 'Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH', }, facts: { type: 'boolean', @@ -156,7 +158,8 @@ async function run( bin = sockJson.defaults?.manifest?.gradle?.bin logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) } else { - bin = path.join(cwd, 'gradlew') + // Prefer the project's ./gradlew wrapper, else `gradle` on PATH. + bin = resolveBuildToolBin('gradle', cwd) } } if (!gradleOpts) { diff --git a/src/commands/manifest/cmd-manifest-gradle.test.mts b/src/commands/manifest/cmd-manifest-gradle.test.mts index 361da2d79..fafc155d6 100644 --- a/src/commands/manifest/cmd-manifest-gradle.test.mts +++ b/src/commands/manifest/cmd-manifest-gradle.test.mts @@ -23,7 +23,7 @@ describe('socket manifest gradle', async () => { $ socket manifest gradle [options] [CWD=.] Options - --bin Location of gradlew binary to use, default: CWD/gradlew + --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) --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\` diff --git a/src/commands/manifest/cmd-manifest-kotlin.mts b/src/commands/manifest/cmd-manifest-kotlin.mts index 1bcc008ac..606c3f9b9 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.mts @@ -6,6 +6,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 { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -33,7 +34,8 @@ const config: CliCommandConfig = { ...commonFlags, bin: { type: 'string', - description: 'Location of gradlew binary to use, default: CWD/gradlew', + description: + 'Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH', }, facts: { type: 'boolean', @@ -161,7 +163,8 @@ async function run( bin = sockJson.defaults?.manifest?.gradle?.bin logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) } else { - bin = path.join(cwd, 'gradlew') + // Prefer the project's ./gradlew wrapper, else `gradle` on PATH. + bin = resolveBuildToolBin('gradle', cwd) } } if (!gradleOpts) { diff --git a/src/commands/manifest/cmd-manifest-kotlin.test.mts b/src/commands/manifest/cmd-manifest-kotlin.test.mts index 3b9dc94a8..befb8bd76 100644 --- a/src/commands/manifest/cmd-manifest-kotlin.test.mts +++ b/src/commands/manifest/cmd-manifest-kotlin.test.mts @@ -23,7 +23,7 @@ describe('socket manifest kotlin', async () => { $ socket manifest kotlin [options] [CWD=.] Options - --bin Location of gradlew binary to use, default: CWD/gradlew + --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) --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\` diff --git a/src/commands/manifest/cmd-manifest-maven.mts b/src/commands/manifest/cmd-manifest-maven.mts index b2b863e3c..f28510844 100644 --- a/src/commands/manifest/cmd-manifest-maven.mts +++ b/src/commands/manifest/cmd-manifest-maven.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { convertMavenToFacts } from './convert-maven-to-facts.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -27,7 +28,8 @@ const config: CliCommandConfig = { ...commonFlags, bin: { type: 'string', - description: 'Location of the maven binary to use, default: mvn on PATH', + description: + 'Location of the maven binary to use, default: ./mvnw if present, else mvn on PATH', }, includeConfigs: { type: 'string', @@ -136,7 +138,8 @@ async function run( bin = sockJson.defaults?.manifest?.maven?.bin logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) } else { - bin = 'mvn' + // Prefer the project's ./mvnw wrapper, else `mvn` on PATH. + bin = resolveBuildToolBin('maven', cwd) } } if (!mavenOpts) { diff --git a/src/commands/manifest/cmd-manifest-maven.test.mts b/src/commands/manifest/cmd-manifest-maven.test.mts index c4dc818b3..765abe9e5 100644 --- a/src/commands/manifest/cmd-manifest-maven.test.mts +++ b/src/commands/manifest/cmd-manifest-maven.test.mts @@ -22,7 +22,7 @@ describe('socket manifest maven', async () => { $ socket manifest maven [options] [CWD=.] Options - --bin Location of the maven binary to use, default: mvn on PATH + --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) --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 diff --git a/src/commands/manifest/coana-manifest-facts.mts b/src/commands/manifest/coana-manifest-facts.mts deleted file mode 100644 index 47ac6fb72..000000000 --- a/src/commands/manifest/coana-manifest-facts.mts +++ /dev/null @@ -1,124 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { logger } from '@socketsecurity/registry/lib/logger' - -import constants from '../../constants.mts' -import { spawnCoanaDlx } from '../../utils/dlx.mts' - -// Delegates Socket facts generation for a JVM build tool to the Coana CLI's -// `manifest ` command. The build-tool resolution scripts (the Gradle -// init script and the sbt plugin) live in Coana now, so socket-cli no longer -// runs them itself; it only asks Coana for the uploadable `.socket.facts.json`. -// -// The resolved artifact-paths sidecar is intentionally NOT requested here: it -// only matters for reachability analysis, which is internal to Coana, so Coana -// emits it itself when it runs reachability. `socket manifest` only needs the -// facts file. -// -// `spawnCoanaDlx` resolves the Coana CLI via dlx (or a local build when -// `SOCKET_CLI_COANA_LOCAL_PATH` is set). `bin` (the gradle/maven/sbt executable) -// is always resolved by the caller to a concrete default (`/gradlew`, or -// `mvn`/`sbt` on PATH) before we get here, so it is forwarded verbatim; the -// empty guard below is just a cheap safeguard against passing `--bin ''`. -export async function runCoanaManifestFacts({ - bin, - buildOpts, - buildOptsFlag, - cwd, - ecosystem, - excludeConfigs, - ignoreUnresolved, - includeConfigs, - verbose, -}: { - bin: string - buildOpts: string[] - buildOptsFlag: '--gradle-opts' | '--maven-opts' | '--sbt-opts' - cwd: string - ecosystem: 'gradle' | 'maven' | 'sbt' - excludeConfigs: string - ignoreUnresolved: boolean - includeConfigs: string - verbose: boolean -}): Promise { - // Pin the facts output location explicitly rather than relying on Coana's - // "project root" default. `factsPath` is then the single source of truth for - // both what we tell Coana to write and what we verify exists below, so the - // two can't drift apart if Coana's default ever changes. This is deliberately - // NOT user-configurable: Socket facts always land in the project root so that - // `socket scan create ` finds them (see cmd-manifest-scala.mts, which - // rejects --out/--stdout in facts mode). - const factsDir = cwd - const factsFile = constants.DOT_SOCKET_DOT_FACTS_JSON - const factsPath = path.join(factsDir, factsFile) - // `coana manifest ` emits `.socket.facts.json` by default; - // there is no `--facts` flag (the artifact-paths sidecar is reachability- - // internal and not requested here). - const coanaArgs: string[] = [ - 'manifest', - ecosystem, - cwd, - '--output-dir', - factsDir, - '--output-file', - factsFile, - ] - if (bin) { - coanaArgs.push('--bin', bin) - } - if (includeConfigs) { - coanaArgs.push('--include-configs', includeConfigs) - } - if (excludeConfigs) { - coanaArgs.push('--exclude-configs', excludeConfigs) - } - if (ignoreUnresolved) { - coanaArgs.push('--ignore-unresolved') - } - if (verbose) { - coanaArgs.push('--debug') - } - // `--gradle-opts` / `--sbt-opts` are variadic on the Coana side; keep them - // last so the pass-through values don't swallow any following flags. - if (buildOpts.length) { - coanaArgs.push(buildOptsFlag, ...buildOpts) - } - - logger.log( - `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, - ) - if (verbose) { - logger.log('[VERBOSE] coana args:', coanaArgs) - } - - // Stream Coana's output so the user sees build-tool progress and Coana's own - // "Socket facts file written to: ..." line. - const result = await spawnCoanaDlx( - coanaArgs, - undefined, - { cwd }, - { stdio: 'inherit' }, - ) - if (!result.ok) { - process.exitCode = 1 - logger.fail(result.message || 'Coana failed to generate Socket facts') - return - } - // A zero exit code doesn't guarantee a facts file was written: Coana skips - // emitting it when there are no resolvable dependencies (e.g. with - // --ignore-unresolved). We pinned the output to `factsPath` above, so confirm - // it exists before claiming success; otherwise the "next step: socket scan - // create" line would mislead. - if (!existsSync(factsPath)) { - logger.warn( - `Coana completed but wrote no ${factsFile} (no resolvable dependencies?); nothing to upload.`, - ) - return - } - logger.success('Generated Socket facts') - logger.log('') - logger.log( - 'Next step is to generate a Scan by running the `socket scan create` command on the same directory.', - ) -} diff --git a/src/commands/manifest/convert-gradle-to-facts.mts b/src/commands/manifest/convert-gradle-to-facts.mts index 31d5febb1..6b7ad2144 100644 --- a/src/commands/manifest/convert-gradle-to-facts.mts +++ b/src/commands/manifest/convert-gradle-to-facts.mts @@ -1,10 +1,8 @@ -import { runCoanaManifestFacts } from './coana-manifest-facts.mts' +import { runManifestFacts } from './run-manifest-facts.mts' -// Generates a `.socket.facts.json` for a Gradle project by delegating to the -// Coana CLI's `manifest gradle` command (which owns the Gradle init script that -// resolves the dependency graph). socket-cli no longer runs gradle itself; an -// explicit `bin` is forwarded as `--bin`, otherwise Coana defaults to -// `./gradlew`. +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// Generates `.socket.facts.json` for a Gradle project via the bundled init script. export async function convertGradleToFacts({ bin, cwd, @@ -12,7 +10,9 @@ export async function convertGradleToFacts({ gradleOpts, ignoreUnresolved, includeConfigs, + sidecarAcc, verbose, + withFiles, }: { bin: string cwd: string @@ -20,17 +20,20 @@ export async function convertGradleToFacts({ gradleOpts: string[] ignoreUnresolved: boolean includeConfigs: string + sidecarAcc?: SidecarAccumulator | undefined verbose: boolean + withFiles?: boolean | undefined }): Promise { - await runCoanaManifestFacts({ + await runManifestFacts({ bin, buildOpts: gradleOpts, - buildOptsFlag: '--gradle-opts', cwd, ecosystem: 'gradle', excludeConfigs, ignoreUnresolved, includeConfigs, + sidecarAcc, verbose, + withFiles, }) } diff --git a/src/commands/manifest/convert-maven-to-facts.mts b/src/commands/manifest/convert-maven-to-facts.mts index 5255321ce..c9a8f16c0 100644 --- a/src/commands/manifest/convert-maven-to-facts.mts +++ b/src/commands/manifest/convert-maven-to-facts.mts @@ -1,10 +1,8 @@ -import { runCoanaManifestFacts } from './coana-manifest-facts.mts' +import { runManifestFacts } from './run-manifest-facts.mts' -// Generates a `.socket.facts.json` for a Maven project by delegating to the -// Coana CLI's `manifest maven` command (which owns the Maven plugin that -// resolves the dependency graph). socket-cli no longer runs maven itself; an -// explicit `bin` is forwarded as `--bin`, otherwise Coana defaults to `mvn` on -// PATH. +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// Generates `.socket.facts.json` for a Maven project via the bundled extension. export async function convertMavenToFacts({ bin, cwd, @@ -12,7 +10,9 @@ export async function convertMavenToFacts({ ignoreUnresolved, includeConfigs, mavenOpts, + sidecarAcc, verbose, + withFiles, }: { bin: string cwd: string @@ -20,17 +20,20 @@ export async function convertMavenToFacts({ ignoreUnresolved: boolean includeConfigs: string mavenOpts: string[] + sidecarAcc?: SidecarAccumulator | undefined verbose: boolean + withFiles?: boolean | undefined }): Promise { - await runCoanaManifestFacts({ + await runManifestFacts({ bin, buildOpts: mavenOpts, - buildOptsFlag: '--maven-opts', cwd, ecosystem: 'maven', excludeConfigs, ignoreUnresolved, includeConfigs, + sidecarAcc, verbose, + withFiles, }) } diff --git a/src/commands/manifest/convert-sbt-to-facts.mts b/src/commands/manifest/convert-sbt-to-facts.mts index 4e6124c87..9bf913e17 100644 --- a/src/commands/manifest/convert-sbt-to-facts.mts +++ b/src/commands/manifest/convert-sbt-to-facts.mts @@ -1,12 +1,10 @@ -import { runCoanaManifestFacts } from './coana-manifest-facts.mts' +import { runManifestFacts } from './run-manifest-facts.mts' -// Generates a `.socket.facts.json` for an sbt project by delegating to the -// Coana CLI's `manifest sbt` command (which owns the sbt plugin that resolves -// the dependency graph). socket-cli no longer runs sbt itself; an explicit -// `bin` is forwarded as `--bin`, otherwise Coana defaults to `sbt` on PATH. -// JDK-compatibility guidance (sbt 0.13/early 1.x cannot run on modern JDKs) is -// handled by Coana; pass a compatible JDK via `--sbt-opts "--java-home "` -// or `JAVA_HOME`. +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// Generates `.socket.facts.json` for an sbt project via the bundled sbt plugin. +// sbt 0.13/early 1.x can't run on modern JDKs — pass a compatible JDK via +// `--sbt-opts "--java-home "` or `JAVA_HOME`. export async function convertSbtToFacts({ bin, cwd, @@ -14,7 +12,9 @@ export async function convertSbtToFacts({ ignoreUnresolved, includeConfigs, sbtOpts, + sidecarAcc, verbose, + withFiles, }: { bin: string cwd: string @@ -22,17 +22,20 @@ export async function convertSbtToFacts({ ignoreUnresolved: boolean includeConfigs: string sbtOpts: string[] + sidecarAcc?: SidecarAccumulator | undefined verbose: boolean + withFiles?: boolean | undefined }): Promise { - await runCoanaManifestFacts({ + await runManifestFacts({ bin, buildOpts: sbtOpts, - buildOptsFlag: '--sbt-opts', cwd, ecosystem: 'sbt', excludeConfigs, ignoreUnresolved, includeConfigs, + sidecarAcc, verbose, + withFiles, }) } diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 1742d2d89..1ad3f4cbd 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -10,30 +10,54 @@ import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { convertSbtToMaven } from './convert_sbt_to_maven.mts' import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { serializeSidecar } from './scripts/sidecar.mts' import { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import type { GeneratableManifests } from './detect-manifest-actions.mts' +import type { + ResolvedPathsSidecar, + SidecarAccumulator, +} from './scripts/sidecar.mts' import type { OutputKind } from '../../types.mts' export type GenerateAutoManifestResult = { generatedFiles: string[] + // Reachability path only: resolved on-disk paths from the build-tool run. + resolvedPathsSidecar?: ResolvedPathsSidecar | undefined } export async function generateAutoManifest({ + computeArtifactsSidecar, cwd, detected, outputKind, + reachContinueOnInstallErrors, verbose, }: { + // Reachability path: run build tools with files to emit the sidecar. + computeArtifactsSidecar?: boolean | undefined detected: GeneratableManifests cwd: string outputKind: OutputKind + // Reachability install-error gate: tolerate a blocking resolution failure. + reachContinueOnInstallErrors?: boolean | undefined verbose: boolean }): Promise { const sockJson = readOrDefaultSocketJson(cwd) const generatedFiles: string[] = [] + // Resolved paths across all JVM roots, serialized to one sidecar at the end. + const sidecarAcc: SidecarAccumulator | undefined = computeArtifactsSidecar + ? new Map() + : undefined + // Reachability: the install-error gate decides abort; manifest path: socket.json. + const resolveIgnoreUnresolved = (configured: boolean): boolean => + computeArtifactsSidecar + ? configured || Boolean(reachContinueOnInstallErrors) + : configured + if (verbose) { logger.info(`Using this ${SOCKET_JSON} for defaults:`, sockJson) } @@ -56,10 +80,12 @@ export async function generateAutoManifest({ await convertSbtToFacts({ ...sbtArgs, excludeConfigs: sockJson.defaults?.manifest?.sbt?.excludeConfigs ?? '', - ignoreUnresolved: Boolean( - sockJson.defaults?.manifest?.sbt?.ignoreUnresolved, + ignoreUnresolved: resolveIgnoreUnresolved( + Boolean(sockJson.defaults?.manifest?.sbt?.ignoreUnresolved), ), includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '', + sidecarAcc, + withFiles: computeArtifactsSidecar, }) } else { logger.log('Detected a Scala sbt build, generating pom files with sbt...') @@ -72,12 +98,10 @@ export async function generateAutoManifest({ if (!sockJson?.defaults?.manifest?.gradle?.disabled && detected.gradle) { const gradleArgs = { - // Note: `gradlew` is more likely to be resolved against cwd. - // Note: .resolve() won't butcher an absolute path. - // TODO: `gradlew` (or anything else given) may want to resolve against PATH. + // Configured bin wins; else prefer ./gradlew, else gradle on PATH. bin: sockJson.defaults?.manifest?.gradle?.bin ? path.resolve(cwd, sockJson.defaults.manifest.gradle.bin) - : path.join(cwd, 'gradlew'), + : resolveBuildToolBin('gradle', cwd), cwd, verbose: Boolean(sockJson.defaults?.manifest?.gradle?.verbose), gradleOpts: parseBuildToolOpts( @@ -94,11 +118,13 @@ export async function generateAutoManifest({ ...gradleArgs, excludeConfigs: sockJson.defaults?.manifest?.gradle?.excludeConfigs ?? '', - ignoreUnresolved: Boolean( - sockJson.defaults?.manifest?.gradle?.ignoreUnresolved, + ignoreUnresolved: resolveIgnoreUnresolved( + Boolean(sockJson.defaults?.manifest?.gradle?.ignoreUnresolved), ), includeConfigs: sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '', + sidecarAcc, + withFiles: computeArtifactsSidecar, }) } else { logger.log( @@ -111,18 +137,22 @@ export async function generateAutoManifest({ if (!sockJson?.defaults?.manifest?.maven?.disabled && detected.maven) { logger.log('Detected a Maven pom.xml build, generating Socket facts...') await convertMavenToFacts({ - // Note: `mvn` is more likely to be resolved against PATH env. - bin: sockJson.defaults?.manifest?.maven?.bin ?? 'mvn', + // Configured bin wins; else prefer ./mvnw, else mvn on PATH. + bin: + sockJson.defaults?.manifest?.maven?.bin ?? + resolveBuildToolBin('maven', cwd), cwd, excludeConfigs: sockJson.defaults?.manifest?.maven?.excludeConfigs ?? '', - ignoreUnresolved: Boolean( - sockJson.defaults?.manifest?.maven?.ignoreUnresolved, + ignoreUnresolved: resolveIgnoreUnresolved( + Boolean(sockJson.defaults?.manifest?.maven?.ignoreUnresolved), ), includeConfigs: sockJson.defaults?.manifest?.maven?.includeConfigs ?? '', mavenOpts: parseBuildToolOpts( sockJson.defaults?.manifest?.maven?.mavenOpts, ), + sidecarAcc, verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose), + withFiles: computeArtifactsSidecar, }) } @@ -193,5 +223,9 @@ export async function generateAutoManifest({ } } - return { generatedFiles } + return { + generatedFiles, + resolvedPathsSidecar: + sidecarAcc && sidecarAcc.size ? serializeSidecar(sidecarAcc) : undefined, + } } diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts new file mode 100644 index 000000000..0a3e8039c --- /dev/null +++ b/src/commands/manifest/run-manifest-facts.mts @@ -0,0 +1,122 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +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 { InputError } from '../../utils/errors.mts' + +import type { BuildTool } from './scripts/build-tool.mts' +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// 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 — or +// a build that crashes without emitting any facts — throws unless +// `ignoreUnresolved`. +export async function runManifestFacts({ + bin, + buildOpts, + cwd, + ecosystem, + excludeConfigs, + ignoreUnresolved, + includeConfigs, + sidecarAcc, + verbose, + withFiles, +}: { + bin: string + buildOpts: string[] + cwd: string + ecosystem: BuildTool + excludeConfigs: string + ignoreUnresolved: boolean + includeConfigs: string + sidecarAcc?: SidecarAccumulator | undefined + verbose: boolean + withFiles?: boolean | undefined +}): Promise { + const factsPath = path.join(cwd, constants.DOT_SOCKET_DOT_FACTS_JSON) + + logger.log( + `Generating Socket facts for the ${ecosystem} project at \`${cwd}\` ...`, + ) + + const { artifactPaths, code, facts, report } = await runManifestScript( + ecosystem, + { + bin: bin || undefined, + excludeConfigs: excludeConfigs || undefined, + includeConfigs: includeConfigs || undefined, + projectDir: cwd, + toolOpts: buildOpts, + withFiles, + }, + ) + + const rendered = renderResolutionErrorReport( + report.failures, + report.scannedConfigs, + ecosystem, + { ignoreUnresolved }, + ) + + if (rendered.hasBlockingFailures) { + if (ignoreUnresolved) { + logger.warn(rendered.summary) + } else { + if (verbose && rendered.details) { + logger.log(rendered.details) + } + throw new InputError(rendered.summary) + } + } + if (rendered.nonBlockingNotice) { + logger.info(rendered.nonBlockingNotice) + } + if (verbose && rendered.details) { + logger.log(rendered.details) + } + + // A non-zero build exit with no usable output (no graph, no first-party + // modules, no failure records) means the build died before the socketFacts + // task emitted anything — a script/plugin compile error, OOM, or an unchecked + // exception in the extension. The build tool's own exit is the only signal, so + // fail closed rather than silently dropping the ecosystem with an empty SBOM + // (the empty-facts branch below would otherwise just log "nothing to upload"). + if ( + code !== 0 && + !facts.components.length && + !facts.projects?.length && + !report.failures.length + ) { + const message = `The ${ecosystem} build failed (exit code ${code}) before producing any Socket facts. Re-run with --verbose for the build tool's output.` + if (!ignoreUnresolved) { + throw new InputError(message) + } + logger.warn(message) + return + } + + // Nothing resolved at all — no dependencies and no first-party modules. A + // project with only first-party modules (empty components, non-empty projects) + // still has source roots reachability needs, so it must be written. + if (!facts.components.length && !facts.projects?.length) { + logger.warn( + `No resolvable ${ecosystem} dependencies found; nothing to upload.`, + ) + return + } + + await fs.writeFile(factsPath, JSON.stringify(facts, null, 2), 'utf8') + + if (withFiles && sidecarAcc) { + accumulateSidecar(sidecarAcc, facts, artifactPaths) + } + + logger.success('Generated Socket facts') +} diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts new file mode 100644 index 000000000..6d23bfa33 --- /dev/null +++ b/src/commands/manifest/scripts/assemble.mts @@ -0,0 +1,447 @@ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' + +import { + type ResolvedArtifactPaths, + type SocketFactsSbom, + type SocketFactsSbomComponent, + type SocketFactsSbomMetadata, + type SocketFactsSbomProject, + mavenCoordinateKey, +} from './facts.mts' + +import type { ParsedRecords, RawCoord, RawProject } from './records.mts' +import type { ResolutionReport } from './resolution-report.mts' + +const PURL_TYPE_MAVEN = 'maven' + +export type AssembleResult = { + facts: SocketFactsSbom + report: ResolutionReport + artifactPaths: ResolvedArtifactPaths +} + +export type AssembleOptions = { + emitProjects?: boolean | undefined + // Injectable for tests; an uncompiled module's output dir is dropped (module + // stays resolvable via its sources). + fileExists?: ((path: string) => boolean) | undefined +} + +type MergedNode = { + coord: RawCoord + children: Set + prod: boolean + direct: boolean + targets: Set +} + +type PerRoot = { + projectKey: string + prod: boolean + nodes: Map< + string, + { coord: RawCoord; children: string[]; direct: boolean; targets: string[] } + > +} + +export function assembleFacts( + parsed: ParsedRecords, + opts: AssembleOptions = {}, +): AssembleResult { + const fileExists = opts.fileExists ?? existsSync + const perRoot = buildPerRoot(parsed) + const { directByRoot, finalNodes } = mergePathSensitive(perRoot) + + const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] + const components = buildComponents(finalNodes) + const projects = + opts.emitProjects === false + ? [] + : buildProjects(parsed, finalNodes, directByRoot, perRoot) + + const metadata: SocketFactsSbomMetadata = { + format: 'socket-facts-sbom', + tool, + toolVersion: parsed.toolVersion, + ...(parsed.javaVersion ? { javaVersion: parsed.javaVersion } : {}), + } + + const facts: SocketFactsSbom = projects.length + ? { metadata, projects, components } + : { metadata, components } + + return { + facts, + report: buildReport(parsed), + artifactPaths: buildArtifactPaths( + finalNodes, + [...parsed.projects.values()], + fileExists, + ), + } +} + +function gav(group: string, name: string, version: string): string { + return `${group}:${name}:${version}` +} + +function shortHash(s: string): string { + return createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 12) +} + +function buildPerRoot(parsed: ParsedRecords): Map { + const out = new Map() + for (const [rootId, r] of parsed.roots) { + const childrenByParent = new Map>() + for (const [p, c] of r.edges) { + if (!r.nodes.has(p) || !r.nodes.has(c)) { + continue + } + let set = childrenByParent.get(p) + if (!set) { + set = new Set() + childrenByParent.set(p, set) + } + set.add(c) + } + const nodes = new Map< + string, + { + coord: RawCoord + children: string[] + direct: boolean + targets: string[] + } + >() + for (const [coordId, n] of r.nodes) { + nodes.set(coordId, { + coord: n.coord, + children: [...(childrenByParent.get(coordId) ?? [])].sort(), + direct: n.direct, + targets: n.targets, + }) + } + out.set(rootId, { projectKey: r.projectKey, prod: r.prod, nodes }) + } + return out +} + +// A coordinate with identical subtrees everywhere collapses to one node (id = +// coordId); divergent subtrees each get a content-addressed id +// (`#`) so per-subproject overrides stay distinct. +function mergePathSensitive(perRoot: Map): { + finalNodes: Map + directByRoot: Map> +} { + const memo = new Map() + const nodesOf = (rootId: string) => perRoot.get(rootId)?.nodes + + function computeSig( + rootId: string, + coordId: string, + onPath: Set, + ): string { + const memoKey = rootId + ' ' + coordId + const cached = memo.get(memoKey) + if (cached !== undefined) { + return cached + } + if (onPath.has(coordId)) { + // Cycle: back-edge as leaf. + return coordId + } + const node = nodesOf(rootId)?.get(coordId) + if (!node) { + return coordId + } + onPath.add(coordId) + const childSigs = node.children.map(c => computeSig(rootId, c, onPath)) + onPath.delete(coordId) + // Digest, not the raw string: caching expanded subtree strings OOMs on + // reconverging DAGs; a fixed-size digest keeps the pass O(V+E). + const sig = coordId + '{' + childSigs.join(',') + '}' + const digest = createHash('sha256') + .update(sig, 'utf8') + .digest('hex') + .slice(0, 16) + memo.set(memoKey, digest) + return digest + } + + // Sorted iteration keeps cyclic-graph signatures stable run-to-run. + const sigsByCoord = new Map>() + for (const rootId of [...perRoot.keys()].sort()) { + const nodes = perRoot.get(rootId)!.nodes + for (const coordId of [...nodes.keys()].sort()) { + const sig = computeSig(rootId, coordId, new Set()) + let set = sigsByCoord.get(coordId) + if (!set) { + set = new Set() + sigsByCoord.set(coordId, set) + } + set.add(sig) + } + } + const divergent = (coordId: string): boolean => + (sigsByCoord.get(coordId)?.size ?? 0) > 1 + const emittedIdMemo = new Map() + const emittedIdFor = (rootId: string, coordId: string): string => { + const k = rootId + ' ' + coordId + let v = emittedIdMemo.get(k) + if (v === undefined) { + v = divergent(coordId) + ? coordId + '#' + shortHash(computeSig(rootId, coordId, new Set())) + : coordId + emittedIdMemo.set(k, v) + } + return v + } + + const finalNodes = new Map() + const directByRoot = new Map>() + for (const [rootId, { nodes, prod }] of perRoot) { + for (const [coordId, node] of nodes) { + const eid = emittedIdFor(rootId, coordId) + let fn = finalNodes.get(eid) + if (!fn) { + fn = { + coord: node.coord, + children: new Set(), + prod: false, + direct: false, + targets: new Set(), + } + finalNodes.set(eid, fn) + } + if (prod) { + fn.prod = true + } + if (node.direct) { + fn.direct = true + } + for (const c of node.children) { + fn.children.add(emittedIdFor(rootId, c)) + } + for (const t of node.targets) { + fn.targets.add(t) + } + if (node.direct) { + let d = directByRoot.get(rootId) + if (!d) { + d = new Set() + directByRoot.set(rootId, d) + } + d.add(eid) + } + } + } + return { finalNodes, directByRoot } +} + +function buildComponents( + finalNodes: Map, +): SocketFactsSbomComponent[] { + return [...finalNodes.keys()].sort().map(id => { + const fn = finalNodes.get(id)! + const c = fn.coord + const qualifiers: Record = { + __proto__: null, + } as unknown as Record + if (c.classifier) { + qualifiers['classifier'] = c.classifier + } + if (c.ext) { + qualifiers['ext'] = c.ext + } + const comp: SocketFactsSbomComponent = { + type: PURL_TYPE_MAVEN, + namespace: c.group, + name: c.name, + ...(c.version ? { version: c.version } : {}), + ...(Object.keys(qualifiers).length ? { qualifiers } : {}), + id, + } + if (fn.direct) { + comp.direct = true + } + if (!fn.prod) { + comp.dev = true + } + if (fn.children.size) { + comp.dependencies = [...fn.children].sort() + } + return comp + }) +} + +function buildProjects( + parsed: ParsedRecords, + finalNodes: Map, + directByRoot: Map>, + perRoot: Map, +): SocketFactsSbomProject[] { + const idsByGav = new Map>() + for (const [id, fn] of finalNodes) { + const key = gav(fn.coord.group, fn.coord.name, fn.coord.version ?? '') + let set = idsByGav.get(key) + if (!set) { + set = new Set() + idsByGav.set(key, set) + } + set.add(id) + } + const directByProject = new Map>() + for (const [rootId, ids] of directByRoot) { + const pk = perRoot.get(rootId)?.projectKey ?? '' + let set = directByProject.get(pk) + if (!set) { + set = new Set() + directByProject.set(pk, set) + } + for (const id of ids) { + set.add(id) + } + } + + const projects = [...parsed.projects.values()].map(p => { + const entry: SocketFactsSbomProject = { + type: PURL_TYPE_MAVEN, + namespace: p.group, + name: p.name, + ...(p.version ? { version: p.version } : {}), + subprojectDir: p.dir, + dependencies: [...(directByProject.get(p.projectKey) ?? [])].sort(), + resolvedAs: [ + ...(idsByGav.get(gav(p.group, p.name, p.version)) ?? []), + ].sort(), + } + return entry + }) + projects.sort((a, b) => { + const ka = `${a.subprojectDir} ${a.namespace}:${a.name}` + const kb = `${b.subprojectDir} ${b.namespace}:${b.name}` + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) + return projects +} + +function unionInto( + map: Map, + key: string, + add: string[], +): void { + if (!add.length) { + return + } + const acc = map.get(key) + if (acc) { + for (const f of add) { + if (!acc.includes(f)) { + acc.push(f) + } + } + } else { + map.set(key, [...add]) + } +} + +function buildArtifactPaths( + finalNodes: Map, + projects: RawProject[], + fileExists: (path: string) => boolean, +): ResolvedArtifactPaths { + const projectsByGav = new Map< + string, + { sources: string[]; targets: string[] } + >() + for (const p of projects) { + projectsByGav.set(gav(p.group, p.name, p.version), { + sources: p.sources, + targets: p.targets, + }) + } + const targetsByCoord = new Map() + const targetsByGav = new Map() + const sourcesByCoord = new Map() + const coords = new Set() + for (const fn of finalNodes.values()) { + const c = fn.coord + const coordKey = mavenCoordinateKey( + c.group, + c.name, + c.ext, + c.classifier, + c.version, + ) + if (!coordKey) { + continue + } + coords.add(coordKey) + const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) + const sources = (pi?.sources ?? []).filter(fileExists) + const targets = [...new Set([...fn.targets, ...(pi?.targets ?? [])])] + .filter(fileExists) + .sort() + if (sources.length) { + sourcesByCoord.set(coordKey, sources) + } + if (!targets.length) { + continue + } + targetsByCoord.set(coordKey, targets) + const gavKey = mavenCoordinateKey( + c.group, + c.name, + undefined, + undefined, + c.version, + ) + if (gavKey) { + const acc = targetsByGav.get(gavKey) + if (acc) { + for (const f of targets) { + if (!acc.includes(f)) { + acc.push(f) + } + } + } else { + targetsByGav.set(gavKey, [...targets]) + } + } + } + // A top-level module is a `project` but usually not a dependency node, so its + // source roots (where reachability starts) are missed by the node loop above; + // emit first-party module paths here. + for (const p of projects) { + const coordKey = mavenCoordinateKey( + p.group, + p.name, + undefined, + undefined, + p.version, + ) + if (!coordKey) { + continue + } + coords.add(coordKey) + unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) + const targets = p.targets.filter(fileExists) + unionInto(targetsByCoord, coordKey, targets) + unionInto(targetsByGav, coordKey, targets) + } + return { targetsByCoord, targetsByGav, sourcesByCoord, coords } +} + +function buildReport(parsed: ParsedRecords): ResolutionReport { + const seen = new Set() + const failures = parsed.failures.filter(f => { + const key = `${f.coord}|${f.detail}|${f.config}` + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) + return { failures, scannedConfigs: parsed.scannedConfigs } +} diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts new file mode 100644 index 000000000..be2e1b0eb --- /dev/null +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { assembleFacts } from './assemble.mts' +import { parseRecords } from './records.mts' +import { accumulateSidecar, serializeSidecar } from './sidecar.mts' + +import type { SidecarAccumulator } from './sidecar.mts' + +// Minimal line-protocol records for a one-module Gradle build (--with-files): +// - first-party module `:app` (a project, NOT a dependency node) with source + +// output roots, +// - an external dep `lib` resolved to a jar, +// - a `bom` resolved as a constraints-only artifact (no file). +const RECORDS = [ + 'meta\tgradle\t8.0\t17', + 'project\t:app\tcom.example\tapp\t1.0\t/abs/app', + 'projectSrc\t:app\t/abs/app/src/main/java', + 'projectTgt\t:app\t/abs/app/build/classes', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tcom.example:lib:jar:1.0\tcom.example\tlib\t1.0\tjar\t\t1', + 'node\tr1\tcom.example:bom:2.0\tcom.example\tbom\t2.0\t\t\t1', + 'file\tr1\tcom.example:lib:jar:1.0\t/abs/lib.jar', + 'scanned\truntimeClasspath', +].join('\n') + +describe('records → assemble → sidecar', () => { + it('carries first-party project paths, external jars, and artifactless BOMs', () => { + // Inject fileExists so the synthetic absolute paths aren't filtered out. + const { artifactPaths, facts } = assembleFacts(parseRecords(RECORDS), { + fileExists: () => true, + }) + + expect(facts.metadata?.tool).toBe('gradle') + expect(facts.metadata?.javaVersion).toBe('17') + // contentHash/schemaVersion are intentionally absent from metadata. + expect(facts.metadata).not.toHaveProperty('contentHash') + expect(facts.metadata).not.toHaveProperty('schemaVersion') + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths) + const byName = new Map(serializeSidecar(acc).map(r => [r.name, r])) + + // First-party module: project-only (not a node), yet its source/output + // roots reach the sidecar. + expect(byName.get('app')).toEqual({ + group: 'com.example', + name: 'app', + version: '1.0', + ext: '', + classifier: null, + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }) + + // External dependency: jar target, no sources. + expect(byName.get('lib')?.targets).toEqual(['/abs/lib.jar']) + expect(byName.get('lib')?.sources).toEqual([]) + + // Artifactless BOM: present with empty arrays (resolved, no artifact). + expect(byName.get('bom')).toMatchObject({ targets: [], sources: [] }) + }) +}) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts new file mode 100644 index 000000000..4d2608450 --- /dev/null +++ b/src/commands/manifest/scripts/build-tool.mts @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +export type BuildTool = 'gradle' | 'maven' | 'sbt' + +// PATH fallback when no `bin` and no project wrapper. +const DEFAULT_BUILD_TOOL_BIN: Record = { + __proto__: null, + gradle: 'gradle', + maven: 'mvn', + sbt: 'sbt', +} as unknown as Record + +// Project-local wrapper, preferred because it pins the expected build-tool +// version. sbt has no wrapper convention. POSIX names only (no win32 target). +const BUILD_TOOL_WRAPPER = { + __proto__: null, + gradle: 'gradlew', + maven: 'mvnw', +} as unknown as Partial> + +export function resolveBuildToolBin( + tool: BuildTool, + projectDir: string, + bin?: string | undefined, +): string { + if (bin) { + return bin + } + const wrapperName = BUILD_TOOL_WRAPPER[tool] + if (wrapperName && existsSync(resolve(projectDir, wrapperName))) { + return `./${wrapperName}` + } + return DEFAULT_BUILD_TOOL_BIN[tool] +} diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts new file mode 100644 index 000000000..66859d183 --- /dev/null +++ b/src/commands/manifest/scripts/facts.mts @@ -0,0 +1,61 @@ +export type AnyPURL = { + type: string + namespace?: string | undefined + name: string + version?: string | undefined + qualifiers?: Record | undefined +} + +// No sources/targets here: those are local absolute paths, returned in-memory +// as ResolvedArtifactPaths, never serialized into the SBOM. +export type SocketFactsSbom = { + metadata?: SocketFactsSbomMetadata | undefined + projects?: SocketFactsSbomProject[] | undefined + components: SocketFactsSbomComponent[] +} + +export type SocketFactsSbomMetadata = { + format: 'socket-facts-sbom' + tool: 'gradle' | 'maven' | 'sbt' + toolVersion: string + javaVersion?: string | undefined +} + +export type SocketFactsSbomComponent = AnyPURL & { + id: string + direct?: boolean | undefined + dev?: boolean | undefined + dependencies?: string[] | undefined +} + +export type SocketFactsSbomProject = AnyPURL & { + subprojectDir: string + dependencies: string[] + resolvedAs: string[] +} + +// Resolved on-disk paths for a --with-files run, keyed by coordinate. `targets` +// = classpath entries (jars / module output dirs); `sources` = module source +// roots. +export type ResolvedArtifactPaths = { + targetsByCoord: Map + // ext/classifier-agnostic, to recover the variant when an ingested ext is + // untrustworthy (Gradle lockfile / version-catalog hardcode ext=jar). + targetsByGav: Map + sourcesByCoord: Map + coords: Set +} + +// Coordinate-based (not `id`-based) so it also matches foreign SBOMs like +// CycloneDX. Empty segments dropped. +export function mavenCoordinateKey( + groupId: string | undefined, + artifactId: string | undefined, + type: string | undefined, + classifier: string | undefined, + version: string | undefined, +): string { + return [groupId, artifactId, type, classifier, version] + .filter(Boolean) + .join(':') +} diff --git a/src/commands/manifest/scripts/maven-extension/.gitignore b/src/commands/manifest/scripts/maven-extension/.gitignore new file mode 100644 index 000000000..b8d44bdac --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts — the jar is compiled from source (build-jar.sh), never committed. +target/ +coana-maven-extension.jar diff --git a/src/commands/manifest/scripts/maven-extension/.mvn/wrapper/maven-wrapper.properties b/src/commands/manifest/scripts/maven-extension/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..ffcab66aa --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/src/commands/manifest/scripts/maven-extension/build-jar.sh b/src/commands/manifest/scripts/maven-extension/build-jar.sh new file mode 100755 index 000000000..eeaef2f67 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/build-jar.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Compile the Coana Maven core extension to a self-contained jar and place it at the path the TS +# runner resolves: manifest-scripts/maven-extension/coana-maven-extension.jar. Run by the npm-package +# build and the manifest-maven CI job. Uses the bundled Maven wrapper, so it needs only a JDK. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +( cd "$here" && ./mvnw -q --batch-mode package ) +cp -f "$here/target/coana-maven-extension.jar" "$here/coana-maven-extension.jar" +echo "Coana Maven extension jar: $here/coana-maven-extension.jar" diff --git a/src/commands/manifest/scripts/maven-extension/mvnw b/src/commands/manifest/scripts/maven-extension/mvnw new file mode 100755 index 000000000..bd8896bf2 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/src/commands/manifest/scripts/maven-extension/mvnw.cmd b/src/commands/manifest/scripts/maven-extension/mvnw.cmd new file mode 100644 index 000000000..92450f932 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/src/commands/manifest/scripts/maven-extension/pom.xml b/src/commands/manifest/scripts/maven-extension/pom.xml new file mode 100644 index 000000000..42b4e2c7f --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + tech.coana + coana-maven-extension + 1.0 + + jar + Coana Maven extension + Core extension that emits Coana's dependency-graph records (socket-facts) without a local-repo install. + + + 8 + 8 + UTF-8 + + 3.9.6 + + + + + coana-maven-extension + + + + org.eclipse.sisu + sisu-maven-plugin + 0.9.0.M3 + + + index-project + + main-index + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + + + META-INF/sisu/javax.inject.Named + + + + + + + + + + + + + javax.inject + javax.inject + 1 + provided + + + + + org.apache.maven + maven-core + ${maven.version} + provided + + + org.apache.maven + maven-model + ${maven.version} + provided + + + org.apache.maven + maven-artifact + ${maven.version} + provided + + + org.apache.maven.resolver + maven-resolver-api + 1.9.18 + provided + + + + org.apache.maven.resolver + maven-resolver-util + 1.9.18 + provided + + + org.slf4j + slf4j-api + 1.7.36 + provided + + + + + org.apache.maven.shared + maven-dependency-tree + 3.3.0 + + + diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java new file mode 100644 index 000000000..4234edfc0 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java @@ -0,0 +1,84 @@ +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 org.apache.maven.shared.dependency.graph.DependencyGraphBuilder; +import org.eclipse.aether.RepositorySystem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tech.coana.socket.SocketFactsRecordsEngine; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.util.Properties; + +/** + * Core extension loaded via {@code -Dmaven.ext.class.path}, gated by {@code -Dcoana.task=socket-facts} + * (inert otherwise; no local-repo install, pom untouched). Runs at {@code afterSessionEnd} so + * {@code -Dsocket.withFiles} sees compiled classes / generated sources. + */ +@Named("coana-facts") +@Singleton +public class CoanaFactsLifecycleParticipant extends AbstractMavenLifecycleParticipant { + + private static final Logger LOG = LoggerFactory.getLogger("coana"); + + private final RepositorySystem repoSystem; + private final DependencyGraphBuilder dependencyGraphBuilder; + private final RuntimeInformation runtimeInformation; + + @Inject + public CoanaFactsLifecycleParticipant( + RepositorySystem repoSystem, + DependencyGraphBuilder dependencyGraphBuilder, + RuntimeInformation runtimeInformation) { + this.repoSystem = repoSystem; + this.dependencyGraphBuilder = dependencyGraphBuilder; + this.runtimeInformation = runtimeInformation; + } + + @Override + public void afterSessionEnd(MavenSession session) throws MavenExecutionException { + if (!"socket-facts".equals(normalize(opt(session, "coana.task")))) { + return; + } + String recordsFile = opt(session, "socket.recordsFile"); + if (recordsFile == null || recordsFile.isEmpty()) { + throw new MavenExecutionException("socket-facts requires -Dsocket.recordsFile", new IllegalStateException()); + } + SocketFactsRecordsEngine.Options opts = new SocketFactsRecordsEngine.Options(); + opts.recordsFile = recordsFile; + opts.withFiles = optBoolean(session, "socket.withFiles"); + opts.populateFilesFor = opt(session, "socket.populateFilesFor"); + opts.includeConfigs = opt(session, "socket.includeConfigs"); + opts.excludeConfigs = opt(session, "socket.excludeConfigs"); + File rootDir = new File(session.getExecutionRootDirectory()); + try { + new SocketFactsRecordsEngine(repoSystem, dependencyGraphBuilder, runtimeInformation.getMavenVersion(), LOG) + .run(session, session.getProjects(), rootDir, opts); + } catch (IOException exception) { + throw new MavenExecutionException("Cannot write socket facts records", exception); + } + } + + // Accept the cross-tool camelCase alias used by the CLI / Gradle / SBT scripts. + private static String normalize(String task) { + return "socketFacts".equals(task) ? "socket-facts" : task; + } + + // -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); + } + + private static boolean optBoolean(MavenSession session, String key) { + return Boolean.parseBoolean(opt(session, key)); + } +} diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java new file mode 100644 index 000000000..270a6a9d6 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java @@ -0,0 +1,427 @@ +package tech.coana.socket; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.handler.ArtifactHandler; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Resource; +import org.apache.maven.project.DefaultProjectBuildingRequest; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.ProjectBuildingRequest; +import org.apache.maven.shared.dependency.graph.DependencyGraphBuilder; +import org.apache.maven.shared.dependency.graph.DependencyGraphBuilderException; +import org.apache.maven.shared.dependency.graph.DependencyNode; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactRequest; +import org.eclipse.aether.resolution.ArtifactResolutionException; +import org.eclipse.aether.resolution.ArtifactResult; +import org.slf4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * Emits each reactor module's resolved dependency graph as line-protocol records for the TS assembler + * (same contract as the Gradle/SBT scripts; no JSON/hashing here). Per module: a prod root + * (compile/runtime/system) and a dev root (test/provided). A reactor module becomes a component only + * where another depends on it, by its bare {@code groupId:artifactId:version} id. + */ +public final class SocketFactsRecordsEngine { + + public static final class Options { + public boolean withFiles; + public String populateFilesFor; + public String includeConfigs; + public String excludeConfigs; + public String recordsFile; + } + + private static final List ALL_SCOPES = + Arrays.asList("compile", "provided", "runtime", "system", "test"); + + private final RepositorySystem repoSystem; + private final DependencyGraphBuilder dependencyGraphBuilder; + private final String mavenVersion; + private final Logger log; + + public SocketFactsRecordsEngine( + RepositorySystem repoSystem, + DependencyGraphBuilder dependencyGraphBuilder, + String mavenVersion, + Logger log) { + this.repoSystem = repoSystem; + this.dependencyGraphBuilder = dependencyGraphBuilder; + this.mavenVersion = mavenVersion; + this.log = log; + } + + public void run(MavenSession session, List reactor, File rootDir, Options opts) + throws IOException { + RepositorySystemSession repoSession = session.getRepositorySession(); + Set passingScopes = computePassingScopes(opts.includeConfigs, opts.excludeConfigs); + // GAVs to materialize under --with-files (null = all). Scopes artifact downloads so reachability + // doesn't fetch the whole dependency universe. Module src/tgt dirs are emitted regardless (no download). + Set populateGavs = readPopulateGavs(opts); + Set reactorGavs = new HashSet<>(); + for (MavenProject p : reactor) { + reactorGavs.add(p.getGroupId() + ":" + p.getArtifactId() + ":" + p.getVersion()); + } + + 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()); + rec(lines, "project", ws, module.getGroupId(), module.getArtifactId(), module.getVersion(), ws); + if (opts.withFiles) { + for (String s : collectSources(module)) rec(lines, "projectSrc", ws, s); + for (String t : collectTargets(module)) rec(lines, "projectTgt", ws, t); + } + } + + for (String scope : passingScopes) rec(lines, "scanned", scope); + + Set failures = new LinkedHashSet<>(); + int rootIdx = 0; + for (MavenProject module : reactor) { + String ws = SocketSupport.workspace(rootDir.toPath(), module.getBasedir().toPath()); + Map nodes = new LinkedHashMap<>(); + Set directIds = new HashSet<>(); + collectModule(session, repoSession, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, failures); + rootIdx = emitModuleRoots(lines, rootIdx, ws, nodes, directIds); + } + + for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config); + + write(opts.recordsFile, lines); + } + + // ---- resolution (mirrors the reference engine's visit, minus JSON shaping) ---- + + private void collectModule( + MavenSession session, + RepositorySystemSession repoSession, + MavenProject module, + Set passingScopes, + Set reactorGavs, + Set populateGavs, + Options opts, + Map nodes, + Set directIds, + Set failures) { + DependencyNode root; + try { + ProjectBuildingRequest req = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest()); + req.setProject(module); + root = dependencyGraphBuilder.buildDependencyGraph(req, null); + } catch (DependencyGraphBuilderException e) { + String coord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); + failures.add(new Failure(coord, rootMessage(e), "graph")); + log.warn("[socket-facts] could not build dependency graph for " + coord + ": " + rootMessage(e)); + return; + } + List repos = module.getRemoteProjectRepositories(); + Set visited = new HashSet<>(); + for (DependencyNode child : root.getChildren()) { + String id = visit(repoSession, child, passingScopes, reactorGavs, populateGavs, opts, repos, nodes, visited, failures); + if (id != null) directIds.add(id); + } + } + + private String visit( + RepositorySystemSession repoSession, + DependencyNode dn, + Set passingScopes, + Set reactorGavs, + Set populateGavs, + Options opts, + List repos, + Map nodes, + Set visited, + Set failures) { + Artifact artifact = dn.getArtifact(); + String scope = artifact.getScope(); + if (scope == null || scope.isEmpty()) scope = "compile"; + if (!passingScopes.contains(scope)) return null; + + String gav = artifact.getGroupId() + ":" + artifact.getArtifactId() + ":" + artifact.getVersion(); + boolean internal = reactorGavs.contains(gav); + String type = artifact.getType(); + String classifier = artifact.getClassifier(); + String id = internal + ? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()) + : SocketSupport.coordId(artifact.getGroupId(), artifact.getArtifactId(), type, classifier, artifact.getVersion()); + + // One walk per node per module traversal: a shared subtree reached via another edge is already + // fully recorded (node, children, resolved file), and the resolve below is a download-on-miss — + // so hand back the id without re-resolving or re-descending. Keeps reconverging graphs linear. + if (!visited.add(id)) return id; + + Node node = internal + ? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", artifact.getVersion()) + : upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), + type == null ? "" : type, classifier == null ? "" : classifier, artifact.getVersion()); + // `a.file` downloads if uncached, so scope to the requested GAVs (null = all). + if (!internal && opts.withFiles && (populateGavs == null || populateGavs.contains(gav))) { + String file = resolveArtifactFile(repoSession, artifact, scope, repos, failures); + if (file != null) node.files.add(file); + } + if (isProd(scope)) node.prod = true; + + for (DependencyNode child : dn.getChildren()) { + String childId = visit(repoSession, child, passingScopes, reactorGavs, populateGavs, opts, repos, nodes, visited, failures); + if (childId != null) node.children.add(childId); + } + return id; + } + + private static boolean isProd(String scope) { + return scope.equals("compile") || scope.equals("runtime") || scope.equals("system"); + } + + private static Node upsert( + Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version) { + Node node = nodes.get(id); + if (node == null) { + node = new Node(id, groupId, artifactId, type, classifier, version); + nodes.put(id, node); + } + return node; + } + + private String resolveArtifactFile( + RepositorySystemSession repoSession, Artifact artifact, String scope, List repos, Set failures) { + if ("system".equals(scope)) { + return SocketSupport.existingAbsolutePath(artifact.getFile()); + } + ArtifactHandler handler = artifact.getArtifactHandler(); + String extension = handler != null ? handler.getExtension() : artifact.getType(); + String classifier = artifact.getClassifier(); + try { + ArtifactRequest request = new ArtifactRequest() + .setArtifact(new DefaultArtifact( + artifact.getGroupId(), + artifact.getArtifactId(), + classifier == null ? "" : classifier, + extension, + artifact.getVersion())) + .setRepositories(repos); + ArtifactResult result = repoSystem.resolveArtifact(repoSession, request); + File file = result.getArtifact() != null ? result.getArtifact().getFile() : null; + return SocketSupport.existingAbsolutePath(file); + } catch (ArtifactResolutionException e) { + String coord = artifact.getGroupId() + ":" + artifact.getArtifactId() + ":" + artifact.getVersion(); + failures.add(new Failure(coord, rootMessage(e), scope)); + log.debug("[socket-facts] could not materialize " + artifact + ": " + rootMessage(e)); + return null; + } + } + + // ---- emission ---- + + // Split a module's resolved nodes into a prod root and a dev root (each artifact has one effective + // scope, so the subgraphs are disjoint and edges stay intra-root). Empty roots are skipped. + private int emitModuleRoots(List lines, int rootIdx, String projectKey, Map nodes, Set directIds) { + Map prod = new LinkedHashMap<>(); + Map dev = new LinkedHashMap<>(); + for (Node n : nodes.values()) (n.prod ? prod : dev).put(n.id, n); + if (!prod.isEmpty()) { + rootIdx = emitRoot(lines, rootIdx, projectKey, "compile", true, prod, directIds); + } + if (!dev.isEmpty()) { + rootIdx = emitRoot(lines, rootIdx, projectKey, "test", false, dev, directIds); + } + return rootIdx; + } + + private int emitRoot( + List lines, int rootIdx, String projectKey, String config, boolean prod, Map nodeMap, Set directIds) { + String rootId = Integer.toString(rootIdx); + rec(lines, "root", rootId, projectKey, config, prod ? "1" : "0"); + for (Node n : nodeMap.values()) { + rec(lines, "node", rootId, n.id, n.groupId, n.artifactId, n.version, n.type, n.classifier, + directIds.contains(n.id) ? "1" : "0"); + for (String child : n.children) { + if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child); + } + for (String f : n.files) rec(lines, "file", rootId, n.id, f); + } + return rootIdx + 1; + } + + // ---- scopes / module files ---- + + private Set computePassingScopes(String includeConfigs, String excludeConfigs) { + List includes = SocketSupport.parsePatterns(includeConfigs); + List excludes = SocketSupport.parsePatterns(excludeConfigs); + Set passing = new TreeSet<>(); + for (String scope : ALL_SCOPES) { + if (matchesAny(excludes, scope)) continue; + if (!includes.isEmpty() && !matchesAny(includes, scope)) continue; + passing.add(scope); + } + return passing; + } + + private static boolean matchesAny(List patterns, String name) { + for (Pattern p : patterns) if (p.matcher(name).matches()) return true; + return false; + } + + // Read the newline-delimited GAV file named by -Dsocket.populateFilesFor. Returns null (materialize + // all) when not under --with-files, unset, or the file is missing/empty (a wiring slip, not a + // deliberate "fetch nothing"), matching the Gradle/SBT scripts. + private Set readPopulateGavs(Options opts) throws IOException { + if (!opts.withFiles || opts.populateFilesFor == null || opts.populateFilesFor.trim().isEmpty()) return null; + File f = new File(opts.populateFilesFor.trim()); + if (!f.exists()) { + log.warn("[socket-facts] populateFilesFor file not found; materializing files for all resolved artifacts"); + return null; + } + Set gavs = new HashSet<>(); + for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) { + String t = line.trim(); + if (!t.isEmpty()) gavs.add(t); + } + if (gavs.isEmpty()) { + log.warn("[socket-facts] populateFilesFor file empty; materializing files for all resolved artifacts"); + return null; + } + log.info("[socket-facts] --with-files scoped to " + gavs.size() + " artifact(s)"); + return gavs; + } + + // Configured source roots, emitted unconditionally (like gradle's srcDirs / sbt's sourceDirectories): + // the analysis never builds the project, so these need not exist yet. + private List collectSources(MavenProject module) { + Set sources = new TreeSet<>(); + addPaths(sources, module.getCompileSourceRoots()); + addPaths(sources, module.getTestCompileSourceRoots()); + for (Resource r : module.getBuild().getResources()) addPath(sources, r.getDirectory()); + for (Resource r : module.getBuild().getTestResources()) addPath(sources, r.getDirectory()); + // generated-source roots aren't on the model without a build; best-effort pick up the + // conventional dirs only if a prior `mvn compile` already produced them (else they'd be guesses). + String buildDir = module.getBuild().getDirectory(); + addExisting(sources, buildDir + File.separator + "generated-sources"); + addExisting(sources, buildDir + File.separator + "generated-test-sources"); + return new ArrayList<>(sources); + } + + // Configured compiled-output dirs, emitted unconditionally (like gradle's classesDirs / sbt's + // classDirectory): the analysis never builds the project, so these need not exist yet. + private List collectTargets(MavenProject module) { + Set targets = new TreeSet<>(); + addPath(targets, module.getBuild().getOutputDirectory()); + addPath(targets, module.getBuild().getTestOutputDirectory()); + return new ArrayList<>(targets); + } + + private static void addPaths(Set acc, List dirs) { + if (dirs == null) return; + for (String d : dirs) addPath(acc, d); + } + + // Existence-filtered: for speculative paths we only want to emit when they actually exist. + private static void addExisting(Set acc, String dir) { + if (dir == null) return; + String p = SocketSupport.existingAbsolutePath(new File(dir)); + if (p != null) acc.add(p); + } + + private static void addPath(Set acc, String dir) { + if (dir == null) return; + acc.add(new File(dir).getAbsolutePath()); + } + + // ---- records I/O ---- + + 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 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"); + } + log.info("[socket-facts] records written to: " + out.getAbsolutePath()); + } + + private static String rootMessage(Throwable t) { + Throwable cur = t; + String msg = null; + int guard = 0; + while (cur != null && guard++ < 12) { + if (cur.getMessage() != null) msg = cur.getMessage(); + cur = cur.getCause(); + } + return (msg != null ? msg : "unknown resolution failure").trim(); + } + + private static final class Failure { + final String coord; + final String detail; + final String config; + + Failure(String coord, String detail, String config) { + this.coord = coord; + this.detail = detail; + this.config = config; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Failure)) return false; + Failure f = (Failure) o; + return coord.equals(f.coord) && detail.equals(f.detail) && config.equals(f.config); + } + + @Override + public int hashCode() { + return (coord + "|" + detail + "|" + config).hashCode(); + } + } + + private static final class Node { + final String id; + final String groupId; + final String artifactId; + final String type; + final String classifier; + final String version; + final TreeSet children = new TreeSet<>(); + final TreeSet files = new TreeSet<>(); + boolean prod = false; + + Node(String id, String groupId, String artifactId, String type, String classifier, String version) { + this.id = id; + this.groupId = groupId; + this.artifactId = artifactId; + this.type = type; + this.classifier = classifier; + this.version = version; + } + } +} diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java new file mode 100644 index 000000000..5d9c670a0 --- /dev/null +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java @@ -0,0 +1,81 @@ +package tech.coana.socket; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Shared helpers kept byte-compatible with the Gradle/SBT scripts and the TS parsers: the + * build-root-relative workspace path, the full-coordinate {@code id} key, and the config-glob + * translation. + */ +public final class SocketSupport { + private SocketSupport() {} + + /** The module dir relative to the reactor (build) root; "." for the root. */ + public static String workspace(Path rootDir, Path projectDir) { + return rootDir.equals(projectDir) ? "." : rootDir.relativize(projectDir).toString(); + } + + /** + * Full Maven coordinate {@code groupId:artifactId:type:classifier:version} with empty segments + * dropped — the per-root node key the assembler uses. {@code type} is the Maven packaging (the + * Gradle/SBT {@code ext}). + */ + public static String coordId(String groupId, String artifactId, String type, String classifier, String version) { + StringBuilder sb = new StringBuilder(); + for (String segment : new String[] {groupId, artifactId, type, classifier, version}) { + if (segment == null || segment.isEmpty()) continue; + if (sb.length() > 0) sb.append(':'); + sb.append(segment); + } + return sb.toString(); + } + + /** Bare {@code groupId:artifactId:version} id used for reactor (internal) module components. */ + public static String bareId(String groupId, String artifactId, String version) { + return coordId(groupId, artifactId, null, null, version); + } + + /** Translate a config-name glob ({@code *}/{@code ?}) to a case-insensitive regex. */ + public static Pattern globToRegex(String glob) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + char c = glob.charAt(i); + switch (c) { + case '*': sb.append(".*"); break; + case '?': sb.append('.'); break; + case '\\': case '.': case '^': case '$': case '|': + case '+': case '(': case ')': + case '[': case ']': case '{': case '}': + sb.append('\\').append(c); break; + default: sb.append(c); + } + } + return Pattern.compile(sb.toString(), Pattern.CASE_INSENSITIVE); + } + + /** Parse a comma-separated list of globs into case-insensitive patterns. */ + public static List parsePatterns(String csv) { + List out = new ArrayList<>(); + if (csv == null || csv.trim().isEmpty()) return out; + for (String raw : csv.split(",")) { + String p = raw.trim(); + if (!p.isEmpty()) out.add(globToRegex(p)); + } + return out; + } + + /** Absolute path of a file if it exists, else null. */ + public static String existingAbsolutePath(File f) { + return f != null && f.exists() ? f.getAbsolutePath() : null; + } + + /** Backslash-escape a record field so it can never break line/field framing (see records.ts). */ + public static String escapeField(String v) { + if (v == null) return ""; + return v.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n").replace("\r", "\\r"); + } +} diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts new file mode 100644 index 000000000..5e9fc749b --- /dev/null +++ b/src/commands/manifest/scripts/records.mts @@ -0,0 +1,222 @@ +import type { ResolutionFailure } from './resolution-report.mts' + +// Line-protocol the build-tool scripts emit to a records file (NOT stdout — sbt +// prints unsilenceable resolution noise there). One record per line, fields +// backslash-escaped (\\, \t, \n, \r) so a value can't break framing: +// +// \t\t... +// +// meta tool toolVersion javaVersion +// project projectKey group name version dir +// projectSrc projectKey path (--with-files only) +// projectTgt projectKey path (--with-files only) +// root rootId projectKey config prod(0|1) +// node rootId coordId group name version ext classifier direct(0|1) +// edge rootId parentCoordId childCoordId +// file rootId coordId path (--with-files only) +// scanned config +// failure coord detail config +// +// A `root` is one (subproject, configuration) resolution root; `coordId` is the +// coordinate key (`group:name:ext:classifier:version`, empty segments dropped), +// used opaquely as the per-root node key. Unknown tags are ignored. + +export type RawCoord = { + group: string + name: string + version: string + ext: string + classifier: string +} + +export type RawNode = { + coordId: string + coord: RawCoord + direct: boolean + // --with-files only. + targets: string[] +} + +export type RawRoot = { + rootId: string + projectKey: string + config: string + prod: boolean + nodes: Map + edges: Array<[string, string]> +} + +export type RawProject = { + projectKey: string + group: string + name: string + version: string + dir: string + sources: string[] + targets: string[] +} + +export type ParsedRecords = { + tool: string + toolVersion: string + javaVersion: string + projects: Map + roots: Map + scannedConfigs: string[] + failures: ResolutionFailure[] +} + +export function unescapeField(s: string): string { + if (!s.includes('\\')) { + return s + } + let out = '' + for (let i = 0; i < s.length; i += 1) { + const c = s[i] + if (c === '\\' && i + 1 < s.length) { + const n = s[++i] + out += n === 't' ? '\t' : n === 'n' ? '\n' : n === 'r' ? '\r' : n + } else { + out += c + } + } + return out +} + +function bool(s: string | undefined): boolean { + return s === '1' || s === 'true' +} + +export function parseRecords(text: string): ParsedRecords { + const result: ParsedRecords = { + tool: '', + toolVersion: '', + javaVersion: '', + projects: new Map(), + roots: new Map(), + scannedConfigs: [], + failures: [], + } + const scanned = new Set() + + const root = (id: string): RawRoot => { + let r = result.roots.get(id) + if (!r) { + r = { + rootId: id, + projectKey: '', + config: '', + prod: false, + nodes: new Map(), + edges: [], + } + result.roots.set(id, r) + } + return r + } + const project = (key: string): RawProject => { + let p = result.projects.get(key) + if (!p) { + p = { + projectKey: key, + group: '', + name: '', + version: '', + dir: '', + sources: [], + targets: [], + } + result.projects.set(key, p) + } + return p + } + + for (const rawLine of text.split('\n')) { + if (!rawLine) { + continue + } + const f = rawLine.split('\t').map(unescapeField) + switch (f[0]) { + case 'meta': + result.tool = f[1] ?? '' + result.toolVersion = f[2] ?? '' + result.javaVersion = f[3] ?? '' + break + case 'project': { + const p = project(f[1] ?? '') + p.group = f[2] ?? '' + p.name = f[3] ?? '' + p.version = f[4] ?? '' + p.dir = f[5] ?? '' + break + } + case 'projectSrc': + if (f[2]) { + project(f[1] ?? '').sources.push(f[2]) + } + break + case 'projectTgt': + if (f[2]) { + project(f[1] ?? '').targets.push(f[2]) + } + break + case 'root': { + const r = root(f[1] ?? '') + r.projectKey = f[2] ?? '' + r.config = f[3] ?? '' + r.prod = bool(f[4]) + break + } + case 'node': { + const r = root(f[1] ?? '') + const coordId = f[2] ?? '' + r.nodes.set(coordId, { + coordId, + coord: { + group: f[3] ?? '', + name: f[4] ?? '', + version: f[5] ?? '', + ext: f[6] ?? '', + classifier: f[7] ?? '', + }, + direct: bool(f[8]), + targets: [], + }) + break + } + case 'edge': { + const parent = f[2] ?? '' + const child = f[3] ?? '' + if (parent !== child) { + root(f[1] ?? '').edges.push([parent, child]) + } + break + } + case 'file': { + const node = root(f[1] ?? '').nodes.get(f[2] ?? '') + if (node && f[3]) { + node.targets.push(f[3]) + } + break + } + case 'scanned': + if (f[1]) { + scanned.add(f[1]) + } + break + case 'failure': + if (f[1]) { + result.failures.push({ + coord: f[1], + detail: f[2] ?? '', + config: f[3] ?? '', + }) + } + break + default: + break + } + } + result.scannedConfigs = [...scanned].sort() + return result +} diff --git a/src/commands/manifest/scripts/resolution-report-gradle.mts b/src/commands/manifest/scripts/resolution-report-gradle.mts new file mode 100644 index 000000000..d1ea189fa --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-gradle.mts @@ -0,0 +1,91 @@ +import type { + FailureCategory, + ResolutionDialect, +} from './resolution-report-render.mts' + +// Gradle's variant-aware resolver: distinct exceptions give mutually-exclusive +// phrasing, so most-specific-first substring checks classify reliably. +export function classifyGradleFailure(detail: string): FailureCategory { + const t = (detail || '').toLowerCase() + // Check before variant ambiguity. + if (t.includes('conflict on capability')) { + return 'capability-conflict' + } + // Zero compatible variants — the opposite of ambiguity below. + if (t.includes('no matching variant') || t.includes('no variants of')) { + return 'no-matching-variant' + } + if (t.includes('cannot choose between')) { + return 'variant-ambiguity' + } + if ( + t.includes('could not get') || + t.includes('could not head') || + t.includes('status code 401') || + t.includes('status code 403') || + t.includes('connection refused') || + t.includes('connection timed out') || + t.includes('read timed out') || + t.includes('certification path') || + t.includes('peer not authenticated') + ) { + return 'repository-or-network' + } + if (t.includes('could not find')) { + return 'not-found' + } + return 'other' +} + +// Every kind blocks except variant-ambiguity: the module demonstrably exists +// and is almost always captured via another configuration, so it's benign for +// the SBOM (non-blocking, count only). A module that resolves in NO config +// surfaces as not-found / no-matching-variant, which stay blocking. +export const GRADLE_DIALECT: ResolutionDialect = { + label: 'Gradle', + classify: classifyGradleFailure, + categories: [ + { + key: 'not-found', + header: () => ` Not found in any repository:`, + blocking: true, + }, + { + key: 'no-matching-variant', + header: n => + ` No compatible variant — ${n} found the module but no variant matched the requested attributes:`, + blocking: true, + }, + { + key: 'capability-conflict', + header: n => + ` Capability conflict — ${n} found two modules providing the same capability and cannot use both (add a capability-resolution or module-replacement rule):`, + showReason: true, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Repository or network error — ${n} could not reach or authenticate to a repository:`, + blocking: true, + }, + { + key: 'config-problem', + header: n => ` Resolver/configuration problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other resolution failures (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'variant-ambiguity', + blocking: false, + notice: (n, depCount, configCount) => + `Skipped ${depCount} dependency(ies) with ambiguous variant selection in ${configCount} configuration(s) — re-run with --verbose for ${n}'s messages.`, + }, + ], +} diff --git a/src/commands/manifest/scripts/resolution-report-ivy.mts b/src/commands/manifest/scripts/resolution-report-ivy.mts new file mode 100644 index 000000000..f93f5e1e3 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-ivy.mts @@ -0,0 +1,61 @@ +import type { + FailureCategory, + ResolutionDialect, +} from './resolution-report-render.mts' + +// Ivy/sbt resolver: no attribute-based variants, so no variant categories +// (Gradle-only). Ivy degrades transport failures to "not found". +export function classifyIvyFailure(detail: string): FailureCategory { + const t = (detail || '').toLowerCase() + if ( + t.includes('server access error') || + t.includes('download failed') || + t.includes('connection timed out') || + t.includes('unauthorized') || + t.includes('forbidden') + ) { + return 'repository-or-network' + } + if ( + t.includes('no resolver found') || + t.includes('configuration not found') || + t.includes('configuration not public') + ) { + return 'config-problem' + } + if (t.includes('not found') || t.includes('unresolved dependency')) { + return 'not-found' + } + return 'other' +} + +// Every kind blocks; no non-blocking kind because Ivy has no variant ambiguity. +export const SBT_DIALECT: ResolutionDialect = { + label: 'sbt', + classify: classifyIvyFailure, + categories: [ + { + key: 'not-found', + header: () => ` Not found in any repository:`, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Repository or network error — ${n} could not reach or authenticate to a repository:`, + blocking: true, + }, + { + key: 'config-problem', + header: n => ` Resolver/configuration problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other resolution failures (reason from ${n}):`, + showReason: true, + blocking: true, + }, + ], +} diff --git a/src/commands/manifest/scripts/resolution-report-maven.mts b/src/commands/manifest/scripts/resolution-report-maven.mts new file mode 100644 index 000000000..5ab942f2a --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-maven.mts @@ -0,0 +1,75 @@ +import type { + FailureCategory, + ResolutionDialect, +} from './resolution-report-render.mts' + +// Maven's resolver (Aether/maven-resolver): no attribute-based variants. Two +// failure shapes (artifact-resolution miss with config = scope, dependency-graph +// build failure with config = "graph") both classify off the root-cause message. +export function classifyMavenFailure(detail: string): FailureCategory { + const t = (detail || '').toLowerCase() + if ( + t.includes('could not transfer') || + t.includes('connection refused') || + t.includes('connect timed out') || + t.includes('connection timed out') || + t.includes('read timed out') || + t.includes('status code: 401') || + t.includes('status code: 403') || + t.includes('unauthorized') || + t.includes('forbidden') || + t.includes('peer not authenticated') || + t.includes('certpathbuilderexception') + ) { + return 'repository-or-network' + } + if ( + t.includes('could not find artifact') || + t.includes('failure to find') || + t.includes('could not resolve') || + t.includes('no versions available') || + t.includes('not found') + ) { + return 'not-found' + } + // POM exists but can't be read/parsed. + if ( + t.includes('failed to read artifact descriptor') || + t.includes('invalid pom') || + t.includes('could not parse pom') + ) { + return 'config-problem' + } + return 'other' +} + +// Every kind blocks; no non-blocking kind because Maven has no variant ambiguity. +export const MAVEN_DIALECT: ResolutionDialect = { + label: 'Maven', + classify: classifyMavenFailure, + categories: [ + { + key: 'not-found', + header: () => ` Not found in any repository:`, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Repository or network error — ${n} could not reach or authenticate to a repository:`, + blocking: true, + }, + { + key: 'config-problem', + header: n => ` POM/descriptor problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other resolution failures (reason from ${n}):`, + showReason: true, + blocking: true, + }, + ], +} diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts new file mode 100644 index 000000000..76751fda7 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -0,0 +1,240 @@ +import { GRADLE_DIALECT } from './resolution-report-gradle.mts' +import { SBT_DIALECT } from './resolution-report-ivy.mts' +import { MAVEN_DIALECT } from './resolution-report-maven.mts' + +import type { BuildTool } from './build-tool.mts' +import type { ResolutionFailure } from './resolution-report.mts' + +// Recognized from the build tool's message; drives wording AND whether the kind +// is blocking. An unrecognized message degrades to 'other' (blocking) — safe. +export type FailureCategory = + | 'not-found' + | 'no-matching-variant' + | 'capability-conflict' + | 'variant-ambiguity' + | 'repository-or-network' + | 'config-problem' + | 'other' + +export type FailureCategorySpec = { + key: FailureCategory + // Whether failures of this kind fail the run (fail-closed unless + // --ignore-unresolved). A non-blocking kind never affects the exit code and + // is surfaced only as a one-line `notice`. + blocking: boolean + header?: ((toolLabel: string) => string) | undefined + showReason?: boolean | undefined + notice?: + | ((toolLabel: string, depCount: number, configCount: number) => string) + | undefined +} + +// Per-resolver classify + render/score policy. +export type ResolutionDialect = { + label: string + classify: (detail: string) => FailureCategory + categories: FailureCategorySpec[] +} + +export type RenderedResolutionReport = { + // Failure report for blocking kinds; empty when nothing blocks. + summary: string + // Build tool's own full messages for all kinds; surfaced at --verbose. + details: string + // Caller fails the run iff this is true and not --ignore-unresolved. + hasBlockingFailures: boolean + // One-liner(s) for non-blocking kinds; empty when none. + nonBlockingNotice: string +} + +const RESOLUTION_REPORT_ARTIFACT_LIMIT = 15 +const RESOLUTION_REPORT_CONFIG_LIMIT = 20 + +// Drop a bare "group:name" when a versioned "group:name:v" of the same module +// is also present: the lenient resolver reports both forms for one failure. +function dedupCoords(coords: Iterable): string[] { + const set = new Set(coords) + const versioned = new Set() + for (const c of set) { + const p = c.split(':') + if (p.length >= 3) { + versioned.add(`${p[0]}:${p[1]}`) + } + } + return [...set] + .filter(c => c.split(':').length >= 3 || !versioned.has(c)) + .sort() +} + +function fmtList(list: string[], limit: number): string { + const shown = list.slice(0, limit).join(', ') + return list.length > limit ? `${shown} (+${list.length - limit} more)` : shown +} + +function firstLine(s: string): string { + return ( + (s || '') + .split('\n') + .map(l => l.trim()) + .find(Boolean) ?? '' + ) +} + +// Severity is per-kind; the exit-code decision lives in the caller. We do NOT +// cross-reference what resolved elsewhere: the failed selector carries no +// classifier/type, so relating a failed and a succeeded dep is unsound. +export function renderResolutionReport( + failures: ResolutionFailure[], + scannedConfigs: string[], + dialect: ResolutionDialect, + opts: { ignoreUnresolved?: boolean | undefined } = {}, +): RenderedResolutionReport { + const name = dialect.label + const specOf = new Map(dialect.categories.map(c => [c.key, c])) + const isBlocking = (cat: FailureCategory): boolean => + specOf.get(cat)?.blocking ?? true + + // Aggregate by (coord, category): one module can fail with different causes + // across configs. Keep first-seen detail, union the configs. + type CoordInfo = { + coord: string + category: FailureCategory + detail: string + configs: Set + } + const byKey = new Map() + const keyOf = (coord: string, category: FailureCategory): string => + `${coord} ${category}` + for (const f of failures) { + const category = dialect.classify(f.detail) + const key = keyOf(f.coord, category) + let info = byKey.get(key) + if (!info) { + info = { coord: f.coord, category, detail: f.detail, configs: new Set() } + byKey.set(key, info) + } + if (f.config) { + info.configs.add(f.config) + } + } + const allInfos = [...byKey.values()] + + const blockingConfigs = new Set() + for (const info of allInfos) { + if (isBlocking(info.category)) { + for (const c of info.configs) { + blockingConfigs.add(c) + } + } + } + const blockingFailed = [...blockingConfigs].sort() + const succeeded = scannedConfigs.filter(c => !blockingConfigs.has(c)).sort() + + const groups = dialect.categories + .map(spec => ({ + spec, + infos: dedupCoords( + allInfos.filter(i => i.category === spec.key).map(i => i.coord), + ).map(c => byKey.get(keyOf(c, spec.key))!), + })) + .filter(g => g.infos.length) + const blockingGroups = groups.filter(g => g.spec.blocking) + const nonBlockingGroups = groups.filter(g => !g.spec.blocking) + const blockingCount = blockingGroups.reduce((n, g) => n + g.infos.length, 0) + const hasBlockingFailures = blockingCount > 0 + const willFail = hasBlockingFailures && !opts.ignoreUnresolved + + const out: string[] = [] + if (hasBlockingFailures) { + out.push( + opts.ignoreUnresolved + ? `Ignored ${blockingCount} unresolved dependency(ies) in ${blockingFailed.length} configuration(s):` + : `Could not resolve ${blockingCount} dependency(ies) in ${blockingFailed.length} configuration(s):`, + ) + for (const { infos, spec } of blockingGroups) { + out.push('') + out.push(spec.header ? spec.header(name) : '') + for (const info of infos.slice(0, RESOLUTION_REPORT_ARTIFACT_LIMIT)) { + const fl = firstLine(info.detail) + const reasonSuffix = spec.showReason && fl ? ` [${fl}]` : '' + out.push(` - ${info.coord}${reasonSuffix}`) + } + if (infos.length > RESOLUTION_REPORT_ARTIFACT_LIMIT) { + out.push( + ` … and ${infos.length - RESOLUTION_REPORT_ARTIFACT_LIMIT} more`, + ) + } + } + out.push('') + if (succeeded.length) { + out.push( + `Resolution succeeded in: ${fmtList(succeeded, RESOLUTION_REPORT_CONFIG_LIMIT)}`, + ) + } + if (blockingFailed.length) { + out.push( + `Resolution failed in: ${fmtList(blockingFailed, RESOLUTION_REPORT_CONFIG_LIMIT)}`, + ) + } + if (willFail) { + out.push('') + out.push(`To proceed, re-run with either:`) + out.push(` --ignore-unresolved`) + if (blockingFailed.length) { + out.push(` --exclude-configs '${blockingFailed.join(',')}'`) + } + } + out.push('') + out.push(`Re-run with --verbose for ${name}'s full messages.`) + } + + const notices: string[] = [] + for (const { infos, spec } of nonBlockingGroups) { + if (!spec.notice) { + continue + } + const configCount = new Set(infos.flatMap(i => [...i.configs])).size + notices.push(spec.notice(name, infos.length, configCount)) + } + + const detailLines = [`${name}'s full message for each unresolved dependency:`] + for (const info of allInfos) { + detailLines.push('') + detailLines.push(` ${info.coord}:`) + for (const line of (info.detail || '(no message)').split('\n')) { + detailLines.push(` ${line}`) + } + } + + return { + summary: out.join('\n'), + details: detailLines.join('\n'), + hasBlockingFailures, + nonBlockingNotice: notices.join('\n'), + } +} + +function dialectFor(tool: BuildTool): ResolutionDialect { + switch (tool) { + case 'gradle': + return GRADLE_DIALECT + case 'sbt': + return SBT_DIALECT + case 'maven': + return MAVEN_DIALECT + } +} + +export function renderResolutionErrorReport( + failures: ResolutionFailure[], + scannedConfigs: string[] = [], + tool: BuildTool = 'gradle', + opts: { ignoreUnresolved?: boolean | undefined } = {}, +): RenderedResolutionReport { + return renderResolutionReport( + failures, + scannedConfigs, + dialectFor(tool), + opts, + ) +} diff --git a/src/commands/manifest/scripts/resolution-report-render.test.mts b/src/commands/manifest/scripts/resolution-report-render.test.mts new file mode 100644 index 000000000..acc746afd --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-render.test.mts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' + +import { renderResolutionErrorReport } from './resolution-report-render.mts' + +import type { ResolutionFailure } from './resolution-report.mts' + +const f = ( + coord: string, + detail: string, + config = 'runtimeClasspath', +): ResolutionFailure => ({ + coord, + detail, + config, +}) + +describe('resolution failure classification', () => { + it('classifies a Gradle registry miss as blocking not-found', () => { + const r = renderResolutionErrorReport( + [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + expect(r.nonBlockingNotice).toBe('') + }) + + it('treats Gradle variant ambiguity as non-blocking (notice only, no summary)', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:amb:1.0', + 'Cannot choose between the following variants of com.example:amb:1.0', + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toContain('ambiguous variant') + }) + + it('classifies a Gradle capability conflict as blocking', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.google.collections:google-collections:1.0', + 'Conflict on capability com.google.collections:google-collections', + ), + ], + ['runtimeClasspath'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Capability conflict') + }) + + it('surfaces both a blocking failure and a non-blocking notice together', () => { + const r = renderResolutionErrorReport( + [ + f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.'), + f( + 'com.example:amb:1.0', + 'Cannot choose between the following variants', + 'testRuntime', + ), + ], + ['runtimeClasspath', 'testRuntime'], + 'gradle', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + expect(r.nonBlockingNotice).toContain('ambiguous variant') + }) + + it('classifies an sbt/Ivy unresolved dependency as blocking not-found', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:missing:1.0', + 'unresolved dependency: com.example#missing;1.0: not found', + ), + ], + ['compile'], + 'sbt', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + // Ivy has no variant categories. + expect(r.nonBlockingNotice).toBe('') + }) + + it('classifies a Maven artifact miss as blocking not-found', () => { + const r = renderResolutionErrorReport( + [ + f( + 'com.example:missing:jar:1.0', + 'Could not find artifact com.example:missing:jar:1.0', + 'compile', + ), + ], + ['compile'], + 'maven', + ) + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Not found in any repository') + }) + + it('reports blocking failures as ignored (not fatal) under ignoreUnresolved', () => { + const r = renderResolutionErrorReport( + [f('com.example:missing:1.0', 'Could not find com.example:missing:1.0.')], + ['runtimeClasspath'], + 'gradle', + { ignoreUnresolved: true }, + ) + // hasBlockingFailures still reflects the classification; the caller decides. + expect(r.hasBlockingFailures).toBe(true) + expect(r.summary).toContain('Ignored') + expect(r.summary).not.toContain('To proceed, re-run') + }) + + it('returns an empty report when there are no failures', () => { + const r = renderResolutionErrorReport([], ['runtimeClasspath'], 'gradle') + expect(r.hasBlockingFailures).toBe(false) + expect(r.summary).toBe('') + expect(r.nonBlockingNotice).toBe('') + }) +}) diff --git a/src/commands/manifest/scripts/resolution-report.mts b/src/commands/manifest/scripts/resolution-report.mts new file mode 100644 index 000000000..2d56683b5 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report.mts @@ -0,0 +1,12 @@ +export type ResolutionFailure = { + coord: string + // Build tool's own failure message (deepest cause; may be multi-line). + // Classified on; first line shown by default, whole thing at --verbose. + detail: string + config: string +} + +export type ResolutionReport = { + failures: ResolutionFailure[] + scannedConfigs: string[] +} diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts new file mode 100644 index 000000000..68343d148 --- /dev/null +++ b/src/commands/manifest/scripts/run.mts @@ -0,0 +1,246 @@ +import { existsSync, promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { isSpawnError, spawn } from '@socketsecurity/registry/lib/spawn' + +import { assembleFacts } from './assemble.mts' +import { resolveBuildToolBin } from './build-tool.mts' +import { parseRecords } from './records.mts' +import constants from '../../../constants.mts' + +import type { BuildTool } from './build-tool.mts' +import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { ResolutionReport } from './resolution-report.mts' + +export type ManifestScriptOptions = { + projectDir: string + // Unset ⇒ resolved to the project wrapper, else PATH (resolveBuildToolBin). + bin?: string | undefined + // Reachability-only: also materialize resolved artifact paths (artifactPaths). + withFiles?: boolean | undefined + // Newline-delimited GAV file scoping withFiles materialization; absent ⇒ all. + populateFilesFor?: string | undefined + includeConfigs?: string | undefined + excludeConfigs?: string | undefined + toolOpts?: string[] | undefined + stdio?: 'inherit' | 'pipe' | undefined + env?: NodeJS.ProcessEnv | undefined + signal?: AbortSignal | undefined +} + +export type ManifestRunResult = { + code: number + facts: SocketFactsSbom + report: ResolutionReport + artifactPaths: ResolvedArtifactPaths +} + +type RunOutput = { code: number; stdout: string; stderr: string } + +const FACTS_TASK = 'socketFacts' +const SBT_PLUGIN_FILENAME = 'SocketFactsPlugin.scala' + +// Bundled emitter assets, copied into dist by the rollup build. +function manifestScriptsPath(...parts: string[]): string { + return path.join(constants.distPath, 'manifest-scripts', ...parts) +} + +// Don't throw on a non-zero exit: the script emits failure records, so a usable +// records file still exists. A non-exit spawn error (e.g. missing executable) +// propagates. +async function runNeverThrow( + bin: string, + args: string[], + opts: ManifestScriptOptions, +): Promise { + try { + const result = await spawn(bin, args, { + cwd: opts.projectDir, + stdio: opts.stdio ?? 'inherit', + ...(opts.env ? { env: opts.env } : {}), + ...(opts.signal ? { signal: opts.signal } : {}), + }) + return { + code: result.code, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + } + } catch (e) { + if (isSpawnError(e)) { + return { + code: e.code, + stdout: typeof e.stdout === 'string' ? e.stdout : '', + stderr: typeof e.stderr === 'string' ? e.stderr : '', + } + } + throw e + } +} + +async function withTmpDir( + prefix: string, + fn: (tmpDir: string) => Promise, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(tmpdir(), prefix)) + try { + return await fn(tmpDir) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } +} + +async function writeSbtPlugin(globalBase: string): Promise { + const src = await fs.readFile( + manifestScriptsPath('socket-facts.plugin.scala'), + 'utf8', + ) + const pluginsDir = path.join(globalBase, 'plugins') + await fs.mkdir(pluginsDir, { recursive: true }) + await fs.writeFile(path.join(pluginsDir, SBT_PLUGIN_FILENAME), src) +} + +async function assembleFromRecords( + code: number, + recordsFile: string, +): Promise { + const text = existsSync(recordsFile) + ? await fs.readFile(recordsFile, 'utf8') + : '' + const { artifactPaths, facts, report } = assembleFacts(parseRecords(text)) + return { code, facts, report, artifactPaths } +} + +// Missing only in an unbuilt local checkout. Fail loudly: without the extension, +// Maven silently emits an empty SBOM. +function assertMavenExtensionBuilt(jarPath: string): void { + if (existsSync(jarPath)) { + return + } + throw new Error( + `Maven manifest extension jar not found at ${jarPath}. It is bundled in the published CLI; for local dev build it with: bash src/commands/manifest/scripts/maven-extension/build-jar.sh`, + ) +} + +// Runs the build-tool script (which emits a records file) and assembles it. +// Writes no files; the caller persists facts or consumes artifactPaths. +export async function runManifestScript( + tool: BuildTool, + opts: ManifestScriptOptions, +): Promise { + switch (tool) { + case 'gradle': + return await runGradle(opts) + case 'sbt': + return await runSbt(opts) + case 'maven': + return await runMaven(opts) + } +} + +function commonProps( + opts: ManifestScriptOptions, + prefix: '-D' | '-P', +): string[] { + const props: string[] = [] + if (opts.withFiles) { + props.push(`${prefix}socket.withFiles=true`) + } + if (opts.populateFilesFor) { + props.push(`${prefix}socket.populateFilesFor=${opts.populateFilesFor}`) + } + if (opts.includeConfigs) { + props.push(`${prefix}socket.includeConfigs=${opts.includeConfigs}`) + } + if (opts.excludeConfigs) { + props.push(`${prefix}socket.excludeConfigs=${opts.excludeConfigs}`) + } + return props +} + +async function runGradle( + opts: ManifestScriptOptions, +): Promise { + const initScript = manifestScriptsPath('socket-facts.init.gradle') + return await withTmpDir('socket-gradle-facts-', 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 + // resolvedConfiguration API and shared accumulator aren't cache-safe. + const args = [ + '--init-script', + initScript, + '-Dorg.gradle.configuration-cache=false', + `-Psocket.recordsFile=${recordsFile}`, + ...commonProps(opts, '-P'), + ...(opts.toolOpts ?? []), + FACTS_TASK, + '--no-daemon', + '--console=plain', + ] + const out = await runNeverThrow(bin, args, opts) + return await assembleFromRecords(out.code, recordsFile) + }) +} + +async function runSbt(opts: ManifestScriptOptions): Promise { + return await withTmpDir('socket-sbt-facts-', async globalBase => { + await writeSbtPlugin(globalBase) + 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 + // plugins/, so a shared path is a code-injection surface. BSP off for this run. + const props = [ + `-Dsbt.global.base=${globalBase}`, + '-Dsbt.server.autostart=false', + `-Dsocket.recordsFile=${recordsFile}`, + ...commonProps(opts, '-D'), + ] + // sbt's launcher doesn't always honor JAVA_HOME; never override a + // caller-supplied --java-home. + const javaHome = opts.env?.['JAVA_HOME'] ?? process.env['JAVA_HOME'] + const javaHomeOpt = + javaHome && !(opts.toolOpts ?? []).includes('--java-home') + ? ['--java-home', javaHome] + : [] + const args = [ + ...javaHomeOpt, + ...props, + ...(opts.toolOpts ?? []), + '--batch', + FACTS_TASK, + ] + const out = await runNeverThrow(bin, args, opts) + return await assembleFromRecords(out.code, recordsFile) + }) +} + +async function runMaven( + opts: ManifestScriptOptions, +): Promise { + const jarPath = manifestScriptsPath( + 'maven-extension', + 'coana-maven-extension.jar', + ) + assertMavenExtensionBuilt(jarPath) + return await withTmpDir('socket-maven-facts-', 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). + const props = [ + `-Dmaven.ext.class.path=${jarPath}`, + '-Dcoana.task=socket-facts', + `-Dsocket.recordsFile=${recordsFile}`, + ...commonProps(opts, '-D'), + ] + const args = [ + ...props, + ...(opts.toolOpts ?? []), + '--batch-mode', + 'validate', + ] + const out = await runNeverThrow(bin, args, opts) + return await assembleFromRecords(out.code, recordsFile) + }) +} diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts new file mode 100644 index 000000000..d4cd81a27 --- /dev/null +++ b/src/commands/manifest/scripts/sidecar.mts @@ -0,0 +1,111 @@ +import { mavenCoordinateKey } from './facts.mts' + +import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' + +// Frozen contract with `coana run --compute-artifacts-sidecar`; change only in +// sync with the coana consumer. Per coordinate: targets/sources present → +// resolved (coana uses the paths); both empty → resolved with no artifact +// (pom/BOM), not a failure; absent → coana degrades that vuln to precomputed. +export type ResolvedComponent = { + group: string + name: string + version: string + ext: string + classifier: string | null + // Classpath entries (jars / first-party output dirs). + targets: string[] + // First-party source roots; [] for external deps. + sources: string[] +} + +// Bare array, no schema version: socket-cli pins the coana version, so producer +// and consumer never drift. +export type ResolvedPathsSidecar = ResolvedComponent[] + +// Keyed by full coordinate; unions paths so multiple build roots merge into one. +export type SidecarAccumulator = Map + +function pushUnique(into: string[], from: string[]): void { + for (const f of from) { + if (!into.includes(f)) { + into.push(f) + } + } +} + +function addEntry( + acc: SidecarAccumulator, + artifactPaths: ResolvedArtifactPaths, + group: string, + name: string, + version: string, + ext: string, + classifier: string | null, +): void { + const coordKey = mavenCoordinateKey( + group, + name, + ext || undefined, + classifier ?? undefined, + version || undefined, + ) + if (!coordKey) { + return + } + let entry = acc.get(coordKey) + if (!entry) { + entry = { group, name, version, ext, classifier, targets: [], sources: [] } + acc.set(coordKey, entry) + } + pushUnique(entry.targets, artifactPaths.targetsByCoord.get(coordKey) ?? []) + pushUnique(entry.sources, artifactPaths.sourcesByCoord.get(coordKey) ?? []) +} + +// Emit an entry for every SBOM component AND every first-party project: a +// top-level module is a project, not a dependency component, yet its source +// roots are where reachability starts, so the sidecar must carry them. +export function accumulateSidecar( + acc: SidecarAccumulator, + facts: SocketFactsSbom, + artifactPaths: ResolvedArtifactPaths, +): void { + for (const comp of facts.components) { + addEntry( + acc, + artifactPaths, + comp.namespace ?? '', + comp.name, + comp.version ?? '', + comp.qualifiers?.['ext'] ?? '', + comp.qualifiers?.['classifier'] ?? null, + ) + } + // First-party modules have no ext/classifier. + for (const proj of facts.projects ?? []) { + addEntry( + acc, + artifactPaths, + proj.namespace ?? '', + proj.name, + proj.version ?? '', + '', + null, + ) + } +} + +export function serializeSidecar( + acc: SidecarAccumulator, +): ResolvedPathsSidecar { + const resolved = [...acc.values()] + for (const entry of resolved) { + entry.targets.sort() + entry.sources.sort() + } + resolved.sort((a, b) => { + const ka = `${a.group}:${a.name}:${a.ext}:${a.classifier ?? ''}:${a.version}` + const kb = `${b.group}:${b.name}:${b.ext}:${b.classifier ?? ''}:${b.version}` + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) + return resolved +} diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts new file mode 100644 index 000000000..86a74ff2e --- /dev/null +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' + +import { accumulateSidecar, serializeSidecar } from './sidecar.mts' + +import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { SidecarAccumulator } from './sidecar.mts' + +function emptyArtifactPaths(): ResolvedArtifactPaths { + return { + targetsByCoord: new Map(), + targetsByGav: new Map(), + sourcesByCoord: new Map(), + coords: new Set(), + } +} + +function mkRootFixture(target: string): { + facts: SocketFactsSbom + paths: ResolvedArtifactPaths +} { + const paths = emptyArtifactPaths() + paths.targetsByCoord.set('g:a:jar:1', [target]) + return { + facts: { + components: [ + { + type: 'maven', + namespace: 'g', + name: 'a', + version: '1', + qualifiers: { ext: 'jar' }, + id: 'g:a:jar:1', + }, + ], + }, + paths, + } +} + +describe('compute-artifacts sidecar', () => { + it('emits the frozen ResolvedComponent[] contract', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'lib', + version: 'da517db', + qualifiers: { ext: 'jar' }, + id: 'com.example:lib:jar:da517db', + }, + ], + } + const artifactPaths = emptyArtifactPaths() + artifactPaths.targetsByCoord.set('com.example:lib:jar:da517db', [ + '/abs/lib.jar', + ]) + artifactPaths.sourcesByCoord.set('com.example:lib:jar:da517db', [ + '/abs/lib/src/main/java', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths) + const resolved = serializeSidecar(acc) + + expect(resolved).toEqual([ + { + group: 'com.example', + name: 'lib', + version: 'da517db', + ext: 'jar', + classifier: null, + targets: ['/abs/lib.jar'], + sources: ['/abs/lib/src/main/java'], + }, + ]) + }) + + it('emits empty target/source arrays for a resolved-but-artifactless coord (pom/BOM)', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'com.example', + name: 'bom', + version: '1.0', + qualifiers: { ext: 'pom' }, + id: 'com.example:bom:pom:1.0', + }, + ], + } + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, emptyArtifactPaths()) + const resolved = serializeSidecar(acc) + + expect(resolved).toHaveLength(1) + expect(resolved[0]!.targets).toEqual([]) + expect(resolved[0]!.sources).toEqual([]) + }) + + it('preserves a classifier qualifier and defaults it to null when absent', () => { + const facts: SocketFactsSbom = { + components: [ + { + type: 'maven', + namespace: 'g', + name: 'a', + version: '1', + qualifiers: { ext: 'jar', classifier: 'sources' }, + id: 'g:a:jar:sources:1', + }, + ], + } + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, emptyArtifactPaths()) + expect(serializeSidecar(acc)[0]!.classifier).toBe('sources') + }) + + it('carries a first-party module (project, not a component) source/target roots', () => { + const facts: SocketFactsSbom = { + // The app module is a project but nothing depends on it, so it is absent + // from components — its source roots must still reach the sidecar. + components: [], + projects: [ + { + type: 'maven', + namespace: 'com.example', + name: 'app', + version: '1.0', + subprojectDir: 'app', + dependencies: [], + resolvedAs: [], + }, + ], + } + const artifactPaths = emptyArtifactPaths() + artifactPaths.sourcesByCoord.set('com.example:app:1.0', [ + '/abs/app/src/main/java', + ]) + artifactPaths.targetsByCoord.set('com.example:app:1.0', [ + '/abs/app/build/classes', + ]) + + const acc: SidecarAccumulator = new Map() + accumulateSidecar(acc, facts, artifactPaths) + const resolved = serializeSidecar(acc) + + expect(resolved).toEqual([ + { + group: 'com.example', + name: 'app', + version: '1.0', + ext: '', + classifier: null, + targets: ['/abs/app/build/classes'], + sources: ['/abs/app/src/main/java'], + }, + ]) + }) + + it('merges the same coordinate across build roots, unioning paths', () => { + const acc: SidecarAccumulator = new Map() + const a = mkRootFixture('/root-a/a.jar') + const b = mkRootFixture('/root-b/a.jar') + accumulateSidecar(acc, a.facts, a.paths) + accumulateSidecar(acc, b.facts, b.paths) + const resolved = serializeSidecar(acc) + + expect(resolved).toHaveLength(1) + expect(resolved[0]!.targets).toEqual(['/root-a/a.jar', '/root-b/a.jar']) + }) +}) diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle new file mode 100644 index 000000000..2f2dae32e --- /dev/null +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -0,0 +1,479 @@ +// Invoke via: +// ./gradlew --init-script socket-facts.init.gradle socketFacts + +import java.util.Collections + +// Emits a flat line-protocol RECORDS file (NOT the final .socket.facts.json, and NOT to stdout). The +// TS assembler (utils/src/manifest-scripts/assemble.ts) reads the records and owns all SBOM +// construction — graph merge, content-addressed ids, contentHash. This script only RESOLVES and emits +// raw facts. See records.ts for the record grammar. + +// `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 } + +// Synchronized collections so --parallel-enabled builds don't race; lives on gradle.ext so every +// collector and the root aggregator share one instance. +gradle.ext.socketFactsState = [ + nodes : Collections.synchronizedMap([:]), + directIds : Collections.synchronizedSet([] as Set), + // `detail` is the build tool's failure message verbatim; Coana classifies the failure KIND from it. + failures : Collections.synchronizedList([]), + scannedConfigs : Collections.synchronizedSet([] as Set), + projectKeys : Collections.synchronizedSet([] as Set), + // Only populated with `-Psocket.withFiles=true`: reading `artifact.file` downloads the artifact. + paths : Collections.synchronizedMap([:]), + perSub : Collections.synchronizedMap([:]), + projectsInfo : Collections.synchronizedList([]), + // "group:name" -> the module's real artifact extension (e.g. jar), so an intra-project dep that + // resolves without selecting a published artifact still gets its true coordinate, not ext-less. + projectArtifactExt : Collections.synchronizedMap([:]), +] + +// Capture every project's (group:name) before collectors run so they can filter intra-project +// deps without an ordering dependency on other subprojects. +gradle.projectsEvaluated { g -> + def rootPath = g.rootProject.projectDir.toPath() + // '/'-normalized so values are stable across OSes and portable to another machine. + def rel = { java.io.File f -> + def r = rootPath.relativize(f.toPath()).toString().replace(File.separator, '/') + r.isEmpty() ? '.' : r + } + g.rootProject.allprojects.each { p -> + g.socketFactsState.projectKeys.add("${p.group ?: ''}:${p.name}") + // The module's real artifact extension (jar for java; '' for a project that builds no archive, + // e.g. a pure aggregator). Used to stamp intra-project deps that resolve without an artifact. + def artExt = '' + try { + def jt = p.tasks.findByName('jar') + if (jt != null) { + artExt = 'jar' + try { def e = jt.archiveExtension.getOrNull(); if (e) artExt = e } catch (Exception ig1) { + try { def e = jt.extension; if (e) artExt = e } catch (Exception ig2) {} + } + } + } catch (Exception ignore) {} + g.socketFactsState.projectArtifactExt["${p.group ?: ''}:${p.name}".toString()] = artExt + // Guarded: projects without the java `sourceSets` convention (pure aggregators, AGP-only + // Android modules) contribute empty lists rather than failing. + // `classesDirs` (Gradle 4+) falls back to the legacy single `classesDir` on older Gradle. + def sources = [] as Set + def targets = [] as Set + try { + if (p.hasProperty('sourceSets')) { + ['main', 'test'].each { sn -> + def ss = p.sourceSets.findByName(sn) + if (ss != null) { + ss.allSource.srcDirs.each { d -> sources << d.absolutePath } + try { + ss.output.classesDirs.each { d -> targets << d.absolutePath } + } catch (Exception e1) { + try { targets << ss.output.classesDir.absolutePath } catch (Exception ignore) {} + } + } + } + } + } catch (Exception ignore) {} + g.socketFactsState.projectsInfo.add([ + path : p.path, + group : (p.group ?: '').toString(), + name : p.name, + version: (p.version ?: '').toString(), + dir : rel(p.projectDir), + sources: (sources as List).sort(), + targets: (targets as List).sort(), + ]) + } +} + +allprojects { project -> + def collectTask = project.tasks.create('socketFactsCollect') { + description = "Resolves ${project.path}'s configurations into the build-wide Socket facts accumulator" + // Dependency resolution depends on state Gradle's up-to-date tracking can't represent reliably. + outputs.upToDateWhen { false } + + doLast { + def state = gradle.socketFactsState + // Per-RESOLUTION-ROOT accumulators, LOCAL to this collector: one (subproject, configuration) + // classpath resolved into its own coordinate-keyed tree. Reassigned per config below (the + // visit/upsertNode closures capture them); `failures`/`scannedConfigs` stay shared. + def nodes = [:] + def directIds = [] as Set + def failures = state.failures + def scannedConfigs = state.scannedConfigs + def projectKeys = state.projectKeys + def projectArtifactExt = state.projectArtifactExt + def paths = [:] + def withFiles = gradle.socketProp.call(project, 'socket.withFiles')?.toString()?.toLowerCase() == 'true' + // Optional `-Psocket.populateFilesFor=` (newline-delimited GAVs): scope `--with-files` + // materialization to those GAVs. This run walks EVERY resolvable config (not told the SBOM's + // include/exclude), so without a scope it would force-download the build's entire dep universe. + // Matched at GAV level to tolerate untrustworthy-ext SBOMs (lockfile / version catalog hardcode + // ext=jar). Absent/empty/missing file -> materialize all. Parsed once under the state monitor. + def populateFilesFor = gradle.socketProp.call(project, 'socket.populateFilesFor')?.toString() + def populateGavs = null + if (withFiles && populateFilesFor != null && !populateFilesFor.trim().isEmpty()) { + synchronized (state) { + if (!state.containsKey('populateGavs')) { + def f = new File(populateFilesFor.trim()) + if (f.exists()) { + def set = [] as Set + f.eachLine { line -> def t = line.trim(); if (!t.isEmpty()) { set << t } } + if (set.isEmpty()) { + // Empty file is more likely a wiring slip than a deliberate "fetch nothing"; materialize all. + state.populateGavs = null + println "[socket-facts] WARN populateFilesFor file empty; materializing files for all resolved artifacts" + } else { + state.populateGavs = set + println "[socket-facts] --with-files scoped to ${set.size()} SBOM artifact(s)" + } + } else { + // Missing file is more likely a wiring bug than an intentional empty scope; materialize all. + state.populateGavs = null + println "[socket-facts] WARN populateFilesFor file not found; materializing files for all resolved artifacts" + } + } + } + populateGavs = state.populateGavs + } + + // Full Maven coordinate (absent segments dropped) — each ext/classifier variant is its own + // component; the id is the coordinate key reachability joins on (see mavenCoordinateKey). + def coordId = { coord -> + [coord.groupId, coord.artifactId, coord.ext, coord.classifier, coord.version].findAll { it }.join(':') + } + + def isIntraProject = { String group, String name -> + projectKeys.contains("${group ?: ''}:${name}") + } + + // A first-party module dep can resolve with no published artifact (classes-dir variant, or + // variant-ambiguous moduleArtifacts), leaving it ext-less. Stamp the module's real artifact + // extension so it keeps a full, stable coordinate instead of an ext-less one. + def effExt = { String group, String name, String ext -> + if ((ext == null || ext.isEmpty()) && isIntraProject(group, name)) { + projectArtifactExt["${group ?: ''}:${name}".toString()] ?: '' + } else { + ext ?: '' + } + } + + // A node is created once; its prod flag accumulates (OR) across the configs that reach it. + def upsertNode = { Map coord, boolean isProd -> + def id = coordId(coord) + synchronized (nodes) { + def node = nodes[id] + if (node == null) { + node = [coord: coord, children: [] as Set, prod: false] + nodes[id] = node + } + if (isProd) { + node.prod = true + } + } + id + } + + // Walk a resolved dependency and its transitive closure. We never touch `artifact.file` here — + // that forces Gradle to download the file (catastrophic on builds declaring distribution + // archives as deps); `artifact.extension`/`artifact.classifier` read already-resolved metadata. + // Intra-project deps (project(':lib')) ARE emitted and recursed, so an external the consumer + // pins/forces to a different version THROUGH the edge is captured (firstLevelModuleDependencies + // lists only declared deps, so a project dep's transitives are reachable only via the edge). + def visit + visit = { dep, boolean isProd, Map cache -> + if (cache.containsKey(dep)) { + return cache[dep] + } + // Pre-populate the cache to break cycles before we recurse. + def producedIds = [] as Set + cache[dep] = producedIds + + // `moduleArtifacts` forces ARTIFACT-level variant selection, which can throw on a + // variant-ambiguous dep even when the dependency GRAPH resolves fine. Guard per-dep: on + // failure emit at GAV level and keep walking so one ambiguous artifact can't sink the config. + def artifacts + try { + artifacts = dep.moduleArtifacts + } catch (Exception e) { + artifacts = [] as Set + } + if (artifacts.isEmpty()) { + def ext = effExt(dep.moduleGroup, dep.moduleName, '') + // Skip a no-artifact first-party module (aggregator / build root): it builds no archive, so + // it's fully described by `projects` and is never a real artifact dependency. + if (!(ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName))) { + producedIds << upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: '', + ext : ext, + ], isProd) + } + } else { + // Build the GAV scope key only when a scope is set: the no-scope path never reads it and + // this is the hottest line under --parallel. + def shouldMaterialize + if (!withFiles) { + shouldMaterialize = false + } else if (populateGavs == null) { + shouldMaterialize = true + } else { + def gav = "${dep.moduleGroup ?: ''}:${dep.moduleName}:${dep.moduleVersion ?: ''}".toString() + shouldMaterialize = populateGavs.contains(gav) + } + artifacts.each { a -> + // Directory variants (java-classes-directory etc.) carry no extension; for a first-party + // module fall back to its real artifact ext (never artifact.type, a Gradle variant attr). + def ext = effExt(dep.moduleGroup, dep.moduleName, a.extension) + // Skip a no-artifact first-party module (see the empty-artifacts branch above). + if (ext.isEmpty() && isIntraProject(dep.moduleGroup, dep.moduleName)) return + def aid = upsertNode([ + groupId : dep.moduleGroup ?: '', + artifactId: dep.moduleName, + version : dep.moduleVersion ?: '', + classifier: a.classifier ?: '', + ext : ext, + ], isProd) + producedIds << aid + // `a.file` downloads the artifact if not already cached, so scoping avoids fetching + // artifacts the SBOM doesn't reference. Per-artifact try/catch: a single download can + // fail without the whole config being unresolvable. + if (shouldMaterialize) { + try { + def path = a.file.absolutePath + synchronized (paths) { + def set = paths[aid] + if (set == null) { set = [] as Set; paths[aid] = set } + set << path + } + } catch (Exception e) { + println "[socket-facts] could not materialize ${aid}: ${e.message?.readLines()?.first()}" + } + } + } + } + + def childIds = [] as Set + dep.children.each { child -> + childIds.addAll(visit(child, isProd, cache)) + } + // A skipped no-artifact first-party module produced no node, so bubble its resolved children + // up to the parent (return childIds) — otherwise the module's transitives orphan (attached to + // nothing, not bubbled). The module itself is described by `projects`. + if (producedIds.isEmpty()) { + cache[dep] = childIds + return childIds + } + synchronized (nodes) { + producedIds.each { pid -> + // Drop self-edges: a project that depends on its own output (test → main) can resolve + // to the same coordinate as its root, which would otherwise emit a node listing itself. + nodes[pid].children.addAll(childIds.findAll { it != pid }) + } + } + producedIds + } + + def isTestConfig = { String name -> name.toLowerCase().contains('test') } + // Matches real compile/runtime classpaths (modern `*Classpath`, legacy `compile`/`runtime`), + // NOT a loose substring that would wrongly promote war-plugin `providedCompile`/`providedRuntime` + // (provided deps are dev). Sets the informational `dev` flag only — never gates analysis. + def isClasspathConfig = { String name -> + def n = name.toLowerCase() + n.contains('classpath') || n == 'compile' || n == 'runtime' + } + + // Config-name glob (`*`/`?` only) to a case-INSENSITIVE regex — Gradle config names are + // camelCase, so a case-sensitive `*Classpath` would miss the base `compileClasspath`. + def globToRegex = { String glob -> + def sb = new StringBuilder() + glob.each { String ch -> + switch (ch) { + case '*': sb << '.*'; break + case '?': sb << '.'; break + case '.': case '\\': case '^': case '$': case '|': + case '+': case '(': case ')': + case '[': case ']': case '{': case '}': + sb << '\\' << ch; break + default: sb << ch + } + } + java.util.regex.Pattern.compile(sb.toString(), java.util.regex.Pattern.CASE_INSENSITIVE) + } + + // `-Psocket.includeConfigs`/`-Psocket.excludeConfigs`: comma-separated config-name globs. A + // config is walked when it matches some include (or there are none) AND matches no exclude. + def parsePatterns = { String s -> + def out = [] + if (s != null && !s.trim().isEmpty()) { + s.split(',').each { raw -> + def p = raw.trim() + if (!p.isEmpty()) out << globToRegex(p) + } + } + out + } + def includePatterns = parsePatterns(gradle.socketProp.call(project, 'socket.includeConfigs')?.toString()) + def excludePatterns = parsePatterns(gradle.socketProp.call(project, 'socket.excludeConfigs')?.toString()) + + // Don't filter by canBeConsumed: Gradle defaults both role flags to true, so a plain resolvable + // config is also consumable, and excluding consumable configs would drop legitimate custom ones. + // `isCanBeResolved` only exists since Gradle 3.3; older Gradle has no split (all resolvable). + def isResolvable = { c -> c.metaClass.respondsTo(c, 'isCanBeResolved') ? c.canBeResolved : true } + def targetConfigs = project.configurations.findAll { + if (!isResolvable(it)) return false + def name = it.name + if (excludePatterns.any { p -> p.matcher(name).matches() }) return false + if (!includePatterns.isEmpty() && !includePatterns.any { p -> p.matcher(name).matches() }) return false + return true + } + + // Deepest cause's message, in FULL — Coana classifies/templates it, so we don't categorize here. + def rawDetail = { problem -> + def deepest = null + def t = problem + int guard = 0 + while (t != null && guard++ < 12) { if (t.message) { deepest = t.message }; t = t.cause } + return (deepest ?: 'unknown resolution failure').toString().trim() + } + + targetConfigs.each { cfg -> + // Recorded even if it resolves nothing or throws — Coana surfaces the full scanned set. + scannedConfigs.add(cfg.name) + // Informational only (OR-accumulated across configs); never gates analysis, so this name + // heuristic can only mis-bucket a dep in prod/dev views, never drop it. + def isProd = isClasspathConfig(cfg.name) && !isTestConfig(cfg.name) + // Fresh tree for THIS resolution root — captured by visit/upsertNode, so no union with siblings. + nodes = [:] + directIds = [] as Set + paths = [:] + // Per-config try/catch: AGP-style configs can fail variant ambiguity from an init-script + // context lacking the consumer attributes AGP sets internally. + try { + def lenient = cfg.resolvedConfiguration.lenientConfiguration + def cache = [:] + // No-arg getter only since Gradle 3.3; older Gradle has just the Spec-taking overload. + def firstLevel + try { + firstLevel = lenient.firstLevelModuleDependencies + } catch (MissingPropertyException e) { + firstLevel = lenient.getFirstLevelModuleDependencies({ true } as org.gradle.api.specs.Spec) + } + firstLevel.each { dep -> + directIds.addAll(visit(dep, isProd, cache)) + } + // Unresolved deps drive the root aggregator's abort/warn decision but are NOT emitted as + // nodes — their selector-only coordinates would surface as half-formed phantom artifacts. + lenient.unresolvedModuleDependencies.each { dep -> + def sel = dep.selector + if (isIntraProject(sel.group, sel.name)) { + return + } + failures << [ + coord : "${sel.group}:${sel.name}${sel.version ? ':' + sel.version : ''}".toString(), + detail: rawDetail(dep.problem), + config: cfg.name, + ] + } + } catch (Exception e) { + println "[socket-facts] skipping ${project.path}:${cfg.name}: ${e.message?.readLines()?.first()}" + } + // Stash this resolution root's tree; the key only has to be unique per root (aggregator + // groups by subtree hash). Skip empty configs — they'd contribute nothing. + if (!nodes.isEmpty()) { + synchronized (state.perSub) { + state.perSub["${project.path}::${cfg.name}".toString()] = + [nodes: nodes, direct: directIds, paths: paths, projectPath: project.path, config: cfg.name, prod: isProd] + } + } + } + } + } +} + +rootProject { rp -> + // Hoist property reads to configuration time: the configuration cache forbids `Task.project` from + // task actions. The Socket CLI disables the cache for this run, but hoisting is cheap insurance. + def recordsFileOverride = gradle.socketProp.call(rp, 'socket.recordsFile')?.toString() + def defaultRecordsFile = new File(rp.projectDir, '.socket.facts.records.tsv').absolutePath + // `sources`/`targets` are --with-files-only; a plain run emits only the graph fields. + def withFilesProjects = gradle.socketProp.call(rp, 'socket.withFiles')?.toString()?.toLowerCase() == 'true' + + rp.tasks.create('socketFacts') { + group = 'socket' + description = 'Emits Socket facts records for the entire build' + outputs.upToDateWhen { false } + + doLast { + def state = gradle.socketFactsState + + // Backslash-escape so a value can never break line/field framing (see records.ts unescape). + 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')]) + + // One `project` record per build module (sources/targets only with --with-files). + def projectsInfo + synchronized (state.projectsInfo) { projectsInfo = new ArrayList(state.projectsInfo) } + projectsInfo.each { pi -> + rec(['project', pi.path, pi.group ?: '', pi.name, pi.version ?: '', pi.dir]) + if (withFilesProjects) { + pi.sources.each { s -> rec(['projectSrc', pi.path, s]) } + pi.targets.each { t -> rec(['projectTgt', pi.path, t]) } + } + } + + // One resolution root per (subproject, configuration); the TS assembler merges them + // path-sensitively and content-addresses divergent subtrees. + def perSub + synchronized (state.perSub) { perSub = new LinkedHashMap(state.perSub) } + int rootIdx = 0 + perSub.each { rootKey, tree -> + def rootId = (rootIdx++).toString() + rec(['root', rootId, tree.projectPath, tree.config, tree.prod ? '1' : '0']) + tree.nodes.each { coordId, node -> + def c = node.coord + rec(['node', rootId, coordId, c.groupId ?: '', c.artifactId ?: '', c.version ?: '', c.ext ?: '', + c.classifier ?: '', tree.direct.contains(coordId) ? '1' : '0']) + node.children.each { childId -> rec(['edge', rootId, coordId, childId]) } + def fs = tree.paths[coordId] + if (fs) { (fs as List).sort().each { p -> rec(['file', rootId, coordId, p]) } } + } + } + + // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering). + def scanned + synchronized (state.scannedConfigs) { scanned = (state.scannedConfigs as List).sort() } + scanned.each { name -> rec(['scanned', name]) } + def failures + synchronized (state.failures) { failures = new ArrayList(state.failures) } + failures.each { f -> rec(['failure', f.coord, f.detail, f.config]) } + + def outFile = new File(recordsFileOverride ?: defaultRecordsFile) + outFile.parentFile?.mkdirs() + // Explicit UTF-8: the TS reader reads UTF-8, but `File.text =` would encode + // with the JVM default charset (Cp1252 / US-ASCII on Windows or a non-UTF-8 + // locale on JDK <=17), corrupting non-ASCII paths and failure messages. + outFile.withWriter('UTF-8') { it.write(lines.join('\n') + '\n') } + println "Socket facts records written to: ${outFile.absolutePath}" + } + } +} + +gradle.projectsEvaluated { g -> + def aggregator = g.rootProject.tasks.findByName('socketFacts') + if (aggregator) { + g.rootProject.allprojects.each { p -> + def collector = p.tasks.findByName('socketFactsCollect') + if (collector) { + aggregator.dependsOn(collector) + } + } + } +} diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala new file mode 100644 index 000000000..b121c31bb --- /dev/null +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -0,0 +1,444 @@ +package socket + +import sbt._ +import sbt.Keys._ + +import scala.collection.mutable +import scala.reflect.ClassTag + +/** + * Emits a flat line-protocol RECORDS file at the build root (NOT the final + * `.socket.facts.json`, and NOT to stdout — sbt prints resolution noise to stdout + * with no way to silence it). The TS assembler (utils/src/manifest-scripts/assemble.ts) + * reads the records and owns all SBOM construction — graph merge, content-addressed + * ids, contentHash. This plugin only RESOLVES and emits raw facts. See records.ts for + * the record grammar. + * + * Must compile on Scala 2.10/sbt 0.13 and Scala 2.12/sbt 1.x (compiled by the sbt + * meta-build), hence string-named TaskKeys and reflection for version-specific + * ExclusionRule / ResolveException / ConfigRef shapes. + */ +object SocketFactsPlugin extends AutoPlugin { + override def trigger = allRequirements + + object autoImport { + val socketFacts = + taskKey[Unit]("Emit Socket facts records for the whole build") + } + import autoImport._ + + override def projectSettings: Seq[Setting[_]] = Seq( + aggregate in socketFacts := false, + socketFacts := { + val st = state.value + val buildRoot = (baseDirectory in ThisBuild).value + val withFiles = boolProp("socket.withFiles") + val populateScope: Option[Set[String]] = readPopulateScope() + + val extracted = Project.extract(st) + val allRefs = extracted.structure.allProjectRefs + + // Prefer `updateFull` (coursier `update` returns empty callers); fall back to `update` (sbt 0.13). + val hasUpdateFull = + extracted.structure.settings.map(_.key.key).exists(_.label == "updateFull") + val updateTaskName = if (hasUpdateFull) "updateFull" else "update" + + // One tree per resolution root (project, config); the TS assembler merges them path-sensitively. + val perSub = mutable.LinkedHashMap.empty[String, RootTree] + val failures = mutable.LinkedHashSet.empty[Failure] + val scannedConfigs = mutable.LinkedHashSet.empty[String] + val matcher = buildConfigMatcher() + + // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate. + val moduleExts = buildModuleExts(allRefs, extracted) + + allRefs.foreach { ref => + runUpdateResilient(updateTaskName, ref, extracted, st, failures).foreach { report => + foldReport(report, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + case (rootKey, tree) => perSub(rootKey) = tree + } + } + } + + val moduleDirs: Map[String, (Seq[String], Seq[String])] = + if (withFiles) buildModuleDirs(allRefs, extracted) else Map.empty + + val rootPath = buildRoot.getCanonicalFile.toPath + def relOf(f: File): String = { + val r = rootPath.relativize(f.getCanonicalFile.toPath).toString.replace(java.io.File.separator, "/") + if (r.isEmpty) "." else r + } + + 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", "")) + + // One `project` record per build module (sources/targets only with --with-files). + allRefs.foreach { ref => + val mid = rootIdOf(extracted, 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)))) + if (withFiles) { + moduleDirs.get(mid.organization + ":" + mid.name + ":" + ver).foreach { + case (sources, targets) => + sources.foreach(s => rec("projectSrc", ref.project, s)) + targets.foreach(t => rec("projectTgt", ref.project, t)) + } + } + } + + // One resolution root per (subproject, configuration); the TS assembler content-addresses + // divergent subtrees. + var rootIdx = 0 + perSub.foreach { + case (_, tree) => + val rootId = rootIdx.toString + rootIdx += 1 + rec("root", rootId, tree.projectKey, tree.config, if (tree.prod) "1" else "0") + tree.nodes.foreach { + case (coordId, node) => + val c = node.coord + rec("node", rootId, coordId, c.org, c.name, c.version, c.ext, c.classifier, if (node.direct) "1" else "0") + node.children.foreach(ch => rec("edge", rootId, coordId, ch)) + node.targets.foreach(p => rec("file", rootId, coordId, p)) + } + } + + // Scanned configs + raw failures (Coana owns the abort/warn policy and report rendering). + scannedConfigs.toList.sorted.foreach(c => rec("scanned", c)) + failures.foreach(f => rec("failure", f.coord, f.detail, f.config)) + + val recordsFile = sys.props.get("socket.recordsFile").filter(_.nonEmpty) match { + case Some(p) => new File(p) + case None => new File(buildRoot, ".socket.facts.records.tsv") + } + Option(recordsFile.getParentFile).foreach(_.mkdirs()) + IO.write(recordsFile, sb.toString) + println("Socket facts records written to: " + recordsFile.getAbsolutePath) + } + ) + + // ---- resolution --------------------------------------------------------- + + private def rootIdOf(extracted: Extracted, 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))) + } + + // GAV -> a build module's real artifact extension (jar for a normal project). coursier reports an + // inter-project dependency with no resolved artifact, so it would otherwise be ext-less and not + // match how depscan ingests it (ext=jar); stamping the real ext keeps a full, matching coordinate. + private def buildModuleExts(allRefs: Seq[ProjectRef], extracted: Extracted): Map[String, String] = { + allRefs.map { ref => + val ext = extracted.getOpt(artifact.in(ref)).map(_.extension).filter(e => e != null && e.nonEmpty).getOrElse("jar") + gavKey(rootIdOf(extracted, ref)) -> ext + }.toMap + } + + // Absolute source roots + compiled-output dirs per build module, keyed by GAV. --with-files only; + // absolute because reachability locates an internal module's code on THIS machine (no registry jar). + private def buildModuleDirs( + allRefs: Seq[ProjectRef], + extracted: Extracted + ): Map[String, (Seq[String], Seq[String])] = { + allRefs.map { ref => + val mid = rootIdOf(extracted, ref) + val ver = if (mid.revision == null) "" else mid.revision + val sources = Seq(Compile, Test) + .flatMap(c => extracted.getOpt(sourceDirectories.in(ref).in(c)).getOrElse(Nil)) + .map(_.getAbsolutePath).distinct.sorted + val targets = Seq(Compile, Test) + .flatMap(c => extracted.getOpt(classDirectory.in(ref).in(c))) + .map(_.getAbsolutePath).distinct.sorted + (mid.organization + ":" + mid.name + ":" + ver) -> ((sources, targets)) + }.toMap + } + + // On a hard resolve failure, record the failed modules and retry once with them excluded. Never throws. + private def runUpdateResilient( + taskName: String, + ref: ProjectRef, + extracted: Extracted, + state: State, + failures: mutable.LinkedHashSet[Failure] + ): Option[UpdateReport] = { + val key = TaskKey[UpdateReport](taskName) + + def runOn(st: State): Either[Seq[ModuleID], UpdateReport] = + Project.runTask(key.in(ref).scopedKey, st) match { + case Some((_, sbt.Value(rep))) => Right(rep) + case Some((_, sbt.Inc(inc))) => Left(extractFailedModules(inc)) + case _ => Left(Nil) + } + + runOn(state) match { + case Right(rep) => Some(rep) + case Left(failed) => + failed.foreach { m => + failures += Failure(coordOf(m), "unresolved dependency", taskName) + } + if (failed.isEmpty) None + else + runOn(extracted.append(exclusionSettings(extracted, failed), state)) match { + case Right(rep) => Some(rep) + case Left(_) => None + } + } + } + + // Reflection: ResolveException's package and the `failed` accessor differ between sbt 0.13 and 1.x. + private def extractFailedModules(inc: sbt.Incomplete): Seq[ModuleID] = { + val out = mutable.ListBuffer.empty[ModuleID] + def walk(i: sbt.Incomplete): Unit = { + i.directCause.foreach { ex => + val cn = ex.getClass.getName + if (cn == "sbt.ResolveException" || cn == "sbt.librarymanagement.ResolveException") { + try { + val m = ex.getClass.getMethod("failed") + out ++= m.invoke(ex).asInstanceOf[Seq[ModuleID]] + } catch { case _: Throwable => } + } + } + i.causes.foreach(walk) + } + walk(inc) + out.toList.distinct + } + + // ExclusionRule constructor shape differs between sbt 0.13 (`sbt.ExclusionRule`) and 1.x (`sbt.SbtExclusionRule`). + private def exclusionSettings(extracted: Extracted, failed: Seq[ModuleID]): Seq[Setting[_]] = { + val cls: Class[_] = + try Class.forName("sbt.SbtExclusionRule") + catch { case _: ClassNotFoundException => classOf[sbt.ExclusionRule] } + + def rule(m: ModuleID): Any = + if (cls.getName.contains("SbtExclusionRule")) { + cls.getConstructors.find(_.getParameterCount == 5) match { + case Some(ctor) => + ctor.newInstance(m.organization, m.name, "*", Seq(), sbt.CrossVersion.Disabled) + case None => + throw new IllegalStateException("No suitable SbtExclusionRule constructor") + } + } else ExclusionRule(organization = m.organization, name = m.name) + + def castSeq[T](xs: Seq[Any])(implicit ct: ClassTag[T]): Seq[T] = xs.map(_.asInstanceOf[T]) + + extracted.structure.allProjectRefs.map { ref => + excludeDependencies.in(ref) := { + val original = excludeDependencies.in(ref).value + castSeq(original ++ failed.map(rule))(ClassTag(cls)) + } + } + } + + // Fold one project's UpdateReport into one tree per configuration (rootKey -> tree), keyed by coordinate. + private def foldReport( + report: UpdateReport, + ref: ProjectRef, + extracted: Extracted, + matcher: String => Boolean, + scannedConfigs: mutable.LinkedHashSet[String], + withFiles: Boolean, + populateScope: Option[Set[String]], + moduleExts: Map[String, String] + ): mutable.LinkedHashMap[String, RootTree] = { + val perRoot = mutable.LinkedHashMap.empty[String, RootTree] + val rootGav = gavKey(rootIdOf(extracted, ref)) + + def emittable(m: ModuleReport): Boolean = !m.evicted + + def inScope(m: ModuleID): Boolean = populateScope match { + case None => true + case Some(gavs) => gavs.contains(gavKey(m)) + } + + report.configurations.foreach { cr => + val cfg = confName(cr) + if (matcher(cfg)) { + scannedConfigs += cfg + val prod = isProdConf(cfg) && !isTestConf(cfg) + val nodes = mutable.LinkedHashMap.empty[String, Node] + // module GAV -> component ids (caller edges are module-level). + val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] + + cr.modules.foreach { m => + if (emittable(m)) { + val ids = midToIds.getOrElseUpdate(gavKey(m.module), mutable.LinkedHashSet.empty[String]) + variantsOf(m, moduleExts).foreach { case (coord, fileOpt) => + val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) + ids += coord.id + if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) + } + } + } + + // Caller edges within this config: a root caller marks the child direct, any other becomes its parent. + cr.modules.foreach { m => + if (emittable(m)) { + midToIds.get(gavKey(m.module)).foreach { childIds => + m.callers.foreach { c => + val callerKey = gavKey(c.caller) + if (callerKey == rootGav) childIds.foreach(cid => nodes(cid).direct = true) + else + midToIds.get(callerKey).foreach { parentIds => + // Drop self-edges (test → main resolving to the same coordinate), matching gradle. + parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid)) + } + } + } + } + } + + if (nodes.nonEmpty) perRoot(ref.project + "::" + cfg) = new RootTree(ref.project, cfg, prod, nodes) + } + } + perRoot + } + + private def variantsOf(m: ModuleReport, moduleExts: Map[String, String]): Seq[(Coord, Option[File])] = { + val mid = m.module + // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls + // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records). + val ver = if (mid.revision == null) "" else mid.revision + val arts = m.artifacts + if (arts == null || arts.isEmpty) + // No resolved artifact (e.g. an inter-project dep): stamp the module's real ext if it's a build + // module (matches depscan's ext=jar), else leave ext-less. + Seq((Coord(mid.organization, mid.name, ver, moduleExts.getOrElse(gavKey(mid), ""), ""), None)) + else + arts.toList.map { + case (art, file) => + (Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)), Option(file)) + } + } + + // ---- config selection --------------------------------------------------- + + // With no includes the default is ALL configurations (captures build/tooling deps). + private def buildConfigMatcher(): String => Boolean = { + def parse(prop: String): List[java.util.regex.Pattern] = + sys.props.get(prop) match { + case Some(s) if s.trim.nonEmpty => + s.split(",").map(_.trim).filter(_.nonEmpty).toList.map(globToRegex) + case _ => Nil + } + val includes = parse("socket.includeConfigs") + val excludes = parse("socket.excludeConfigs") + (name: String) => { + val included = + if (includes.isEmpty) true + else includes.exists(_.matcher(name).matches()) + included && !excludes.exists(_.matcher(name).matches()) + } + } + + private def globToRegex(glob: String): java.util.regex.Pattern = { + val sb = new StringBuilder + glob.foreach { + case '*' => sb.append(".*") + case '?' => sb.append('.') + case c if "\\.^$|+()[]{}".indexOf(c.toInt) >= 0 => + sb.append('\\').append(c) + case c => sb.append(c) + } + java.util.regex.Pattern.compile(sb.toString, java.util.regex.Pattern.CASE_INSENSITIVE) + } + + // ConfigurationReport.configuration is a String on sbt 0.13, a ConfigRef on 1.x: read `.name` reflectively. + private def confName(cr: ConfigurationReport): String = { + val c: Any = cr.configuration + try c.getClass.getMethod("name").invoke(c).toString + catch { case _: Throwable => c.toString } + } + + private def isTestConf(name: String): Boolean = name.toLowerCase.contains("test") + + // Only feeds the informational `dev` flag, never gates analysis. + private def isProdConf(name: String): Boolean = { + val n = name.toLowerCase + n == "compile" || n == "runtime" + } + + // ---- misc helpers -------------------------------------------------------- + + private def boolProp(name: String): Boolean = + java.lang.Boolean.parseBoolean(sys.props.getOrElse(name, "false")) + + private def readPopulateScope(): Option[Set[String]] = { + sys.props.get("socket.populateFilesFor").map(_.trim).filter(_.nonEmpty) match { + case None => None + case Some(path) => + val f = new java.io.File(path) + if (!f.exists) { + println("[socket-facts] WARN populateFilesFor file not found; recording files for all resolved artifacts") + None + } else { + val src = scala.io.Source.fromFile(f, "UTF-8") + try { + val set = src.getLines().map(_.trim).filter(_.nonEmpty).toSet + if (set.isEmpty) { + println("[socket-facts] WARN populateFilesFor file empty; recording files for all resolved artifacts") + None + } else { + println("[socket-facts] --with-files scoped to " + set.size + " SBOM artifact(s)") + Some(set) + } + } finally src.close() + } + } + } + + private def gavKey(m: ModuleID): String = { + def s(v: String): String = if (v == null) "" else v + s(m.organization) + ":" + s(m.name) + ":" + s(m.revision) + } + + private def coordOf(m: ModuleID): String = { + val rev = m.revision + m.organization + ":" + m.name + (if (rev != null && rev.nonEmpty) ":" + rev else "") + } + + private def extOf(a: Artifact): String = { + val e = a.extension + if (e == null || e.isEmpty) "jar" else e + } + + private def classifierOf(a: Artifact): String = + a.classifier.getOrElse("") + + // 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") + } + + private final case class Failure(coord: String, detail: String, config: String) + + private final case class Coord( + org: String, + name: String, + version: String, + ext: String, + classifier: String + ) { + val id: String = Seq(org, name, ext, classifier, version).filter(_.nonEmpty).mkString(":") + } + + private final class Node(val coord: Coord) { + val children = mutable.TreeSet.empty[String] + var direct = false + // External artifact's resolved jar(s); --with-files only. + val targets = mutable.TreeSet.empty[String] + } + + private final class RootTree( + val projectKey: String, + val config: String, + val prod: Boolean, + val nodes: mutable.LinkedHashMap[String, Node] + ) +} diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md new file mode 100644 index 000000000..572062a1a --- /dev/null +++ b/src/commands/manifest/scripts/test/README.md @@ -0,0 +1,25 @@ +# 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. + +## Run locally, on demand + +There is **no CI for this matrix**: SocketDev's org action allowlist forbids +`actions/setup-java` and `sbt/setup-sbt`, so the build-tool matrix has no GitHub +Actions home. Run it locally whenever you change one of the scripts or the +Maven extension: + +```sh +src/commands/manifest/scripts/test/run-compat.sh [gradle|sbt|maven|all] +``` + +The matrix needs several JDKs. Point `JDK8` / `JDK11` / `JDK17` / `JDK21` at JDK +homes to use the right one per row; otherwise the current `java` is used. The +sbt rows also need the `sbt` launcher on `PATH`. + +The runner downloads the build-tool distributions and invokes the per-ecosystem +`smoke-test.sh`. The unit-level assembler/sidecar behavior is covered separately +by the `*.test.mts` unit tests. diff --git a/src/commands/manifest/scripts/test/gradle-compat/.gitignore b/src/commands/manifest/scripts/test/gradle-compat/.gitignore new file mode 100644 index 000000000..a60b93e60 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/.gitignore @@ -0,0 +1,8 @@ +# generated at test time +project/localrepo/ +project/records.tsv +project/.socket.facts.json +project/.gradle/ +project/build/ +.gradle-home/ +.populate-for.txt diff --git a/src/commands/manifest/scripts/test/gradle-compat/README.md b/src/commands/manifest/scripts/test/gradle-compat/README.md new file mode 100644 index 000000000..219d3eca3 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/README.md @@ -0,0 +1,31 @@ +# Gradle-version compatibility smoke test + +`socket-facts.init.gradle` must run across a very wide Gradle range. It uses several +reflective shims so it degrades gracefully on Gradle older than its modern target: + +- `socketProp` (hasProperty/property) instead of `Project.findProperty` (Gradle 2.13+) +- a reflective `isResolvable` probe instead of `Configuration.isCanBeResolved` (Gradle 3.3+) +- a `try`/`catch` fallback to the `Spec`-taking `getFirstLevelModuleDependencies(Spec)` when the + no-arg overload is absent (Gradle 3.3+) + +Those fallback branches never execute on modern Gradle, so without a test on *old* Gradle they +could silently rot. This smoke test exercises them. + +## What it does +`smoke-test.sh ` generates a tiny **local** Maven repo (`make-localrepo.sh` — two +transitive-free artifacts, a prod `demo.lib:foo` and a test `demo.test:bar`), runs the init script's +`socketFacts` task against `project/` **fully offline**, and asserts the emitted RECORDS (the script's +only output — the TS assembler that turns records into `.socket.facts.json` is covered by +`nx test utils`): exactly the two expected dependency nodes, `foo` in a prod root, `bar` only in +non-prod roots, and an on-disk jar `file` record for each under `-Psocket.withFiles`. + +A local repo (not Maven Central) is essential: Gradle 1.x/2.x can't negotiate modern Maven Central's +TLS, so only an offline local repo makes the old-version matrix entries testable at all. + +## Running locally +```bash +curl -fsSL https://services.gradle.org/distributions/gradle-2.14.1-bin.zip -o g.zip && unzip -q g.zip +JAVA_HOME= ./smoke-test.sh "$PWD/gradle-2.14.1/bin/gradle" +``` +CI runs it (and the SBT equivalent) across a label-gated matrix in +`.github/workflows/manifest-scripts-compat.yml` — add the `manifest-scripts` label to a PR to run it. diff --git a/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh b/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh new file mode 100755 index 000000000..6ee0e1ac3 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/make-localrepo.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Generate a tiny hermetic local Maven repo (two transitive-free artifacts) under +# project/localrepo so the smoke test resolves fully offline on any Gradle version. +# Text-only in git; the .pom + (empty) .jar files are generated at test time. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$HERE/project/localrepo" +rm -rf "$REPO" + +mkpkg() { # group artifact version + local group="$1" art="$2" ver="$3" + local dir="$REPO/${group//.//}/$art/$ver" + mkdir -p "$dir" + cat > "$dir/$art-$ver.pom" < + 4.0.0 + $group + $art + $ver + jar + +POM + # empty but valid jar (jar ships with every JDK, which CI sets up) + local tmp; tmp="$(mktemp -d)" + ( cd "$tmp" && jar cf "$dir/$art-$ver.jar" . ) + rm -rf "$tmp" +} + +mkpkg demo.lib foo 1.0 +mkpkg demo.test bar 1.0 +echo "built local repo at $REPO" diff --git a/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle b/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle new file mode 100644 index 000000000..2dc897f8e --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle @@ -0,0 +1,22 @@ +// Hermetic smoke-test project for ../../socket-facts.init.gradle. +// +// Resolves two artifacts from a generated local Maven repo (see ../make-localrepo.sh) +// so it runs fully OFFLINE on every Gradle version — including pre-3.3, which the +// script must still support (old Gradle can't negotiate modern Maven Central's TLS, +// so a network repo would make the old-version matrix entries impossible to test). +apply plugin: 'java' +group = 'demo' +version = '1.0.0' + +repositories { + maven { url file('localrepo').toURI() } +} + +// `compile`/`testCompile` were removed in Gradle 7; `implementation`/`testImplementation` +// arrived in 3.4. Pick the pair that exists on the running Gradle so this one build +// script spans Gradle 1.x through 9.x. +def modern = org.gradle.util.GradleVersion.current() >= org.gradle.util.GradleVersion.version('3.4') +dependencies { + add(modern ? 'implementation' : 'compile', 'demo.lib:foo:1.0') // production dep + add(modern ? 'testImplementation' : 'testCompile', 'demo.test:bar:1.0') // test (dev) dep +} diff --git a/src/commands/manifest/scripts/test/gradle-compat/project/settings.gradle b/src/commands/manifest/scripts/test/gradle-compat/project/settings.gradle new file mode 100644 index 000000000..d4408b64f --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/project/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'gradle-compat-smoke' diff --git a/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh new file mode 100755 index 000000000..bf657b910 --- /dev/null +++ b/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Run socket-facts.init.gradle against the hermetic local-repo project on a given Gradle binary and +# assert it emits the expected RECORDS (the script's only output now — the TS assembler turns these +# into .socket.facts.json and is tested separately in `nx test utils`). Guards, across the supported +# Gradle range, that the reflective old-Gradle shims (findProperty / canBeResolved / +# firstLevelModuleDependencies) keep producing correct facts: +# - exactly the two expected dependency nodes, demo.lib:foo + demo.test:bar; +# - foo (prod) appears in a prod root, bar (test) only in non-prod roots -> the assembler's dev flag; +# - every resolved dependency gets an on-disk jar `file` record under -Psocket.withFiles (incl. the +# production dep reached via the legacy `compile` config on old Gradle); +# - -Psocket.populateFilesFor scopes materialization to a single GAV. +# +# Usage: smoke-test.sh /path/to/gradle +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +GRADLE="${1:?usage: smoke-test.sh }" +INIT="$HERE/../../socket-facts.init.gradle" +PROJECT="$HERE/project" +GUH="$HERE/.gradle-home" # isolated Gradle user home -> hermetic, no global init scripts +RECORDS="$PROJECT/records.tsv" + +bash "$HERE/make-localrepo.sh" +rm -rf "$GUH" "$RECORDS" "$PROJECT/.gradle" "$PROJECT/build" + +echo "+ $("$GRADLE" --version 2>/dev/null | sed -n 's/^Gradle //p' | head -1)" +( cd "$PROJECT" && "$GRADLE" --no-daemon --offline -g "$GUH" \ + --init-script "$INIT" -Psocket.withFiles=true -Psocket.recordsFile="$RECORDS" socketFacts -q ) + +python3 - "$RECORDS" 'full' <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +scoped = sys.argv[2] == 'scoped' +roots, nodes, files = {}, {}, {} +for r in rows: + if r[0] == 'root': roots[r[1]] = (r[4] == '1') # rootId -> prod + elif r[0] == 'node': nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} + elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} +errors = [] + +foo, bar = 'demo.lib:foo:jar:1.0', 'demo.test:bar:jar:1.0' +if sorted(nodes) != [foo, bar]: + errors.append(f"nodes {sorted(nodes)} != expected {[foo, bar]}") +# prod/dev is derived by the assembler from which roots a node appears in: foo in a prod root, +# bar only in non-prod (test) roots. +if foo in nodes and not any(roots.get(rid) for rid in nodes[foo]): + errors.append("production dep foo not present in any prod root") +if bar in nodes and any(roots.get(rid) for rid in nodes[bar]): + errors.append("test dep bar wrongly present in a prod root") + +def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) +# foo is always materialized; bar only on the unscoped run. +if not has_jar(foo): + errors.append(f"foo missing materialized jar under --with-files: {files.get(foo)}") +if scoped: + if has_jar(bar): errors.append(f"scoped run: bar (out of scope) was materialized: {files.get(bar)}") +else: + if not has_jar(bar): errors.append(f"bar missing materialized jar under --with-files: {files.get(bar)}") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS ({'scoped' if scoped else 'full'}): nodes {sorted(nodes)}; foo prod+jar; bar dev" + + ("; bar skipped" if scoped else "; bar jar")) +PY + +# Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized. +SCOPE="$HERE/.populate-for.txt" +printf 'demo.lib:foo:1.0\n' > "$SCOPE" +rm -rf "$GUH" "$RECORDS" "$PROJECT/.gradle" "$PROJECT/build" +( cd "$PROJECT" && "$GRADLE" --no-daemon --offline -g "$GUH" \ + --init-script "$INIT" -Psocket.withFiles=true -Psocket.populateFilesFor="$SCOPE" -Psocket.recordsFile="$RECORDS" socketFacts -q ) +rm -f "$SCOPE" + +python3 - "$RECORDS" 'scoped' <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +files = {} +for r in rows: + if r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) +errors = [] +if not any(p.endswith('.jar') for p in files.get('demo.lib:foo:jar:1.0', ())): + errors.append(f"scoped run: foo (in scope) not materialized: {files.get('demo.lib:foo:jar:1.0')}") +if files.get('demo.test:bar:jar:1.0'): + errors.append(f"scoped run: bar (out of scope) was materialized: {files.get('demo.test:bar:jar:1.0')}") +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS (populateFilesFor scoping): foo materialized, bar skipped") +PY diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore new file mode 100644 index 000000000..1d6ccc626 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -0,0 +1,3 @@ +project/records.tsv +project/target/ +project/*/target/ diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml new file mode 100644 index 000000000..7e6535424 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + demo + root + 1.0 + + app + + + + demo + lib + 1.0 + + + + commons-io + commons-io + 2.11.0 + + + + junit + junit + 4.13.2 + test + + + diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/App.java b/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/App.java new file mode 100644 index 000000000..16e82b6da --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/App.java @@ -0,0 +1,7 @@ +package demo; + +public final class App { + public static void main(String[] args) { + System.out.println(Lib.greet()); + } +} diff --git a/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml new file mode 100644 index 000000000..f16c76bf3 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml @@ -0,0 +1,10 @@ + + + 4.0.0 + + demo + root + 1.0 + + lib + diff --git a/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/demo/Lib.java b/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/demo/Lib.java new file mode 100644 index 000000000..294b73faf --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/demo/Lib.java @@ -0,0 +1,7 @@ +package demo; + +public final class Lib { + public static String greet() { + return "lib"; + } +} diff --git a/src/commands/manifest/scripts/test/maven-compat/project/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml new file mode 100644 index 000000000..c80503ee0 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + demo + root + 1.0 + pom + + lib + app + + + 8 + 8 + UTF-8 + + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh new file mode 100755 index 000000000..91230eb32 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Load the Coana Maven core extension on a given Maven binary and assert it emits the expected RECORDS +# for the multi-module smoke project (the TS assembler turns these into .socket.facts.json and is +# tested separately in `nx test utils`). Guards, across the supported Maven range, that the extension: +# - emits the external prod dep commons-io (in a prod root) and the test dep junit + its transitive +# hamcrest (only in a non-prod root -> the assembler's dev flag); +# - emits the internal reactor module demo:lib by its bare groupId:artifactId:version id (so the +# inter-module edge lines up with its `project` record); +# - materializes resolved external jars under -Dsocket.withFiles; +# - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file). +# +# Usage: smoke-test.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test.sh }" +JAR="${2:?usage: smoke-test.sh }" +PROJECT="$HERE/project" +RECORDS="$PROJECT/records.tsv" + +rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target + +echo "+ $("$MVN" -v 2>/dev/null | head -1)" +( cd "$PROJECT" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-facts \ + -Dsocket.withFiles=true \ + "-Dsocket.recordsFile=$RECORDS" \ + compile ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +tool = None +roots, nodes, files, direct = {}, {}, {}, {} +for r in rows: + if r[0] == 'meta': tool = r[1] + elif r[0] == 'root': roots[r[1]] = (r[4] == '1') # rootId -> prod + elif r[0] == 'node': + nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} + if r[8] == '1': direct.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId where direct} + elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} + +errors = [] +commons = 'commons-io:commons-io:jar:2.11.0' +junit = 'junit:junit:jar:4.13.2' +hamcrest = 'org.hamcrest:hamcrest-core:jar:1.3' +lib = 'demo:lib:1.0' # internal module: bare id, no ext + +if tool != 'maven': errors.append(f"meta tool {tool!r} != 'maven'") + +def in_prod(cid): return any(roots.get(rid) for rid in nodes.get(cid, ())) +def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) + +if commons not in nodes: errors.append("missing external prod dep commons-io") +elif not in_prod(commons): errors.append("commons-io not in a prod root") +if not has_jar(commons): errors.append(f"commons-io jar not materialized: {files.get(commons)}") + +if junit not in nodes: errors.append("missing test dep junit") +elif in_prod(junit): errors.append("test dep junit wrongly in a prod root") +if not has_jar(junit): errors.append(f"junit jar not materialized: {files.get(junit)}") +if hamcrest in nodes and in_prod(hamcrest): errors.append("transitive test dep hamcrest wrongly in a prod root") + +if lib not in nodes: errors.append("internal module demo:lib not emitted by its bare id") +elif not in_prod(lib): errors.append("internal module demo:lib not in app's prod root") +elif not direct.get(lib): errors.append("internal module demo:lib not marked direct") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS: tool=maven; commons-io prod+jar; junit/hamcrest dev; internal demo:lib (bare id, direct)") +PY + +# Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized. +SCOPE="$PROJECT/.populate-for.txt" +printf 'commons-io:commons-io:2.11.0\n' > "$SCOPE" +rm -rf "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target +( cd "$PROJECT" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-facts \ + -Dsocket.withFiles=true \ + "-Dsocket.populateFilesFor=$SCOPE" \ + "-Dsocket.recordsFile=$RECORDS" \ + compile ) +rm -f "$SCOPE" + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +files = {} +for r in rows: + if r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) +errors = [] +if not any(p.endswith('.jar') for p in files.get('commons-io:commons-io:jar:2.11.0', ())): + errors.append(f"scoped run: commons-io (in scope) not materialized: {files.get('commons-io:commons-io:jar:2.11.0')}") +if files.get('junit:junit:jar:4.13.2'): + errors.append(f"scoped run: junit (out of scope) was materialized: {files.get('junit:junit:jar:4.13.2')}") +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print("PASS (populateFilesFor scoping): commons-io materialized, junit skipped") +PY diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh new file mode 100755 index 000000000..e6279e75c --- /dev/null +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Run the JVM manifest-script compatibility matrix LOCALLY, on demand. +# +# GitHub Actions can't run this: SocketDev's org action allowlist forbids +# `actions/setup-java` and `sbt/setup-sbt`, so the build-tool matrix has no CI +# home. Run this whenever you change the Gradle init script +# (socket-facts.init.gradle), the sbt plugin (socket-facts.plugin.scala), or the +# Maven extension (maven-extension/) — it exercises the same version matrix the +# CI workflow used and asserts the scripts still emit the expected records. +# +# JDKs: the matrix needs several Java versions (a row's required major is shown +# per run). Point JDK8 / JDK11 / JDK17 / JDK21 at JDK homes to use the right one +# per row; otherwise the current `java` is used (fine if it can run that tool). +# sbt rows additionally need the `sbt` launcher on PATH. +# +# Usage: run-compat.sh [gradle|sbt|maven|all] (default: all) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +TOOL="${1:-all}" +CACHE="${SOCKET_COMPAT_CACHE:-${TMPDIR:-/tmp}/socket-manifest-compat}" +mkdir -p "$CACHE" + +# Same matrix as the former CI workflow. Rows: " [scala]". +GRADLE_MATRIX=("1.12 8" "2.14.1 8" "3.3 8" "8.10.2 17" "9.2.1 21") +MAVEN_MATRIX=("3.6.3 11" "3.8.8 17" "3.9.9 17") +SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20") + +# Select a JDK for the given Java major: use $JDK if set, else current java. +use_jdk() { + local home_var="JDK$1" + local home="${!home_var:-}" + if [ -n "$home" ]; then + export JAVA_HOME="$home" + echo " JDK $1: $home" + else + echo " JDK $1 not provided (set $home_var= for fidelity); using current java" + unset JAVA_HOME || true + fi +} + +run_gradle() { + for row in "${GRADLE_MATRIX[@]}"; do + # shellcheck disable=SC2086 + set -- $row + local ver="$1" java="$2" + echo "== gradle $ver (wants JDK $java) ==" + use_jdk "$java" + local dir="$CACHE/gradle-$ver" + if [ ! -x "$dir/bin/gradle" ]; then + curl -fsSL "https://services.gradle.org/distributions/gradle-$ver-bin.zip" -o "$CACHE/gradle.zip" + unzip -q -o "$CACHE/gradle.zip" -d "$CACHE" + fi + bash "$HERE/gradle-compat/smoke-test.sh" "$dir/bin/gradle" + done +} + +run_maven() { + echo "== building the Maven extension jar ==" + bash "$HERE/../maven-extension/build-jar.sh" + local jar="$HERE/../maven-extension/coana-maven-extension.jar" + for row in "${MAVEN_MATRIX[@]}"; do + # shellcheck disable=SC2086 + set -- $row + local ver="$1" java="$2" + echo "== maven $ver (wants JDK $java) ==" + use_jdk "$java" + local dir="$CACHE/apache-maven-$ver" + if [ ! -x "$dir/bin/mvn" ]; then + curl -fsSL "https://archive.apache.org/dist/maven/maven-3/$ver/binaries/apache-maven-$ver-bin.zip" -o "$CACHE/maven.zip" + unzip -q -o "$CACHE/maven.zip" -d "$CACHE" + fi + bash "$HERE/maven-compat/smoke-test.sh" "$dir/bin/mvn" "$jar" + done +} + +run_sbt() { + if ! command -v sbt >/dev/null 2>&1; then + echo "sbt launcher not found on PATH; install it (e.g. 'brew install sbt' or coursier) to run the sbt matrix" >&2 + return 1 + fi + for row in "${SBT_MATRIX[@]}"; do + # shellcheck disable=SC2086 + set -- $row + local ver="$1" java="$2" scala="$3" + echo "== sbt $ver / scala $scala (wants JDK $java) ==" + use_jdk "$java" + bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" + done +} + +case "$TOOL" in + gradle) run_gradle ;; + maven) run_maven ;; + sbt) run_sbt ;; + all) + run_gradle + run_maven + run_sbt + ;; + *) + echo "usage: run-compat.sh [gradle|sbt|maven|all]" >&2 + exit 1 + ;; +esac + +echo "compat matrix passed: $TOOL" diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore new file mode 100644 index 000000000..8b58940dc --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -0,0 +1,7 @@ +# Generated by smoke-test.sh / sbt at test time. +project/target/ +project/project/target/ +project/project/build.properties +project/scala-version.sbt +project/records.tsv +project/target diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt new file mode 100644 index 000000000..bc96acdbd --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt @@ -0,0 +1,20 @@ +// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep +// (commons-io, a plain Java artifact so it needs no Scala cross-version) and one test dep (junit), +// so the smoke test can assert the prod/dev split and --with-files materialization. Uses the +// `in ThisBuild` setting form (not the `/` slash form) so it parses on sbt 0.13 AND 1.x. +// scalaVersion is set by smoke-test.sh per matrix entry (scala-version.sbt: 2.10 for sbt 0.13, +// 2.12 for 1.x). +organization in ThisBuild := "demo" +version in ThisBuild := "0.1.0" + +lazy val root = (project in file(".")) + .settings( + name := "sbt-compat-smoke", + // Pin fast resolvers first: sbt 0.13's default chain otherwise hits slow/dead Ivy repos. + resolvers := Seq( + "Ivy Releases" at "https://scala.jfrog.io/artifactory/ivy-releases/", + "Maven Central" at "https://repo1.maven.org/maven2/" + ) ++ resolvers.value, + libraryDependencies += "commons-io" % "commons-io" % "2.11.0", + libraryDependencies += "junit" % "junit" % "4.13.2" % Test + ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/src/main/scala/App.scala b/src/commands/manifest/scripts/test/sbt-compat/project/src/main/scala/App.scala new file mode 100644 index 000000000..5c4529302 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/project/src/main/scala/App.scala @@ -0,0 +1 @@ +object App { def main(a: Array[String]): Unit = println("hi") } diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh new file mode 100755 index 000000000..ea220c549 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Run socket-facts.plugin.scala against the smoke project on a given sbt version and assert it emits +# the expected RECORDS (the plugin's only output now — the TS assembler turns these into +# .socket.facts.json and is tested separately in `nx test utils`). Guards, across the supported sbt +# range (0.13.x .. 1.x), that the reflective version shims (ResolveException / ExclusionRule / +# ConfigRef, updateFull-vs-update) keep producing correct facts: +# - the two expected dependency nodes are present (commons-io prod, junit test); +# - commons-io appears in a prod root, junit only in non-prod roots -> the assembler's dev flag; +# - both get an on-disk jar `file` record under -Dsocket.withFiles. +# +# The plugin is activated exactly as run.ts does it: dropped into a fresh sbt global base's plugins/. +# Usage: smoke-test.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test.sh }" +SCALA_VERSION="${2:?usage: smoke-test.sh }" +PLUGIN="$HERE/../../socket-facts.plugin.scala" +PROJECT="$HERE/project" +RECORDS="$PROJECT/records.tsv" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.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)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +roots, nodes, files = {}, {}, {} +for r in rows: + if r[0] == 'root': roots[r[1]] = (r[4] == '1') + elif r[0] == 'node': nodes.setdefault(r[2], set()).add(r[1]) + elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) +errors = [] + +commons = 'commons-io:commons-io:jar:2.11.0' +junit = 'junit:junit:jar:4.13.2' +for cid in (commons, junit): + if cid not in nodes: errors.append(f"expected node missing: {cid}") +if commons in nodes and not any(roots.get(rid) for rid in nodes[commons]): + errors.append("commons-io not present in any prod root") +if junit in nodes and any(roots.get(rid) for rid in nodes[junit]): + errors.append("test dep junit wrongly present in a prod root") +def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) +for cid in (commons, junit): + if not has_jar(cid): errors.append(f"{cid} missing materialized jar under --with-files: {files.get(cid)}") + +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS: commons-io prod+jar; junit dev+jar ({len(nodes)} nodes)") +PY diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index a0fd7c6ce..6f00ebb95 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -17,7 +17,6 @@ import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' import constants, { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' -import { buildAutoManifestConfig } from '../../utils/auto-manifest-config.mts' import { checkCommandInput } from '../../utils/check-input.mts' import { cmdFlagValueToArray } from '../../utils/cmd.mts' import { determineOrgSlug } from '../../utils/determine-org-slug.mts' @@ -636,15 +635,6 @@ async function run( pendingHead: Boolean(pendingHead), pullRequest: Number(pullRequest), reach: { - // Build-tool config for the reach-time resolution, mapped from socket.json - // (per-ecosystem). Best-effort on plain --reach; under --auto-manifest the - // config carries top-level failOnBuildToolError=true (fail-closed). Only - // built when reachability runs. - autoManifestConfig: reach - ? buildAutoManifestConfig(sockJson, { - autoManifest: Boolean(autoManifest), - }) - : undefined, excludePaths, reachAnalysisMemoryLimit, reachAnalysisTimeout, diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index 7533e1657..1e2fb100d 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -27,6 +27,7 @@ import { generateAutoManifest } from '../manifest/generate_auto_manifest.mts' import type { ReachabilityOptions } from './perform-reachability-analysis.mts' import type { REPORT_LEVEL } from './types.mts' import type { OutputKind } from '../../types.mts' +import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Remap } from '@socketsecurity/registry/lib/objects' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' @@ -129,6 +130,8 @@ export async function handleCreateNewScan({ workspace, }) + // Sidecar forwarded to reachability; populated only when reach runs. + let resolvedPathsSidecar: ResolvedPathsSidecar | undefined if (autoManifest) { logger.info('Auto-generating manifest files ...') debugFn('notice', 'Auto-manifest mode enabled') @@ -136,11 +139,14 @@ export async function handleCreateNewScan({ const detected = await detectManifestActions(sockJson, cwd) debugDir('inspect', { detected }) const autoManifestResult = await generateAutoManifest({ - detected, + computeArtifactsSidecar: reach.runReachabilityAnalysis, cwd, + detected, outputKind, + reachContinueOnInstallErrors: reach.reachContinueOnInstallErrors, verbose: false, }) + resolvedPathsSidecar = autoManifestResult.resolvedPathsSidecar if (autoManifestResult.generatedFiles.length) { scanTargets = Array.from( new Set([...targets, ...autoManifestResult.generatedFiles]), @@ -242,6 +248,7 @@ export async function handleCreateNewScan({ packagePaths, reachabilityOptions: mergedReachabilityOptions, repoName, + resolvedPathsSidecar, spinner, target: targets[0]!, }) diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index c9cf0cf41..37fb7d4f9 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,7 +8,6 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' -import { isAutoManifestConfigEmpty } from '../../utils/auto-manifest-config.mts' import { extractTier1ReachabilityScanId } from '../../utils/coana.mts' import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' @@ -17,13 +16,12 @@ import { socketDevLink } from '../../utils/terminal-link.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' import type { CResult, OutputKind } from '../../types.mts' -import type { AutoManifestConfig } from '../../utils/auto-manifest-config.mts' import type { PURL_Type } from '../../utils/ecosystem.mts' +import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' import type { Spinner } from '@socketsecurity/registry/lib/spinner' import type { StdioOptions } from 'node:child_process' export type ReachabilityOptions = { - autoManifestConfig?: AutoManifestConfig | undefined excludePaths: string[] reachAnalysisMemoryLimit: string reachAnalysisTimeout: string @@ -54,6 +52,9 @@ export type ReachabilityAnalysisOptions = { outputPath?: string | undefined packagePaths?: string[] | undefined reachabilityOptions: ReachabilityOptions + // Resolved-paths sidecar from the auto-manifest run; passed to coana so it + // reuses these paths instead of re-resolving the build. + resolvedPathsSidecar?: ResolvedPathsSidecar | undefined repoName?: string | undefined spinner?: Spinner | undefined target: string @@ -77,6 +78,7 @@ export async function performReachabilityAnalysis( packagePaths, reachabilityOptions, repoName, + resolvedPathsSidecar, spinner, target, uploadManifests = true, @@ -113,7 +115,8 @@ export async function performReachabilityAnalysis( if (!hasEnterpriseOrgPlan(organizations)) { return { ok: false, - message: 'Full application reachability analysis requires an enterprise plan', + message: + 'Full application reachability analysis requires an enterprise plan', cause: `Please ${socketDevLink('upgrade your plan', '/pricing')}. This feature is only available for organizations with an enterprise plan.`, } } @@ -182,19 +185,17 @@ export async function performReachabilityAnalysis( const outputFilePath = outputPath || constants.DOT_SOCKET_DOT_FACTS_JSON - // Coana reads `--auto-manifest-config` from a JSON file, so write the resolved - // per-ecosystem build-tool config (mapped from socket.json) to a temp file and - // pass its absolute path. Cleaned up in the finally below. - let autoManifestConfigPath: string | undefined - const { autoManifestConfig } = reachabilityOptions - if (autoManifestConfig && !isAutoManifestConfigEmpty(autoManifestConfig)) { - autoManifestConfigPath = path.join( + // Write the sidecar to a temp file for `--compute-artifacts-sidecar`; cleaned + // up in the finally below. + let sidecarPath: string | undefined + if (resolvedPathsSidecar?.length) { + sidecarPath = path.join( tmpdir(), - `socket-auto-manifest-config-${randomUUID()}.json`, + `socket-compute-artifacts-sidecar-${randomUUID()}.json`, ) await fs.writeFile( - autoManifestConfigPath, - JSON.stringify(autoManifestConfig), + sidecarPath, + JSON.stringify(resolvedPathsSidecar), 'utf8', ) } @@ -257,11 +258,7 @@ export async function performReachabilityAnalysis( ...(reachabilityOptions.reachUseOnlyPregeneratedSboms ? ['--use-only-pregenerated-sboms'] : []), - // Hand the per-ecosystem build-tool config (mapped from socket.json) to - // Coana's reach-time resolution, as a temp JSON file path. - ...(autoManifestConfigPath - ? ['--auto-manifest-config', autoManifestConfigPath] - : []), + ...(sidecarPath ? ['--compute-artifacts-sidecar', sidecarPath] : []), ] // Build environment variables. @@ -329,10 +326,10 @@ export async function performReachabilityAnalysis( }, } } finally { - // The run no longer needs the temp config file; best-effort cleanup. - if (autoManifestConfigPath) { + // Best-effort cleanup of the temp sidecar. + if (sidecarPath) { try { - await fs.unlink(autoManifestConfigPath) + await fs.unlink(sidecarPath) } catch { // File may already be gone or unwritable. } diff --git a/src/utils/auto-manifest-config.mts b/src/utils/auto-manifest-config.mts deleted file mode 100644 index 01c6bc040..000000000 --- a/src/utils/auto-manifest-config.mts +++ /dev/null @@ -1,110 +0,0 @@ -import type { SocketJson } from './socket-json.mts' - -// Per-ecosystem build-tool options handed off to the Coana CLI — used both when -// generating manifests (`coana manifest `) and, in socket mode, for -// reach-time dependency resolution (`coana run`). This mirrors the Coana-side -// `--auto-manifest-config` shape: socket-cli owns mapping `socket.json` onto it, -// so Coana stays uncoupled from `socket.json`'s schema. Keeping the -// per-ecosystem options namespaced (rather than as flat CLI flags) avoids the -// ambiguity of a bare `--bin`/`--include-configs` when a repo has more than one -// build tool. -export type BuildToolOptions = { - // Build-tool executable override (e.g. `./gradlew`, `atlas-mvn`). - bin?: string | undefined - // Comma-separated config-name globs to skip. - excludeConfigs?: string | undefined - // `socket.json`'s per-ecosystem `ignoreUnresolved` (warn vs fail on unresolved - // dependencies), forwarded verbatim. NOTE: this is NOT the reach-time - // fail-closed switch — that's the run-wide `failOnBuildToolError` below. - ignoreUnresolved?: boolean | undefined - // Comma-separated config-name globs to resolve. - includeConfigs?: string | undefined - // Extra build-tool options, pre-split into argv. Coana maps these straight to - // the tool's opts (no splitting on its side). Mapped from `socket.json`'s - // `gradleOpts`/`sbtOpts` string. - opts?: string[] | undefined -} - -// The Coana hand-off config. `failOnBuildToolError` is run-wide (top level) -// because `--auto-manifest` is a single CLI mode, not a per-package-manager -// setting. The per-ecosystem entries are present only for ecosystems configured -// (and not disabled) in `socket.json`; absent ecosystems fall to Coana's own -// defaults. -export type AutoManifestConfig = { - // Run-wide fail-closed switch. When true, Coana treats a build-tool step - // failure as fatal rather than tolerating it. socket-cli sets it true under - // `--auto-manifest`; left unset on plain `--reach` (permissive — Coana's - // default best-effort behaviour). - failOnBuildToolError?: boolean | undefined - gradle?: BuildToolOptions | undefined - sbt?: BuildToolOptions | undefined -} - -// Splits a `socket.json` opts string (`gradleOpts`/`sbtOpts`) into argv, matching -// how the standalone `socket manifest` path splits it. Returns undefined when -// there's nothing to pass so the field is omitted from the config. -function parseOpts(value: string | undefined): string[] | undefined { - if (!value) { - return undefined - } - const parts = value - .split(' ') - .map(s => s.trim()) - .filter(Boolean) - return parts.length ? parts : undefined -} - -// Maps `socket.json`'s `defaults.manifest.` build-tool options onto -// the Coana hand-off config. -// -// `autoManifest` reflects whether the run is `--auto-manifest` (fail-closed: -// `failOnBuildToolError=true`) vs plain `--reach` (permissive: -// `failOnBuildToolError` left unset so Coana's default applies). Per-ecosystem -// options are forwarded verbatim from `socket.json`; disabled ecosystems are -// omitted so they fall back to Coana's defaults. -export function buildAutoManifestConfig( - sockJson: SocketJson, - { autoManifest }: { autoManifest: boolean }, -): AutoManifestConfig { - const manifest = sockJson.defaults?.manifest - const config: AutoManifestConfig = {} - - // `--auto-manifest` expects every build-tool command to succeed, so a - // build-tool step failure should be fatal rather than tolerated. - if (autoManifest) { - config.failOnBuildToolError = true - } - - const gradle = manifest?.gradle - if (gradle && !gradle.disabled) { - config.gradle = { - bin: gradle.bin, - excludeConfigs: gradle.excludeConfigs, - ignoreUnresolved: gradle.ignoreUnresolved, - includeConfigs: gradle.includeConfigs, - opts: parseOpts(gradle.gradleOpts), - } - } - - const sbt = manifest?.sbt - if (sbt && !sbt.disabled) { - config.sbt = { - bin: sbt.bin, - excludeConfigs: sbt.excludeConfigs, - ignoreUnresolved: sbt.ignoreUnresolved, - includeConfigs: sbt.includeConfigs, - opts: parseOpts(sbt.sbtOpts), - } - } - - return config -} - -// True when there's nothing to hand to Coana: no per-ecosystem options and the -// run mode is left at Coana's permissive default. When true, the -// `--auto-manifest-config` option should be omitted entirely. -export function isAutoManifestConfigEmpty(config: AutoManifestConfig): boolean { - return ( - !config.gradle && !config.sbt && config.failOnBuildToolError === undefined - ) -} diff --git a/src/utils/auto-manifest-config.test.mts b/src/utils/auto-manifest-config.test.mts deleted file mode 100644 index f0ffdc600..000000000 --- a/src/utils/auto-manifest-config.test.mts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - buildAutoManifestConfig, - isAutoManifestConfigEmpty, -} from './auto-manifest-config.mts' - -import type { SocketJson } from './socket-json.mts' - -// Builds a minimal SocketJson for the mapping under test; only -// `defaults.manifest` is read, so the header/version fields are irrelevant. -function socketJson( - manifest?: NonNullable['manifest']>, -): SocketJson { - return { defaults: { manifest } } as SocketJson -} - -describe('buildAutoManifestConfig', () => { - it('returns an empty config for plain --reach with no manifest defaults', () => { - expect( - buildAutoManifestConfig(socketJson(), { autoManifest: false }), - ).toEqual({}) - }) - - it('sets top-level failOnBuildToolError=true under --auto-manifest (fail-closed)', () => { - expect( - buildAutoManifestConfig(socketJson(), { autoManifest: true }), - ).toEqual({ failOnBuildToolError: true }) - }) - - it('leaves failOnBuildToolError unset on plain --reach (Coana default permissive)', () => { - const config = buildAutoManifestConfig( - socketJson({ gradle: { bin: './gradlew' } }), - { autoManifest: false }, - ) - expect(config.failOnBuildToolError).toBeUndefined() - }) - - it('maps gradle/sbt options, *Opts -> opts, ignoreUnresolved passthrough', () => { - const config = buildAutoManifestConfig( - socketJson({ - gradle: { - bin: './gradlew', - excludeConfigs: 'testCompileClasspath', - gradleOpts: '--offline --no-daemon', - ignoreUnresolved: true, - includeConfigs: '*RuntimeClasspath', - }, - sbt: { bin: 'sbt', sbtOpts: '-batch' }, - }), - { autoManifest: true }, - ) - expect(config).toEqual({ - failOnBuildToolError: true, - gradle: { - bin: './gradlew', - excludeConfigs: 'testCompileClasspath', - ignoreUnresolved: true, - includeConfigs: '*RuntimeClasspath', - opts: ['--offline', '--no-daemon'], - }, - sbt: { bin: 'sbt', opts: ['-batch'] }, - }) - }) - - it('omits disabled ecosystems so they fall back to Coana defaults', () => { - const config = buildAutoManifestConfig( - socketJson({ - gradle: { disabled: true, includeConfigs: '*RuntimeClasspath' }, - sbt: { bin: 'sbt' }, - }), - { autoManifest: false }, - ) - expect(config.gradle).toBeUndefined() - expect(config.sbt).toBeDefined() - }) -}) - -describe('isAutoManifestConfigEmpty', () => { - it('is true when there are no ecosystems and the run mode is default', () => { - expect(isAutoManifestConfigEmpty({})).toBe(true) - }) - - it('is false when failOnBuildToolError is set (fail-closed must reach Coana)', () => { - expect(isAutoManifestConfigEmpty({ failOnBuildToolError: true })).toBe( - false, - ) - }) - - it('is false when an ecosystem is configured', () => { - expect(isAutoManifestConfigEmpty({ gradle: { bin: './gradlew' } })).toBe( - false, - ) - }) -})