From 430c9b86f7e45943f9fe6b83bfd6a4d1bcd0a00c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 7 Jul 2026 10:37:43 +0200 Subject: [PATCH] feat(manifest): socket manifest dotnet + centralized config-name globs (REA-627, REA-628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring .NET/NuGet to the Socket facts pipeline, at parity with the JVM tools. socket manifest dotnet (REA-627): - New command + auto-manifest detection (top-level .sln/.slnx/.csproj/.fsproj/ .vbproj) and setup-wizard entry; socket.json defaults.manifest.dotnet.*. - Bundled C# tool (socket-facts-dotnet): one MSBuild session — evaluate, in-process restore, read project.assets.json via NuGet.ProjectModel — emitting the shared records TSV. Ships no NuGet/MSBuild runtime assemblies (compile-low/run-high against the locator-selected SDK; net6 floor, RollForward LatestMajor), so it works across installed SDK versions. Restore forces TreatWarningsAsErrors=false so NU19xx security advisories don't abort the run. packages.config supported (flat closure + HintPath DLLs, with pinned-artifact download via the project's configured feeds). Fail-closed: emits failure records that raise the CLI exit code; every emitted artifact path is guaranteed to exist. - --target-frameworks / --exclude-target-frameworks (TFM globs; RID targets match under their base TFM); --dotnet-opts (MSBuild -p: props applied to the whole session). Reachability sidecar emits nuget-tagged ResolvedComponents. - Resolution summary reports scanned target frameworks across N projects, with per-project attribution under --verbose. Centralized config-name glob compilation (REA-628): - Compile --include-configs/--exclude-configs globs to anchored regex sources once in config-glob.mts; the gradle/sbt/maven scripts and the dotnet tool consume the compiled patterns. Removes the per-language globToRegex (Groovy/Scala/Java) and adds a cross-language vector test suite. Gated on coana REA-626 (nuget sidecar consumer): the coana version pin is bumped and this is un-drafted once coana releases. --- .config/rollup.dist.config.mjs | 14 + .github/workflows/provenance.yml | 7 + package.json | 1 + src/commands/manifest/cmd-manifest-dotnet.mts | 249 +++++ .../manifest/cmd-manifest-dotnet.test.mts | 96 ++ src/commands/manifest/cmd-manifest.mts | 6 +- src/commands/manifest/cmd-manifest.test.mts | 1 + .../manifest/convert-dotnet-to-facts.mts | 40 + .../manifest/detect-manifest-actions.mts | 31 +- .../manifest/detect-manifest-actions.test.mts | 63 +- .../manifest/generate_auto_manifest.mts | 24 + .../manifest/generate_auto_manifest.test.mts | 71 ++ src/commands/manifest/run-manifest-facts.mts | 28 + src/commands/manifest/scripts/assemble.mts | 72 +- .../manifest/scripts/assemble.test.mts | 63 ++ src/commands/manifest/scripts/build-tool.mts | 6 +- src/commands/manifest/scripts/config-glob.mts | 112 +++ .../manifest/scripts/config-glob.test.mts | 181 ++++ .../manifest/scripts/dotnet-tool/.gitignore | 3 + .../scripts/dotnet-tool/FactsRunner.cs | 924 ++++++++++++++++++ .../manifest/scripts/dotnet-tool/Program.cs | 20 + .../scripts/dotnet-tool/RecordsWriter.cs | 60 ++ .../scripts/dotnet-tool/ToolOptions.cs | 102 ++ .../scripts/dotnet-tool/build-tool.sh | 18 + .../dotnet-tool/socket-facts-dotnet.csproj | 49 + src/commands/manifest/scripts/facts.mts | 6 +- .../java/tech/coana/socket/SocketSupport.java | 52 +- .../scripts/resolution-report-nuget.mts | 90 ++ .../scripts/resolution-report-render.mts | 28 +- .../manifest/scripts/resolution-report.mts | 4 + src/commands/manifest/scripts/run.mts | 57 +- src/commands/manifest/scripts/sidecar.mts | 42 +- .../manifest/scripts/sidecar.test.mts | 108 ++ .../manifest/scripts/socket-facts.init.gradle | 48 +- .../scripts/socket-facts.plugin.scala | 45 +- .../manifest/setup-manifest-config.mts | 91 ++ src/utils/socket-json.mts | 9 + 37 files changed, 2673 insertions(+), 148 deletions(-) create mode 100644 src/commands/manifest/cmd-manifest-dotnet.mts create mode 100644 src/commands/manifest/cmd-manifest-dotnet.test.mts create mode 100644 src/commands/manifest/convert-dotnet-to-facts.mts create mode 100644 src/commands/manifest/scripts/config-glob.mts create mode 100644 src/commands/manifest/scripts/config-glob.test.mts create mode 100644 src/commands/manifest/scripts/dotnet-tool/.gitignore create mode 100644 src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/Program.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/build-tool.sh create mode 100644 src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj create mode 100644 src/commands/manifest/scripts/resolution-report-nuget.mts diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index e9341c3e7..bc840872e 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -120,6 +120,20 @@ async function copyManifestScripts() { ' for a published build. Build it first: pnpm run build:maven-extension', ) } + // Same contract for the dotnet tool: compiled by dotnet-tool/build-tool.sh, + // tolerated when absent in local dev, required for published builds. + const dotnetPublishDir = path.join(srcDir, 'dotnet-tool', 'publish') + if (existsSync(path.join(dotnetPublishDir, 'socket-facts-dotnet.dll'))) { + await fs.cp(dotnetPublishDir, path.join(destDir, 'dotnet-tool'), { + recursive: true, + }) + } else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) { + throw new Error( + 'socket-facts-dotnet tool not found at ' + + dotnetPublishDir + + ' for a published build. Build it first: pnpm run build:dotnet-tool', + ) + } } async function copyBashCompletion() { diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 358a7b2d5..71734c848 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -220,6 +220,13 @@ jobs: fi bash src/commands/manifest/scripts/maven-extension/build-jar.sh + # Same bundling contract as the Maven jar: the socket-facts-dotnet tool is + # never committed and ships only in the published package. Invoked via bash + # directly for the same Socket Firewall shim reason as above; uses the .NET + # SDK pre-installed on the runner image. + - name: Build dotnet manifest tool + run: bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh + - run: INLINED_SOCKET_CLI_PUBLISHED_BUILD=1 pnpm run build:dist - name: Publish socket id: publish_socket diff --git a/package.json b/package.json index 80a67b77b..4cb23ffd6 100644 --- a/package.json +++ b/package.json @@ -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:dotnet-tool": "bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh", "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", diff --git a/src/commands/manifest/cmd-manifest-dotnet.mts b/src/commands/manifest/cmd-manifest-dotnet.mts new file mode 100644 index 000000000..5cdc5cbb2 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dotnet.mts @@ -0,0 +1,249 @@ +import path from 'node:path' + +import { debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' +import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import constants, { SOCKET_JSON } from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' + +import type { + CliCommandConfig, + CliCommandContext, +} from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'dotnet', + description: + '[beta] Generate a Socket facts file for a .NET (C#/F#/VB) project', + hidden: false, + flags: { + ...commonFlags, + bin: { + type: 'string', + description: + 'Location of the dotnet binary to use, default: dotnet on PATH', + }, + targetFrameworks: { + type: 'string', + description: + 'Comma-separated glob patterns matched against target framework names (case-sensitive; `*`, `?`, and `[...]` wildcards). Only target frameworks matching at least one pattern are included, e.g. `net8.0` or `net*`. Default: every restored target framework', + }, + excludeTargetFrameworks: { + type: 'string', + description: + 'Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --target-frameworks)', + }, + ignoreUnresolved: { + type: 'boolean', + description: + 'Warn on restore/resolution failures instead of failing the run (unresolved deps are not emitted to the facts file)', + }, + dotnetOpts: { + type: 'string', + description: + 'MSBuild property tokens (`-p:Key=Value`) applied to the whole facts session: project evaluation, restore, and reading the restore output all see the same properties', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.] + + Options + ${getFlagListOutput(config.flags)} + + Emits a single \`.socket.facts.json\` describing the resolved dependency + graph of the .NET solutions/projects at the top level of the given + directory. A bundled tool runs one MSBuild session — evaluate, restore, + read the restore output via NuGet's own APIs — so results reflect exactly + what NuGet resolved. A restore failure is a fatal error; pass + --ignore-unresolved to warn instead. + + Each target framework a project restores is resolved separately: pass + --target-frameworks / --exclude-target-frameworks (comma-separated glob + patterns) to control which target frameworks are included (e.g. + --target-frameworks='net8.0'). RID-specific targets like net8.0/win-x64 + match under their base target framework. + + --dotnet-opts takes MSBuild property tokens (\`-p:Key=Value\`), applied to + the WHOLE session so evaluation, restore, and the emitted graph can never + disagree. Restore-specific settings have property forms, e.g. + \`-p:RestoreSources=\` or \`-p:RestoreConfigFile=\`. + + Requires a .NET SDK (6.0 or newer). Legacy \`packages.config\` projects + are supported from the manifest itself (it pins the full closure): the + graph is flat — every package is listed as a direct dependency — and + \`developmentDependency="true"\` packages are marked dev. No restore is + attempted for them. + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ ${command} . + $ ${command} --target-frameworks='net8.0' . + $ ${command} --dotnet-opts='-p:Configuration=Release' . + `, +} + +export const cmdManifestDotnet = { + description: config.description, + hidden: config.hidden, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + { parentName }: CliCommandContext, +): Promise { + const cli = meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + + const { json = false, markdown = false } = cli.flags + + const dryRun = !!cli.flags['dryRun'] + + const outputKind = getOutputKind(json, markdown) + + let [cwd = '.'] = cli.input + // Note: path.resolve vs .join: + // If given path is absolute then cwd should not affect it. + cwd = path.resolve(process.cwd(), cwd) + + const sockJson = readOrDefaultSocketJson(cwd) + + debugFn( + 'inspect', + `override: ${SOCKET_JSON} dotnet`, + sockJson?.defaults?.manifest?.dotnet, + ) + + let { + bin, + dotnetOpts, + excludeTargetFrameworks, + ignoreUnresolved, + targetFrameworks, + verbose, + } = cli.flags + + // Set defaults for any flag/arg that is not given. Check socket.json first. + if (!bin) { + if (sockJson.defaults?.manifest?.dotnet?.bin) { + bin = sockJson.defaults?.manifest?.dotnet?.bin + logger.info(`Using default --bin from ${SOCKET_JSON}:`, bin) + } else { + bin = 'dotnet' + } + } + if (!dotnetOpts) { + if (sockJson.defaults?.manifest?.dotnet?.dotnetOpts) { + dotnetOpts = sockJson.defaults?.manifest?.dotnet?.dotnetOpts + logger.info( + `Using default --dotnet-opts from ${SOCKET_JSON}:`, + dotnetOpts, + ) + } else { + dotnetOpts = '' + } + } + if (verbose === undefined) { + if (sockJson.defaults?.manifest?.dotnet?.verbose !== undefined) { + verbose = sockJson.defaults?.manifest?.dotnet?.verbose + logger.info(`Using default --verbose from ${SOCKET_JSON}:`, verbose) + } else { + verbose = false + } + } + if (targetFrameworks === undefined) { + if (sockJson.defaults?.manifest?.dotnet?.targetFrameworks !== undefined) { + targetFrameworks = sockJson.defaults?.manifest?.dotnet?.targetFrameworks + logger.info( + `Using default --target-frameworks from ${SOCKET_JSON}:`, + targetFrameworks, + ) + } else { + targetFrameworks = '' + } + } + if (excludeTargetFrameworks === undefined) { + if ( + sockJson.defaults?.manifest?.dotnet?.excludeTargetFrameworks !== undefined + ) { + excludeTargetFrameworks = + sockJson.defaults?.manifest?.dotnet?.excludeTargetFrameworks + logger.info( + `Using default --exclude-target-frameworks from ${SOCKET_JSON}:`, + excludeTargetFrameworks, + ) + } else { + excludeTargetFrameworks = '' + } + } + if (ignoreUnresolved === undefined) { + if (sockJson.defaults?.manifest?.dotnet?.ignoreUnresolved !== undefined) { + ignoreUnresolved = sockJson.defaults?.manifest?.dotnet?.ignoreUnresolved + logger.info( + `Using default --ignore-unresolved from ${SOCKET_JSON}:`, + ignoreUnresolved, + ) + } else { + ignoreUnresolved = false + } + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.groupEnd() + } + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR (make sure to escape spaces!)', + fail: 'received ' + cli.input.length, + }) + if (!wasValidInput) { + return + } + + if (verbose) { + logger.group() + logger.info('- cwd:', cwd) + logger.info('- dotnet bin:', bin) + logger.groupEnd() + } + + if (dryRun) { + logger.log(constants.DRY_RUN_BAILING_NOW) + return + } + + await convertDotnetToFacts({ + bin: String(bin), + cwd, + dotnetOpts: parseBuildToolOpts(String(dotnetOpts || '')), + excludeConfigs: String(excludeTargetFrameworks || ''), + ignoreUnresolved: Boolean(ignoreUnresolved), + includeConfigs: String(targetFrameworks || ''), + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest-dotnet.test.mts b/src/commands/manifest/cmd-manifest-dotnet.test.mts new file mode 100644 index 000000000..d91b8b6db --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dotnet.test.mts @@ -0,0 +1,96 @@ +import { describe, expect } from 'vitest' + +import constants, { + FLAG_CONFIG, + FLAG_DRY_RUN, + FLAG_HELP, +} from '../../../src/constants.mts' +import { cmdit, spawnSocketCli } from '../../../test/utils.mts' + +describe('socket manifest dotnet', async () => { + const { binCliPath } = constants + + cmdit( + ['manifest', 'dotnet', FLAG_HELP, FLAG_CONFIG, '{}'], + `should support ${FLAG_HELP}`, + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(` + "[beta] Generate a Socket facts file for a .NET (C#/F#/VB) project + + Usage + $ socket manifest dotnet [options] [CWD=.] + + Options + --bin Location of the dotnet binary to use, default: dotnet on PATH + --dotnet-opts MSBuild property tokens (\`-p:Key=Value\`) applied to the whole facts session: project evaluation, restore, and reading the restore output all see the same properties + --exclude-target-frameworks Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --target-frameworks) + --ignore-unresolved Warn on restore/resolution failures instead of failing the run (unresolved deps are not emitted to the facts file) + --target-frameworks Comma-separated glob patterns matched against target framework names (case-sensitive; \`*\`, \`?\`, and \`[...]\` wildcards). Only target frameworks matching at least one pattern are included, e.g. \`net8.0\` or \`net*\`. Default: every restored target framework + --verbose Print debug messages + + Emits a single \`.socket.facts.json\` describing the resolved dependency + graph of the .NET solutions/projects at the top level of the given + directory. A bundled tool runs one MSBuild session \\u2014 evaluate, restore, + read the restore output via NuGet's own APIs \\u2014 so results reflect exactly + what NuGet resolved. A restore failure is a fatal error; pass + --ignore-unresolved to warn instead. + + Each target framework a project restores is resolved separately: pass + --target-frameworks / --exclude-target-frameworks (comma-separated glob + patterns) to control which target frameworks are included (e.g. + --target-frameworks='net8.0'). RID-specific targets like net8.0/win-x64 + match under their base target framework. + + --dotnet-opts takes MSBuild property tokens (\`-p:Key=Value\`), applied to + the WHOLE session so evaluation, restore, and the emitted graph can never + disagree. Restore-specific settings have property forms, e.g. + \`-p:RestoreSources=\` or \`-p:RestoreConfigFile=\`. + + Requires a .NET SDK (6.0 or newer). Legacy \`packages.config\` projects + are supported from the manifest itself (it pins the full closure): the + graph is flat \\u2014 every package is listed as a direct dependency \\u2014 and + \`developmentDependency="true"\` packages are marked dev. No restore is + attempted for them. + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ socket manifest dotnet . + $ socket manifest dotnet --target-frameworks='net8.0' . + $ socket manifest dotnet --dotnet-opts='-p:Configuration=Release' ." + `) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: " + `) + + expect(code, 'explicit help should exit with code 0').toBe(0) + expect(stderr, 'banner includes base command').toContain( + '`socket manifest dotnet`', + ) + }, + ) + + cmdit( + ['manifest', 'dotnet', FLAG_DRY_RUN, FLAG_CONFIG, '{}'], + 'should bail on dry-run before running the tool', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(`\n ${stderr}`).toMatchInlineSnapshot(` + " + _____ _ _ /--------------- + | __|___ ___| |_ ___| |_ | CLI: + |__ | * | _| '_| -_| _| | token: , org: + |_____|___|___|_,_|___|_|.dev | Command: \`socket manifest dotnet\`, cwd: " + `) + + expect(code, 'dry-run should exit with code 0 if input ok').toBe(0) + }, + ) +}) diff --git a/src/commands/manifest/cmd-manifest.mts b/src/commands/manifest/cmd-manifest.mts index 518cdfddd..dcc0ae986 100644 --- a/src/commands/manifest/cmd-manifest.mts +++ b/src/commands/manifest/cmd-manifest.mts @@ -2,6 +2,7 @@ import { cmdManifestBazel } from './bazel/cmd-manifest-bazel.mts' import { cmdManifestAuto } from './cmd-manifest-auto.mts' import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts' import { cmdManifestConda } from './cmd-manifest-conda.mts' +import { cmdManifestDotnet } from './cmd-manifest-dotnet.mts' import { cmdManifestGradle } from './cmd-manifest-gradle.mts' import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' import { cmdManifestMaven } from './cmd-manifest-maven.mts' @@ -39,8 +40,8 @@ const config: CliCommandConfig = { configurations available. See \`manifest --help\` for usage details per language. - Currently supported language: bazel [beta], gradle [beta], kotlin (through - gradle) [beta], maven [beta], scala [beta]. + Currently supported language: bazel [beta], dotnet [beta], gradle [beta], + kotlin (through gradle) [beta], maven [beta], scala [beta]. Examples @@ -73,6 +74,7 @@ async function run( bazel: cmdManifestBazel, cdxgen: cmdManifestCdxgen, conda: cmdManifestConda, + dotnet: cmdManifestDotnet, gradle: cmdManifestGradle, kotlin: cmdManifestKotlin, maven: cmdManifestMaven, diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts index 93c264770..f8bba0b31 100644 --- a/src/commands/manifest/cmd-manifest.test.mts +++ b/src/commands/manifest/cmd-manifest.test.mts @@ -27,6 +27,7 @@ describe('socket manifest', async () => { bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI) cdxgen Run cdxgen for SBOM generation conda [beta] Convert a Conda environment.yml file to a python requirements.txt + dotnet [beta] Generate a Socket facts file for a .NET (C#/F#/VB) project gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project maven [beta] Generate a Socket facts file from a Maven \`pom.xml\` project diff --git a/src/commands/manifest/convert-dotnet-to-facts.mts b/src/commands/manifest/convert-dotnet-to-facts.mts new file mode 100644 index 000000000..e769cc90b --- /dev/null +++ b/src/commands/manifest/convert-dotnet-to-facts.mts @@ -0,0 +1,40 @@ +import { runManifestFacts } from './run-manifest-facts.mts' + +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// Generates `.socket.facts.json` for a .NET project by running `dotnet restore` +// and parsing the resulting `project.assets.json` files. +export async function convertDotnetToFacts({ + bin, + cwd, + dotnetOpts, + excludeConfigs, + ignoreUnresolved, + includeConfigs, + sidecarAcc, + verbose, + withFiles, +}: { + bin: string + cwd: string + dotnetOpts: string[] + excludeConfigs: string + ignoreUnresolved: boolean + includeConfigs: string + sidecarAcc?: SidecarAccumulator | undefined + verbose: boolean + withFiles?: boolean | undefined +}): Promise { + await runManifestFacts({ + bin, + buildOpts: dotnetOpts, + cwd, + ecosystem: 'dotnet', + excludeConfigs, + ignoreUnresolved, + includeConfigs, + sidecarAcc, + verbose, + withFiles, + }) +} diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts index e5f65e77f..360444a55 100644 --- a/src/commands/manifest/detect-manifest-actions.mts +++ b/src/commands/manifest/detect-manifest-actions.mts @@ -1,7 +1,7 @@ // The point here is to attempt to detect the various supported manifest files // the CLI can generate. This would be environments that we can't do server side -import { existsSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import path from 'node:path' import { debugLog } from '@socketsecurity/registry/lib/debug' @@ -19,11 +19,28 @@ export interface GeneratableManifests { cdxgen: boolean count: number conda: boolean + dotnet: boolean gradle: boolean maven: boolean sbt: boolean } +// A solution or project file at the top level marks a .NET root (matching the +// root-level convention of the other detectors). +const DOTNET_ROOT_FILE_RE = /\.(?:sln|slnx|csproj|fsproj|vbproj)$/i + +function hasDotnetRootFile(cwd: string): boolean { + try { + // Files only: a directory named e.g. `templates.csproj` must not trigger + // the dotnet pipeline (which aborts scans when the SDK is absent). + return readdirSync(cwd, { withFileTypes: true }).some( + entry => entry.isFile() && DOTNET_ROOT_FILE_RE.test(entry.name), + ) + } catch { + return false + } +} + export async function detectManifestActions( // Passing in null means we attempt detection for every supported language // regardless of local socket.json status. Sometimes we want that. @@ -35,11 +52,23 @@ export async function detectManifestActions( cdxgen: false, // TODO count: 0, conda: false, + dotnet: false, gradle: false, maven: false, sbt: false, } + if (sockJson?.defaults?.manifest?.dotnet?.disabled) { + debugLog( + 'notice', + `[DEBUG] - dotnet auto-detection is disabled in ${SOCKET_JSON}`, + ) + } else if (hasDotnetRootFile(cwd)) { + debugLog('notice', '[DEBUG] - Detected a .NET solution or project file') + output.dotnet = true + output.count += 1 + } + if (sockJson?.defaults?.manifest?.bazel?.disabled) { debugLog( 'notice', diff --git a/src/commands/manifest/detect-manifest-actions.test.mts b/src/commands/manifest/detect-manifest-actions.test.mts index 2ae961553..58cbe960e 100644 --- a/src/commands/manifest/detect-manifest-actions.test.mts +++ b/src/commands/manifest/detect-manifest-actions.test.mts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -138,3 +138,64 @@ describe('detectManifestActions — gradle detector', () => { expect(result.count).toBe(0) }) }) + +describe('detectManifestActions — dotnet detector', () => { + let cwd: string + + beforeEach(() => { + cwd = mkTmp() + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it.each(['app.sln', 'app.slnx', 'app.csproj', 'app.fsproj', 'app.vbproj'])( + 'detects dotnet from a top-level %s', + async name => { + touch(cwd, name) + const result = await detectManifestActions(null, cwd) + expect(result.dotnet).toBe(true) + expect(result.count).toBe(1) + }, + ) + + it('does not detect dotnet from unrelated files', async () => { + touch(cwd, 'project.pbxproj') + touch(cwd, 'package.json') + const result = await detectManifestActions(null, cwd) + expect(result.dotnet).toBe(false) + }) + + it('skips dotnet when defaults.manifest.dotnet.disabled is true', async () => { + touch(cwd, 'app.sln') + const result = await detectManifestActions( + { + defaults: { manifest: { dotnet: { disabled: true } } }, + } as SocketJson, + cwd, + ) + expect(result.dotnet).toBe(false) + expect(result.count).toBe(0) + }) +}) + +describe('detectManifestActions — dotnet detector ignores directories', () => { + let cwd: string + + beforeEach(() => { + cwd = mkTmp() + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('does not detect dotnet from a directory named like a project file', async () => { + mkdirSync(path.join(cwd, 'templates.csproj')) + mkdirSync(path.join(cwd, 'legacy.sln')) + const result = await detectManifestActions(null, cwd) + expect(result.dotnet).toBe(false) + expect(result.count).toBe(0) + }) +}) diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index 3e2aa40e4..3325f259c 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -3,6 +3,7 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { extractBazelToMaven } from './bazel/extract_bazel_to_maven.mts' +import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertMavenToFacts } from './convert-maven-to-facts.mts' import { convertSbtToFacts } from './convert-sbt-to-facts.mts' @@ -152,6 +153,29 @@ export async function generateAutoManifest({ } } + if (!sockJson?.defaults?.manifest?.dotnet?.disabled && detected.dotnet) { + logger.log('Detected a .NET solution/project, generating Socket facts...') + const beforeExitCode = process.exitCode + await convertDotnetToFacts({ + bin: sockJson.defaults?.manifest?.dotnet?.bin ?? 'dotnet', + cwd, + dotnetOpts: parseBuildToolOpts( + sockJson.defaults?.manifest?.dotnet?.dotnetOpts, + ), + excludeConfigs: + sockJson.defaults?.manifest?.dotnet?.excludeTargetFrameworks ?? '', + ignoreUnresolved: Boolean( + sockJson.defaults?.manifest?.dotnet?.ignoreUnresolved, + ), + includeConfigs: + sockJson.defaults?.manifest?.dotnet?.targetFrameworks ?? '', + sidecarAcc, + verbose: Boolean(sockJson.defaults?.manifest?.dotnet?.verbose), + withFiles: computeArtifactsSidecar, + }) + abortManifestRunIfFailed('dotnet', beforeExitCode) + } + if (!sockJson?.defaults?.manifest?.maven?.disabled && detected.maven) { logger.log('Detected a Maven pom.xml build, generating Socket facts...') const beforeExitCode = process.exitCode diff --git a/src/commands/manifest/generate_auto_manifest.test.mts b/src/commands/manifest/generate_auto_manifest.test.mts index fe2e59235..415587597 100644 --- a/src/commands/manifest/generate_auto_manifest.test.mts +++ b/src/commands/manifest/generate_auto_manifest.test.mts @@ -16,6 +16,9 @@ vi.mock('./convert_gradle_to_maven.mts', () => ({ vi.mock('./convert_sbt_to_maven.mts', () => ({ convertSbtToMaven: vi.fn(async () => undefined), })) +vi.mock('./convert-dotnet-to-facts.mts', () => ({ + convertDotnetToFacts: vi.fn(async () => undefined), +})) vi.mock('./convert-gradle-to-facts.mts', () => ({ convertGradleToFacts: vi.fn(async () => undefined), })) @@ -32,6 +35,7 @@ vi.mock('../../utils/socket-json.mts', () => ({ import { logger } from '@socketsecurity/registry/lib/logger' import { extractBazelToMaven } from './bazel/extract_bazel_to_maven.mts' +import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' @@ -44,7 +48,9 @@ const baseDetected = { cdxgen: false, conda: false, count: 0, + dotnet: false, gradle: false, + maven: false, sbt: false, } @@ -302,3 +308,68 @@ describe('generateAutoManifest — bazel branch', () => { ) }) }) + +describe('generateAutoManifest — dotnet branch', () => { + beforeEach(() => { + vi.mocked(convertDotnetToFacts).mockClear() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) + }) + + it('generates dotnet facts when detected and not disabled', async () => { + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { ...baseDetected, count: 1, dotnet: true }, + outputKind: 'text', + verbose: false, + }) + expect(convertDotnetToFacts).toHaveBeenCalledWith( + expect.objectContaining({ + bin: 'dotnet', + cwd: '/tmp/repo', + ignoreUnresolved: false, + }), + ) + }) + + it('skips dotnet when defaults.manifest.dotnet.disabled is true', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { manifest: { dotnet: { disabled: true } } }, + } as SocketJson) + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { ...baseDetected, count: 1, dotnet: true }, + outputKind: 'text', + verbose: false, + }) + expect(convertDotnetToFacts).not.toHaveBeenCalled() + }) + + it('honors socket.json dotnet defaults (bin, target frameworks, ignoreUnresolved)', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { + manifest: { + dotnet: { + bin: '/opt/dotnet/dotnet', + excludeTargetFrameworks: 'netstandard*', + ignoreUnresolved: true, + targetFrameworks: 'net8.0', + }, + }, + }, + } as SocketJson) + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { ...baseDetected, count: 1, dotnet: true }, + outputKind: 'text', + verbose: false, + }) + expect(convertDotnetToFacts).toHaveBeenCalledWith( + expect.objectContaining({ + bin: '/opt/dotnet/dotnet', + excludeConfigs: 'netstandard*', + ignoreUnresolved: true, + includeConfigs: 'net8.0', + }), + ) + }) +}) diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index ca8815daf..099987fbc 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -175,6 +175,34 @@ export async function runManifestFacts({ return } + // On failure the report already lists succeeded/failed configs; on success + // say what was scanned so users know what --exclude-configs (or, for dotnet, + // --exclude-target-frameworks) could trim. + if (!rendered.hasBlockingFailures && report.scannedConfigs.length) { + const noun = + ecosystem === 'dotnet' ? 'target framework(s)' : 'configuration(s)' + const limit = 20 + const shown = report.scannedConfigs.slice(0, limit).join(', ') + const more = + report.scannedConfigs.length > limit + ? ` (+${report.scannedConfigs.length - limit} more)` + : '' + // The list is a union across projects, so name the project count to avoid + // reading as one project using every config; --verbose attributes each. + const projectCount = report.configsByProject.length + const across = projectCount > 1 ? ` across ${projectCount} project(s)` : '' + logger.info( + `Resolved ${report.scannedConfigs.length} ${noun}${across}: ${shown}${more}`, + ) + if (verbose && projectCount > 1) { + logger.group() + for (const { configs, project } of report.configsByProject) { + logger.info(`- ${project}: ${configs.join(', ')}`) + } + logger.groupEnd() + } + } + await fs.writeFile(factsPath, JSON.stringify(facts, null, 2), 'utf8') if (withFiles && sidecarAcc) { diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 1d4749405..67209faf5 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -12,8 +12,27 @@ import { import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' +import type { PURL_Type } from '../../../utils/ecosystem.mts' -const PURL_TYPE_MAVEN = 'maven' +const PURL_TYPE_MAVEN: PURL_Type = 'maven' +const PURL_TYPE_NUGET: PURL_Type = 'nuget' + +function purlTypeForTool(tool: SocketFactsSbomMetadata['tool']): PURL_Type { + return tool === 'dotnet' ? PURL_TYPE_NUGET : PURL_TYPE_MAVEN +} + +// Maven-type entries always carry the namespace key (even empty — the +// pre-dotnet output shape, which downstream identity matching may rely on); +// groupless ecosystems (nuget) omit it. +function namespaceEntry( + purlType: PURL_Type, + group: string, +): { namespace?: string } { + if (purlType === PURL_TYPE_NUGET && !group) { + return {} + } + return { namespace: group } +} export type AssembleResult = { facts: SocketFactsSbom @@ -54,11 +73,12 @@ export function assembleFacts( const { directByRoot, finalNodes } = mergePathSensitive(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] - const components = buildComponents(finalNodes) + const purlType = purlTypeForTool(tool) + const components = buildComponents(finalNodes, purlType) const projects = opts.emitProjects === false ? [] - : buildProjects(parsed, finalNodes, directByRoot, perRoot) + : buildProjects(parsed, finalNodes, directByRoot, perRoot, purlType) const metadata: SocketFactsSbomMetadata = { format: 'socket-facts-sbom', @@ -241,6 +261,7 @@ function mergePathSensitive(perRoot: Map): { function buildComponents( finalNodes: Map, + purlType: PURL_Type, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -255,8 +276,8 @@ function buildComponents( qualifiers['ext'] = c.ext } const comp: SocketFactsSbomComponent = { - type: PURL_TYPE_MAVEN, - namespace: c.group, + type: purlType, + ...namespaceEntry(purlType, c.group), name: c.name, ...(c.version ? { version: c.version } : {}), ...(Object.keys(qualifiers).length ? { qualifiers } : {}), @@ -280,6 +301,7 @@ function buildProjects( finalNodes: Map, directByRoot: Map>, perRoot: Map, + purlType: PURL_Type, ): SocketFactsSbomProject[] { const idsByGav = new Map>() for (const [id, fn] of finalNodes) { @@ -306,8 +328,8 @@ function buildProjects( const projects = [...parsed.projects.values()].map(p => { const entry: SocketFactsSbomProject = { - type: PURL_TYPE_MAVEN, - namespace: p.group, + type: purlType, + ...namespaceEntry(purlType, p.group), name: p.name, ...(p.version ? { version: p.version } : {}), subprojectDir: p.dir, @@ -319,8 +341,8 @@ function buildProjects( return entry }) projects.sort((a, b) => { - const ka = `${a.subprojectDir} ${a.namespace}:${a.name}` - const kb = `${b.subprojectDir} ${b.namespace}:${b.name}` + 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 @@ -452,5 +474,35 @@ function buildReport(parsed: ParsedRecords): ResolutionReport { seenUnscannable.add(key) return true }) - return { failures, scannedConfigs: parsed.scannedConfigs, unscannable } + // Roots carry the (project, config) pairs; label projects by their relative + // dir (unique and human-readable), falling back to name, then key. + const configsByProjectKey = new Map>() + for (const root of parsed.roots.values()) { + if (!root.config) { + continue + } + let set = configsByProjectKey.get(root.projectKey) + if (!set) { + set = new Set() + configsByProjectKey.set(root.projectKey, set) + } + set.add(root.config) + } + const configsByProject = [...configsByProjectKey] + .map(({ 0: projectKey, 1: configs }) => { + const p = parsed.projects.get(projectKey) + return { + project: p?.dir || p?.name || projectKey, + configs: [...configs].sort(), + } + }) + .sort((a, b) => + a.project < b.project ? -1 : a.project > b.project ? 1 : 0, + ) + return { + failures, + scannedConfigs: parsed.scannedConfigs, + configsByProject, + unscannable, + } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index be2e1b0eb..212710b6a 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -60,3 +60,66 @@ describe('records → assemble → sidecar', () => { expect(byName.get('bom')).toMatchObject({ targets: [], sources: [] }) }) }) + +// Records as emitted by the socket-facts-dotnet tool: groupless nuget +// coordinates, one prod + one dev root per (project, target framework). +const DOTNET_RECORDS = [ + 'meta\tdotnet\t8.0.414\t', + 'project\t/abs/MyApp/MyApp.csproj\t\tMyApp\t1.0.0\tMyApp', + 'projectSrc\t/abs/MyApp/MyApp.csproj\t/abs/MyApp', + 'root\t/abs/MyApp/MyApp.csproj|net8.0|prod\t/abs/MyApp/MyApp.csproj\tnet8.0\t1', + 'node\t/abs/MyApp/MyApp.csproj|net8.0|prod\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + 'file\t/abs/MyApp/MyApp.csproj|net8.0|prod\tNewtonsoft.Json:13.0.3\t/abs/nuget/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll', + 'root\t/abs/MyApp/MyApp.csproj|net8.0|dev\t/abs/MyApp/MyApp.csproj\tnet8.0\t0', + 'node\t/abs/MyApp/MyApp.csproj|net8.0|dev\tNuGet.Versioning:6.12.1\t\tNuGet.Versioning\t6.12.1\t\t\t1', + 'scanned\tnet8.0', +].join('\n') + +describe('dotnet records → assemble', () => { + it('emits groupless nuget components with prod/dev split flags', () => { + const { artifactPaths, facts } = assembleFacts( + parseRecords(DOTNET_RECORDS), + { + fileExists: () => true, + }, + ) + + expect(facts.metadata).toEqual({ + format: 'socket-facts-sbom', + tool: 'dotnet', + toolVersion: '8.0.414', + }) + + const newtonsoft = facts.components.find(c => c.name === 'Newtonsoft.Json') + expect(newtonsoft).toMatchObject({ + type: 'nuget', + id: 'Newtonsoft.Json:13.0.3', + version: '13.0.3', + direct: true, + }) + // Groupless: no namespace key at all, and no empty qualifiers. + expect(newtonsoft).not.toHaveProperty('namespace') + expect(newtonsoft).not.toHaveProperty('qualifiers') + expect(newtonsoft?.dev).toBeUndefined() + + const versioning = facts.components.find(c => c.name === 'NuGet.Versioning') + expect(versioning).toMatchObject({ type: 'nuget', dev: true, direct: true }) + + const project = facts.projects?.find(p => p.name === 'MyApp') + expect(project).toMatchObject({ + type: 'nuget', + subprojectDir: 'MyApp', + dependencies: [ + 'NuGet.Versioning:6.12.1', + 'Newtonsoft.Json:13.0.3', + ].sort(), + }) + + expect(artifactPaths.targetsByCoord.get('Newtonsoft.Json:13.0.3')).toEqual([ + '/abs/nuget/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll', + ]) + expect(artifactPaths.sourcesByCoord.get('MyApp:1.0.0')).toEqual([ + '/abs/MyApp', + ]) + }) +}) diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 4d2608450..2c1acf3dc 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -1,18 +1,20 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' -export type BuildTool = 'gradle' | 'maven' | 'sbt' +export type BuildTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' // PATH fallback when no `bin` and no project wrapper. const DEFAULT_BUILD_TOOL_BIN: Record = { __proto__: null, + dotnet: 'dotnet', 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). +// version. sbt and dotnet have no wrapper convention (dotnet pins the SDK via +// global.json instead). POSIX names only (no win32 target). const BUILD_TOOL_WRAPPER = { __proto__: null, gradle: 'gradlew', diff --git a/src/commands/manifest/scripts/config-glob.mts b/src/commands/manifest/scripts/config-glob.mts new file mode 100644 index 000000000..29914bd31 --- /dev/null +++ b/src/commands/manifest/scripts/config-glob.mts @@ -0,0 +1,112 @@ +// Single source of truth for --include-configs / --exclude-configs glob +// semantics: case-sensitive; `*`, `?`, and `[...]` character classes with +// `[!..]`/`[^..]` negation; a malformed glob falls back to a literal match, +// never throws. Globs are compiled to regex pattern source strings HERE and +// handed to every producer (the gradle/sbt/maven scripts and the dotnet tool) +// pre-compiled, so there is exactly one implementation and one test suite. +// +// Portability contract: the emitted subset (`.*`, `.`, `[...]`, `[^...]`, and +// backslash-escaped metacharacters) behaves identically in JS RegExp, Java +// java.util.regex (via `.matcher(name).matches()`), and .NET Regex (via +// `IsMatch`). Patterns are anchored with `^(?:...)$` so unanchored matchers +// still test the full name. Class bodies escape `&` because Java classes +// support `&&` intersection (JS/.NET treat it literally). Patterns transport +// comma-joined: an input glob can never contain a comma because the +// comma-split happens before glob parsing. + +function literalSource(glob: string): string { + return `^(?:${glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})$` +} + +export function globToRegexSource(glob: string): string { + let sb = '' + let i = 0 + const n = glob.length + while (i < n) { + const ch = glob.charAt(i) + if (ch === '*') { + sb += '.*' + i += 1 + } else if (ch === '?') { + sb += '.' + i += 1 + } else if (ch === '[') { + const j = glob.indexOf(']', i + 1) + // Treat as a class only with a non-empty body; else a literal `[`. + if (j <= i + 1) { + sb += '\\[' + i += 1 + } else { + let body = glob.slice(i + 1, j) + const neg = body.startsWith('!') || body.startsWith('^') + if (neg) { + body = body.slice(1) + } + if (!body) { + // `[!]`/`[^]` would emit `[^]`, which JS accepts but Java/.NET + // reject; the JS validity gate below can't catch that, so replicate + // the old per-language fallback: the WHOLE glob matches literally. + return literalSource(glob) + } + // Only literal chars and `-` ranges are meaningful; neutralize + // regex-class tricks (`&` guards Java's `&&` class intersection). + body = body + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/&/g, '\\&') + sb += `[${neg ? '^' : ''}${body}]` + i = j + 1 + } + } else if ('.\\^$|+(){}]'.includes(ch)) { + sb += `\\${ch}` + i += 1 + } else { + sb += ch + i += 1 + } + } + const source = `^(?:${sb})$` + try { + // eslint-disable-next-line no-new + new RegExp(source) + return source + } catch { + return literalSource(glob) + } +} + +// Comma-separated globs -> anchored regex pattern sources. +export function compileConfigPatterns(csv: string | undefined): string[] { + return (csv ?? '') + .split(',') + .map(p => p.trim()) + .filter(Boolean) + .map(globToRegexSource) +} + +// Transport form handed to the build-tool scripts: comma-joined pattern +// sources (safe: globs, and therefore emitted patterns, cannot contain a +// comma). Empty string when there are no patterns. +export function serializeConfigPatterns(csv: string | undefined): string { + return compileConfigPatterns(csv).join(',') +} + +export type ConfigGlobFilter = (name: string) => boolean + +// A config is scanned when it matches some include (or there are none) AND +// matches no exclude — the contract documented on --include-configs / +// --exclude-configs. +export function createConfigGlobFilter( + includeConfigs: string | undefined, + excludeConfigs: string | undefined, +): ConfigGlobFilter { + const includes = compileConfigPatterns(includeConfigs).map(s => new RegExp(s)) + const excludes = compileConfigPatterns(excludeConfigs).map(s => new RegExp(s)) + return name => { + if (excludes.some(p => p.test(name))) { + return false + } + return !includes.length || includes.some(p => p.test(name)) + } +} diff --git a/src/commands/manifest/scripts/config-glob.test.mts b/src/commands/manifest/scripts/config-glob.test.mts new file mode 100644 index 000000000..26da465fc --- /dev/null +++ b/src/commands/manifest/scripts/config-glob.test.mts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' + +import { + createConfigGlobFilter, + globToRegexSource, + serializeConfigPatterns, +} from './config-glob.mts' + +// Vector table for the cross-language config-glob contract. The globs are +// compiled to regex pattern sources here (the ONLY implementation) and handed +// pre-compiled to the gradle init script, the sbt plugin, the maven extension, +// and the dotnet tool — so these vectors define the semantics every producer +// sees. The emitted subset must behave identically in JS RegExp, Java +// java.util.regex, and .NET Regex. +const MATCH_VECTORS: Array<{ + glob: string + matches: string[] + rejects: string[] +}> = [ + // Literals are exact, case-SENSITIVE matches. + { + glob: 'compileClasspath', + matches: ['compileClasspath'], + rejects: ['CompileClasspath', 'compileClasspathX', 'xcompileClasspath'], + }, + // `*` spans any run of characters, including none. + { + glob: '*CompileClasspath', + matches: ['CompileClasspath', 'testCompileClasspath'], + rejects: ['compileClasspath', 'CompileClasspathTest'], + }, + { + glob: 'net*', + matches: ['net8.0', 'netstandard2.0', 'net'], + rejects: ['dotnet8.0'], + }, + // `?` matches exactly one character. + { + glob: 'net?.0', + matches: ['net8.0', 'net9.0'], + rejects: ['net10.0', 'net.0'], + }, + // Character classes: enumerations, ranges, and `[!..]`/`[^..]` negation. + { + glob: '[cC]ompile', + matches: ['compile', 'Compile'], + rejects: ['dompile'], + }, + { + glob: 'net[6-8].0', + matches: ['net6.0', 'net7.0', 'net8.0'], + rejects: ['net9.0'], + }, + { + glob: '[!t]est', + matches: ['best', 'rest'], + rejects: ['test'], + }, + { + glob: '[^t]est', + matches: ['best', 'rest'], + rejects: ['test'], + }, + // Regex metacharacters in globs are literals. + { + glob: 'net8.0', + matches: ['net8.0'], + rejects: ['net8x0'], + }, + { + glob: 'a+b(c)|d', + matches: ['a+b(c)|d'], + rejects: ['aab(c)|d'], + }, + // An unterminated `[` is a literal bracket. + { + glob: 'a[bc', + matches: ['a[bc'], + rejects: ['ab', 'ac'], + }, + // An empty (possibly negated) class would emit `[^]` — valid in JS but + // rejected by Java/.NET — so the whole glob falls back to a literal match, + // the same behavior the per-language implementations had. + { + glob: '[!]est', + matches: ['[!]est'], + rejects: ['best', 'test', 'est'], + }, + { + glob: '[^]est', + matches: ['[^]est'], + rejects: ['best', 'est'], + }, + // `&` inside a class is a literal (Java classes support `&&` intersection; + // the emitted pattern escapes it so all three engines agree). + { + glob: '[a&]x', + matches: ['ax', '&x'], + rejects: ['bx'], + }, +] + +describe('config-glob vectors (cross-language contract)', () => { + for (const { glob, matches, rejects } of MATCH_VECTORS) { + it(`\`${glob}\``, () => { + const filter = createConfigGlobFilter(glob, '') + for (const name of matches) { + expect(filter(name), `${glob} should match ${name}`).toBe(true) + } + for (const name of rejects) { + expect(filter(name), `${glob} should reject ${name}`).toBe(false) + } + }) + } +}) + +describe('include/exclude semantics', () => { + it('no includes means everything; excludes always win', () => { + const filter = createConfigGlobFilter('', '*test*') + expect(filter('compileClasspath')).toBe(true) + expect(filter('Test')).toBe(true) + expect(filter('testCompileClasspath')).toBe(false) + expect(filter('integrationtestRuntime')).toBe(false) + }) + + it('excludes apply after includes', () => { + const filter = createConfigGlobFilter('*Classpath', '*test*') + expect(filter('compileClasspath')).toBe(true) + expect(filter('integrationtestClasspath')).toBe(false) + expect(filter('compile')).toBe(false) + }) + + it('comma-separated patterns OR together', () => { + const filter = createConfigGlobFilter('compile, runtime', '') + expect(filter('compile')).toBe(true) + expect(filter('runtime')).toBe(true) + expect(filter('test')).toBe(false) + }) +}) + +describe('emitted pattern sources (transport format)', () => { + it('anchors patterns so unanchored matchers still test the full name', () => { + expect(globToRegexSource('net*')).toBe('^(?:net.*)$') + expect(globToRegexSource('a?b')).toBe('^(?:a.b)$') + }) + + it('escapes regex metacharacters as literals', () => { + expect(globToRegexSource('net8.0')).toBe('^(?:net8\\.0)$') + expect(globToRegexSource('a+b(c)|d')).toBe('^(?:a\\+b\\(c\\)\\|d)$') + expect(globToRegexSource('a{b}')).toBe('^(?:a\\{b\\})$') + }) + + it('escapes `&` in class bodies (Java `&&` intersection guard)', () => { + expect(globToRegexSource('[a&]x')).toBe('^(?:[a\\&]x)$') + }) + + it('normalizes `[!..]` negation to `[^..]`', () => { + expect(globToRegexSource('[!t]est')).toBe('^(?:[^t]est)$') + }) + + it('never emits `[^]` (Java/.NET-invalid); empty classes go literal', () => { + expect(globToRegexSource('[!]est')).toBe('^(?:\\[!\\]est)$') + expect(globToRegexSource('[^]')).toBe('^(?:\\[\\^\\])$') + }) + + it('emits nothing a comma-join could break on', () => { + // The transport comma-joins patterns; globs cannot contain commas (the + // comma-split precedes glob parsing), so emitted patterns cannot either. + const serialized = serializeConfigPatterns('net*, [a&]x ,a+b(c)|d') + expect(serialized).toBe('^(?:net.*)$,^(?:[a\\&]x)$,^(?:a\\+b\\(c\\)\\|d)$') + for (const pattern of serialized.split(',')) { + expect(() => new RegExp(pattern)).not.toThrow() + } + }) + + it('serializes empty/blank input to the empty string', () => { + expect(serializeConfigPatterns('')).toBe('') + expect(serializeConfigPatterns(' , ,')).toBe('') + expect(serializeConfigPatterns(undefined)).toBe('') + }) +}) diff --git a/src/commands/manifest/scripts/dotnet-tool/.gitignore b/src/commands/manifest/scripts/dotnet-tool/.gitignore new file mode 100644 index 000000000..7538de614 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +publish/ diff --git a/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs new file mode 100644 index 000000000..a4a65e229 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs @@ -0,0 +1,924 @@ +using System.Text.RegularExpressions; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Graph; +using NuGet.Common; +using NuGet.LibraryModel; +using NuGet.Packaging; +using NuGet.ProjectModel; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; + +namespace Socket.Facts.Dotnet; + +// Single-session facts producer: evaluate -> restore -> read, all under ONE +// global-property bag (the user's -p: opts), so restore and the emitted graph +// can never describe different builds. Fail-closed by contract with the CLI: +// this tool records failures instead of throwing; the TypeScript side renders +// them and raises the exit code. +internal static class FactsRunner { + private const string TestHostPackage = "microsoft.net.test.sdk"; + + public static int Run(ToolOptions opts, string sdkVersion) { + using var records = new RecordsWriter(opts.RecordsPath); + try { + records.Meta(sdkVersion); + new Session(opts, records).Execute(); + return 0; + } catch (Exception e) { + // Catastrophic only: per-project problems become failure records inside + // Execute. A hard crash returns non-zero with the partial records left + // in place; the CLI treats it as a crashed build. + Console.Error.WriteLine($"socket-facts-dotnet: {e}"); + return 1; + } + } + + private sealed class Session(ToolOptions opts, RecordsWriter records) { + private readonly List _includes = ParsePatterns(opts.IncludeConfigs); + private readonly List _excludes = ParsePatterns(opts.ExcludeConfigs); + private readonly HashSet _scanned = new(StringComparer.Ordinal); + + // Restore eligibility per evaluated project, keyed by full path. Decided + // the way NuGet's own restore decides — from the project model, not file + // paths — so legacy no-dependency projects and packages.config projects + // are never sent to (or blamed by) a Restore build. + private readonly Dictionary _restoreSupported = + new(StringComparer.Ordinal); + + public void Execute() { + var entries = Discover(); + if (entries.Count == 0) { + Log("no solution or project files found at the top level"); + return; + } + + var graphs = EvaluateGraphs(entries); + var projectPaths = graphs.ProjectPaths; + if (!opts.NoRestore) { + // A standalone project restores only if it supports restore; a + // solution restores if ANY member does (NuGet skips the rest). + var restorable = graphs.Entries + .Where(e => e.AnyRestoreSupported) + .Select(e => e.Path) + .ToList(); + if (restorable.Count > 0) { + Restore(restorable); + } + } + foreach (var projectPath in projectPaths) { + ReadProject(projectPath); + } + } + + // NuGet's documented packages.config lookup: `packages..config` + // (spaces replaced with underscores) takes precedence over `packages.config` + // in the project directory. + private static string? FindPackagesConfig(string projectPath) { + var dir = Path.GetDirectoryName(projectPath)!; + var projectName = Path.GetFileNameWithoutExtension(projectPath).Replace(' ', '_'); + var perProject = Path.Combine(dir, $"packages.{projectName}.config"); + if (File.Exists(perProject)) return perProject; + var shared = Path.Combine(dir, "packages.config"); + return File.Exists(shared) ? shared : null; + } + + // Mirrors NuGet's own eligibility model: PackageReference items (also + // valid in legacy-format projects), an explicit RestoreProjectStyle, or + // an SDK-style project. packages.config presence vetoes: NuGet restores + // those only via msbuild -t:restore on Windows, and the manifest is + // self-contained for our purposes. + private static bool IsRestoreSupported(ProjectInstance instance) { + var fullPath = instance.FullPath; + if (!string.IsNullOrEmpty(fullPath) && FindPackagesConfig(fullPath) != null) { + return false; + } + if (instance.GetItems("PackageReference").Any()) return true; + if (string.Equals( + instance.GetPropertyValue("RestoreProjectStyle"), "PackageReference", + StringComparison.OrdinalIgnoreCase)) { + return true; + } + return string.Equals( + instance.GetPropertyValue("UsingMicrosoftNETSdk"), "true", + StringComparison.OrdinalIgnoreCase); + } + + // Case-insensitive so Linux agrees with the CLI's detection (App.SLN is a + // valid solution file there too). + private static readonly EnumerationOptions TopLevelIgnoreCase = new() { + MatchCasing = MatchCasing.CaseInsensitive, + RecurseSubdirectories = false, + }; + + // Top-level only, matching every other `socket manifest` producer: the + // command runs where the build runs; it does not walk the filesystem. + private List Discover() { + var slns = Directory.EnumerateFiles(opts.RootDir, "*.sln", TopLevelIgnoreCase) + .Concat(Directory.EnumerateFiles(opts.RootDir, "*.slnx", TopLevelIgnoreCase)) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + if (slns.Count > 0) return slns; + return Directory.EnumerateFiles(opts.RootDir, "*.*proj", TopLevelIgnoreCase) + .Where(IsProjectFile) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + } + + private sealed record GraphSummary( + List<(string Path, bool AnyRestoreSupported)> Entries, + List ProjectPaths + ); + + // Walks project graphs (solutions expand to their member projects, and + // project references pull in projects outside the top-level dir) to find + // every project this build covers, recording restore eligibility from the + // evaluated instances along the way. + private GraphSummary EvaluateGraphs(List entries) { + var entrySummaries = new List<(string Path, bool AnyRestoreSupported)>(); + var projectPaths = new SortedSet(StringComparer.Ordinal); + foreach (var entry in entries) { + var anySupported = false; + try { + var collection = new ProjectCollection(opts.GlobalProperties); + var graph = new ProjectGraph( + new[] { new ProjectGraphEntryPoint(entry, opts.GlobalProperties) }, + collection, + CreateInstance + ); + foreach (var node in graph.ProjectNodes) { + var fullPath = node.ProjectInstance.FullPath; + if (string.IsNullOrEmpty(fullPath) || !IsProjectFile(fullPath)) { + continue; + } + fullPath = Path.GetFullPath(fullPath); + projectPaths.Add(fullPath); + var supported = IsRestoreSupported(node.ProjectInstance); + _restoreSupported[fullPath] = supported; + anySupported |= supported; + } + } catch (Exception e) { + records.Failure(Rel(entry), $"could not load the project graph: {FirstLine(e.Message)}", ""); + } + entrySummaries.Add((entry, anySupported)); + } + return new GraphSummary(entrySummaries, projectPaths.ToList()); + } + + private ProjectInstance CreateInstance( + string fullPath, Dictionary globalProperties, ProjectCollection collection + ) { + try { + // Pre-restore, the generated nuget.g.props imports may not exist yet. + var project = new Project( + fullPath, globalProperties, toolsVersion: null, collection, + ProjectLoadSettings.IgnoreMissingImports + ); + return project.CreateProjectInstance(); + } catch (Exception e) { + records.Failure(Rel(fullPath), $"could not evaluate the project: {FirstLine(e.Message)}", ""); + return new Project(collection).CreateProjectInstance(); + } + } + + // Restore-only global properties: a metadata scan wants the resolved graph, + // not the project's build-warning policy. `TreatWarningsAsErrors=false` + // stops a project from promoting a NuGet warning to a fatal error and + // aborting the whole run — most notably NU1902/NU1903/NU1904 security + // advisories, which a Socket scan should surface as findings, never choke + // on. Ours wins over any user `-p:` value: warning-as-error is never what + // an SBOM run wants. Restore-scoped (not folded into opts.GlobalProperties) + // so the post-restore re-evaluation still sees the project's real settings. + private Dictionary RestoreProperties() { + return new Dictionary(opts.GlobalProperties, StringComparer.OrdinalIgnoreCase) { + ["TreatWarningsAsErrors"] = "false", + ["MSBuildTreatWarningsAsErrors"] = "false", + }; + } + + private void Restore(List entries) { + var restoreProps = RestoreProperties(); + var errorCapture = new ErrorCaptureLogger(); + var bm = BuildManager.DefaultBuildManager; + bm.BeginBuild(new BuildParameters(new ProjectCollection(restoreProps)) { + Loggers = new Microsoft.Build.Framework.ILogger[] { errorCapture }, + EnableNodeReuse = false, + }); + var submissions = new List<(string Entry, BuildSubmission Submission)>(); + try { + foreach (var entry in entries) { + var request = new BuildRequestData( + entry, restoreProps, targetsToBuild: new[] { "Restore" }, + toolsVersion: null, hostServices: null + ); + var submission = bm.PendBuildRequest(request); + submission.ExecuteAsync(callback: null, context: null); + submissions.Add((entry, submission)); + } + var deadline = DateTime.UtcNow.AddSeconds(opts.RestoreTimeoutSec); + var timedOut = false; + var cancelled = new HashSet(); + foreach (var (entry, submission) in submissions) { + // A submission that already finished is never a timeout, even when + // an earlier one exhausted the budget. + if (submission.IsCompleted) continue; + var remaining = deadline - DateTime.UtcNow; + if (timedOut || remaining <= TimeSpan.Zero || !submission.WaitHandle.WaitOne(remaining)) { + if (!timedOut) { + timedOut = true; + bm.CancelAllSubmissions(); + } + cancelled.Add(submission); + records.Failure( + Rel(entry), + $"restore did not finish within {opts.RestoreTimeoutSec}s and was cancelled", + "" + ); + } + } + if (timedOut) { + // Give cancelled submissions a moment to unwind before EndBuild. + foreach (var (_, submission) in submissions) { + submission.WaitHandle.WaitOne(TimeSpan.FromSeconds(30)); + } + } + _cancelledSubmissions = cancelled; + } finally { + bm.EndBuild(); + } + foreach (var error in errorCapture.Errors) { + // Restore errors attributed to projects that don't support restore + // (packages.config, legacy no-dependency) are noise: the reader path + // handles those without restore, while a solution restore still + // visits them and can fail on VS-only imports like + // Microsoft.WebApplication.targets. + if (Path.IsPathRooted(error.Coord) + && _restoreSupported.TryGetValue(Path.GetFullPath(error.Coord), out var supported) + && !supported) { + continue; + } + records.Failure(error.Coord, error.Detail, ""); + } + foreach (var (entry, submission) in submissions) { + if (submission.IsCompleted + && !_cancelledSubmissions.Contains(submission) + && submission.BuildResult?.OverallResult == BuildResultCode.Failure + && errorCapture.Errors.Count == 0) { + records.Failure(Rel(entry), "restore failed (run with --verbose for the build output)", ""); + } + } + } + + private HashSet _cancelledSubmissions = new(); + + private void ReadProject(string projectPath) { + Log($"reading {Rel(projectPath)}"); + Project project; + // Fresh evaluation post-restore so the generated nuget.g.props imports + // are picked up; a fresh collection per project keeps evaluations + // independent of graph-time state. + var collection = new ProjectCollection(opts.GlobalProperties); + try { + project = new Project( + projectPath, opts.GlobalProperties, toolsVersion: null, collection, + ProjectLoadSettings.IgnoreMissingImports + ); + } catch (Exception e) { + records.Failure(Rel(projectPath), $"could not evaluate the project: {FirstLine(e.Message)}", ""); + return; + } + + var pkgConfigPath = FindPackagesConfig(projectPath); + if (pkgConfigPath != null) { + ReadPackagesConfigProject(project, projectPath, pkgConfigPath); + return; + } + + var assetsPath = project.GetPropertyValue("ProjectAssetsFile"); + if (string.IsNullOrEmpty(assetsPath)) { + var objDir = project.GetPropertyValue("MSBuildProjectExtensionsPath"); + assetsPath = string.IsNullOrEmpty(objDir) + ? Path.Combine(Path.GetDirectoryName(projectPath)!, "obj", "project.assets.json") + : Path.Combine(objDir, "project.assets.json"); + } + assetsPath = Path.GetFullPath(assetsPath, Path.GetDirectoryName(projectPath)!); + if (!File.Exists(assetsPath)) { + if (project.GetItems("PackageReference").Count == 0 + && !string.Equals(project.GetPropertyValue("UsingMicrosoftNETSdk"), "true", StringComparison.OrdinalIgnoreCase)) { + // No NuGet dependencies at all (e.g. GAC-only legacy project): still + // a first-party module whose sources matter, just nothing to resolve. + EmitBareProject(project, projectPath); + } else { + records.Failure(Rel(projectPath), "restore produced no project.assets.json for this project", ""); + } + return; + } + + var lockFile = LockFileUtilities.GetLockFile(assetsPath, NullLogger.Instance); + if (lockFile?.PackageSpec == null) { + records.Failure(Rel(projectPath), $"could not parse {Rel(assetsPath)}", ""); + return; + } + + var projectKey = projectPath; + var projectName = lockFile.PackageSpec.Name ?? Path.GetFileNameWithoutExtension(projectPath); + var projectVersion = lockFile.PackageSpec.Version?.ToNormalizedString() ?? ""; + records.Project(projectKey, projectName, projectVersion, Rel(Path.GetDirectoryName(projectPath)!)); + + foreach (var message in lockFile.LogMessages ?? Enumerable.Empty()) { + if (message.Level == LogLevel.Error) { + records.Failure( + string.IsNullOrEmpty(message.LibraryId) ? projectName : message.LibraryId, + $"{message.Code}: {message.Message}", + "" + ); + } + } + + var isTestProject = + string.Equals(project.GetPropertyValue("IsTestProject"), "true", StringComparison.OrdinalIgnoreCase) + || lockFile.PackageSpec.TargetFrameworks.Any(f => + DependenciesOf(f).Any(d => string.Equals(d.Name, TestHostPackage, StringComparison.OrdinalIgnoreCase))); + + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + EmitProjectTargets(projectKey, projectPath, project, collection, lockFile); + } + + foreach (var target in lockFile.Targets) { + EmitTarget(projectKey, projectName, lockFile, target, isTestProject); + } + } + + // The project dir is the base source root; evaluated Compile items catch + // linked sources living OUTSIDE it (``), + // which the dir alone would miss. Exclude-globs within the dir are not + // modeled (the dir over-approximates); refine with per-file paths if the + // sidecar consumer ever wants exact sets. + private void EmitProjectSources(string projectKey, string projectPath, Project project) { + var projectDir = Path.GetDirectoryName(projectPath)!; + records.ProjectSrc(projectKey, projectDir); + var external = new SortedSet(StringComparer.Ordinal); + foreach (var item in project.GetItems("Compile")) { + string full; + try { + full = item.GetMetadataValue("FullPath"); + } catch { + continue; + } + if (string.IsNullOrEmpty(full)) continue; + var dir = Path.GetDirectoryName(Path.GetFullPath(full)); + if (dir != null && !IsUnder(dir, projectDir)) { + external.Add(dir); + } + } + foreach (var dir in external) { + records.ProjectSrc(projectKey, dir); + } + } + + private static bool IsUnder(string path, string root) { + var rel = Path.GetRelativePath(root, path); + return rel == "." || (!rel.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(rel)); + } + + private void EmitBareProject(Project project, string projectPath) { + var projectKey = projectPath; + records.Project( + projectKey, + Path.GetFileNameWithoutExtension(projectPath), + "", + Rel(Path.GetDirectoryName(projectPath)!) + ); + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + var targetPath = NormalizeSlashes(project.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) { + records.ProjectTgt(projectKey, Path.GetFullPath(targetPath, Path.GetDirectoryName(projectPath)!)); + } + } + } + + // Legacy packages.config: the manifest itself is the full pinned closure + // (NuGet resolved it at install time), so no restore is needed for + // completeness — the graph is flat (no edge data in the manifest) and + // every package is emitted as direct. `developmentDependency="true"` + // feeds the dev split; installed DLLs come from evaluated Reference + // HintPaths when the packages folder is present. + private void ReadPackagesConfigProject(Project project, string projectPath, string pkgConfigPath) { + var projectKey = projectPath; + records.Project( + projectKey, + Path.GetFileNameWithoutExtension(projectPath), + "", + Rel(Path.GetDirectoryName(projectPath)!) + ); + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + var targetPath = NormalizeSlashes(project.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) { + records.ProjectTgt(projectKey, Path.GetFullPath(targetPath, Path.GetDirectoryName(projectPath)!)); + } + } + + List packages; + try { + using var stream = File.OpenRead(pkgConfigPath); + packages = new NuGet.Packaging.PackagesConfigReader(stream) + .GetPackages(allowDuplicatePackageIds: true) + .ToList(); + } catch (Exception e) { + records.Failure(Rel(pkgConfigPath), $"could not parse packages.config: {FirstLine(e.Message)}", ""); + return; + } + if (packages.Count == 0) return; + + var config = LegacyFrameworkConfigName(project); + if (!ConfigMatches(new[] { config })) return; + if (_scanned.Add(config)) records.Scanned(config); + + // DLL paths per package folder segment (`.`), from the + // evaluated References' HintPaths. + var dllsBySegment = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (opts.WithFiles) { + foreach (var item in project.GetItems("Reference")) { + // Normalize explicitly: MSBuild's unix slash-adjustment for metadata + // is existence-gated, so HintPaths keep raw backslashes precisely + // when the packages folder is missing — the download case. + var hint = NormalizeSlashes(item.GetMetadataValue("HintPath")); + if (string.IsNullOrEmpty(hint)) continue; + var full = Path.GetFullPath(hint, Path.GetDirectoryName(projectPath)!); + foreach (var segment in full.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { + if (!dllsBySegment.TryGetValue(segment, out var list)) dllsBySegment[segment] = list = new List(); + list.Add(full); + } + } + // Packages whose HintPath'd assemblies aren't on disk get downloaded + // into the same packages folder the HintPaths reference — exactly what + // `nuget restore` would populate. Versions are pinned by the manifest, + // so there is no resolution to redo, and a failed download becomes a + // blocking failure record (matching the Maven/Gradle scripts). + if (!opts.NoRestore) { + var missing = new List(); + string? packagesRoot = null; + foreach (var pkg in packages) { + var segment = FolderSegment(pkg); + if (segment == null || !dllsBySegment.TryGetValue(segment, out var dlls)) continue; + var absent = dlls.FirstOrDefault(d => !File.Exists(d)); + if (absent == null) continue; + missing.Add(pkg); + packagesRoot ??= DerivePackagesRoot(absent, segment); + } + if (missing.Count > 0) { + if (packagesRoot == null) { + records.Failure( + Rel(projectPath), + "could not locate the packages folder from the project's HintPaths; run a NuGet restore", + config + ); + } else { + DownloadPackagesConfigArtifacts( + Path.GetDirectoryName(projectPath)!, packagesRoot, missing, config + ); + } + } + } + } + + var emitted = new[] { + (Kind: "prod", Packages: packages.Where(p => !p.IsDevelopmentDependency).ToList()), + (Kind: "dev", Packages: packages.Where(p => p.IsDevelopmentDependency).ToList()), + }; + foreach (var (kind, kindPackages) in emitted) { + if (kindPackages.Count == 0) continue; + var rootId = $"{projectKey}|{config}|{kind}"; + records.Root(rootId, projectKey, config, kind == "prod"); + foreach (var pkg in kindPackages.OrderBy(p => p.PackageIdentity.Id, StringComparer.OrdinalIgnoreCase)) { + var id = pkg.PackageIdentity.Id; + var version = pkg.PackageIdentity.Version?.ToNormalizedString() ?? ""; + var coord = CoordIdOf(id, version); + // The flat closure carries no edges, so direct-vs-transitive is + // unknowable from the manifest alone; every package is direct, the + // same over-approximation cdxgen makes. + records.Node(rootId, coord, id, version, direct: true); + var segment = FolderSegment(pkg); + if (opts.WithFiles && segment != null && dllsBySegment.TryGetValue(segment, out var dlls)) { + foreach (var dll in dlls) { + if (File.Exists(dll)) { + records.File(rootId, coord, dll); + } else { + // Never silently claim an artifact: a referenced assembly that + // is still absent (download failed or was skipped) blocks. + records.Failure( + coord, + $"referenced assembly is not installed ({Path.GetFileName(dll)}); run a NuGet restore or re-run without --no-restore", + config + ); + } + } + } + } + } + } + + private static string NormalizeSlashes(string path) { + return Path.DirectorySeparatorChar == '/' ? path.Replace('\\', '/') : path; + } + + // The `.` folder segment a packages.config package occupies + // in the packages folder (and in HintPaths). + private static string? FolderSegment(NuGet.Packaging.PackageReference pkg) { + var version = pkg.PackageIdentity.Version?.ToNormalizedString(); + return string.IsNullOrEmpty(version) ? null : $"{pkg.PackageIdentity.Id}.{version}"; + } + + // The packages root is whatever the HintPaths point at: everything before + // the `.` segment. + private static string? DerivePackagesRoot(string dllPath, string segment) { + var parts = dllPath.Split(Path.DirectorySeparatorChar); + var idx = Array.FindIndex(parts, p => string.Equals(p, segment, StringComparison.OrdinalIgnoreCase)); + if (idx <= 0) return null; + return string.Join(Path.DirectorySeparatorChar, parts.Take(idx)); + } + + // Pinned-version downloads through the user's configured NuGet sources + // (nuget.config hierarchy, credential providers included) — the same feeds + // the user's own restore would use. Extraction uses the side-by-side + // `.` layout `nuget restore` produces for packages.config. + private void DownloadPackagesConfigArtifacts( + string projectDir, string packagesRoot, + List missing, string config + ) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(opts.RestoreTimeoutSec)); + var logger = NullLogger.Instance; + NuGet.Configuration.ISettings settings; + List repos; + try { + settings = NuGet.Configuration.Settings.LoadDefaultSettings(projectDir); + repos = new NuGet.Configuration.PackageSourceProvider(settings) + .LoadPackageSources() + .Where(s => s.IsEnabled) + .Select(s => Repository.Factory.GetCoreV3(s)) + .ToList(); + NuGet.Credentials.DefaultCredentialServiceUtility.SetupDefaultCredentialService( + logger, nonInteractive: true + ); + } catch (Exception e) { + foreach (var pkg in missing) { + records.Failure( + CoordIdOf(pkg.PackageIdentity.Id, pkg.PackageIdentity.Version?.ToNormalizedString() ?? ""), + $"could not load NuGet sources for download: {FirstLine(e.Message)}", + config + ); + } + return; + } + var extraction = new PackageExtractionContext( + PackageSaveMode.Defaultv2, + XmlDocFileSaveMode.None, + NuGet.Packaging.Signing.ClientPolicyContext.GetClientPolicy(settings, logger), + logger + ); + var resolver = new PackagePathResolver(packagesRoot); + using var cache = new SourceCacheContext(); + foreach (var pkg in missing) { + var identity = pkg.PackageIdentity; + var coord = CoordIdOf(identity.Id, identity.Version?.ToNormalizedString() ?? ""); + string? lastError = repos.Count == 0 ? "no enabled NuGet sources" : null; + var ok = false; + foreach (var repo in repos) { + try { + var resource = repo + .GetResourceAsync(cts.Token) + .GetAwaiter().GetResult(); + using var stream = new MemoryStream(); + var copied = resource + .CopyNupkgToStreamAsync(identity.Id, identity.Version, stream, cache, logger, cts.Token) + .GetAwaiter().GetResult(); + if (!copied) continue; + stream.Position = 0; + PackageExtractor + .ExtractPackageAsync(repo.PackageSource.Source, stream, resolver, extraction, cts.Token) + .GetAwaiter().GetResult(); + Log($"downloaded {identity} from {repo.PackageSource.Name}"); + ok = true; + break; + } catch (Exception e) { + lastError = FirstLine(e.Message); + } + } + if (!ok) { + records.Failure( + coord, + $"could not download from any configured NuGet source{(lastError == null ? "" : $": {lastError}")}", + config + ); + } + } + } + + // Short folder name of the legacy project's single framework, e.g. net472. + private static string LegacyFrameworkConfigName(Project project) { + var moniker = project.GetPropertyValue("TargetFrameworkMoniker"); + if (!string.IsNullOrEmpty(moniker)) { + try { + return NuGet.Frameworks.NuGetFramework.Parse(moniker).GetShortFolderName(); + } catch { + // Fall through to the raw version below. + } + } + var version = project.GetPropertyValue("TargetFrameworkVersion"); + return string.IsNullOrEmpty(version) ? "unknown" : $"net{version.TrimStart('v').Replace(".", "")}"; + } + + // The compiled output path per target framework; multi-targeting needs an + // inner-build evaluation per alias because the outer build has no + // TargetPath. Missing files are fine: the CLI drops non-existent paths. + private void EmitProjectTargets( + string projectKey, string projectPath, Project outerProject, + ProjectCollection collection, LockFile lockFile + ) { + var frameworks = lockFile.PackageSpec.TargetFrameworks; + if (frameworks.Count <= 1) { + var targetPath = NormalizeSlashes(outerProject.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) records.ProjectTgt(projectKey, Path.GetFullPath(targetPath)); + return; + } + foreach (var framework in frameworks) { + var alias = framework.TargetAlias; + if (string.IsNullOrEmpty(alias)) continue; + try { + var props = new Dictionary(opts.GlobalProperties, StringComparer.OrdinalIgnoreCase) { + ["TargetFramework"] = alias, + }; + var inner = new Project(projectPath, props, toolsVersion: null, collection, ProjectLoadSettings.IgnoreMissingImports); + var targetPath = NormalizeSlashes(inner.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) records.ProjectTgt(projectKey, Path.GetFullPath(targetPath)); + } catch { + // TargetPath is best-effort metadata; resolution stays authoritative. + } + } + } + + private void EmitTarget( + string projectKey, string projectName, LockFile lockFile, LockFileTarget target, bool isTestProject + ) { + var frameworkInfo = lockFile.PackageSpec.TargetFrameworks + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + var shortName = frameworkInfo?.TargetAlias; + if (string.IsNullOrEmpty(shortName)) { + try { + shortName = target.TargetFramework.GetShortFolderName(); + } catch { + records.Unscannable(target.Name, $"unrecognized target framework in {Rel(projectKey)}"); + return; + } + } + var config = string.IsNullOrEmpty(target.RuntimeIdentifier) + ? shortName + : $"{shortName}/{target.RuntimeIdentifier}"; + // RID-specific targets match on the composite name AND on the base + // framework, so the documented `--include-configs net8.0` usage also + // covers `net8.0/win-x64` (and an exclude of either form drops it). + var configNames = string.IsNullOrEmpty(target.RuntimeIdentifier) + ? new[] { config } + : new[] { config, shortName }; + if (!ConfigMatches(configNames)) return; + if (_scanned.Add(config)) records.Scanned(config); + + // NuGet package ids are case-insensitive; dependency edges resolve + // through a lowercased name index. + var byLowerName = new Dictionary(StringComparer.Ordinal); + var nodes = new Dictionary(StringComparer.Ordinal); + var children = new Dictionary>(StringComparer.Ordinal); + foreach (var lib in target.Libraries) { + if (string.IsNullOrEmpty(lib.Name)) continue; + var coordId = CoordId(lib); + byLowerName[lib.Name.ToLowerInvariant()] = coordId; + nodes[coordId] = lib; + } + foreach (var lib in target.Libraries) { + if (string.IsNullOrEmpty(lib.Name)) continue; + var parent = CoordId(lib); + foreach (var dep in lib.Dependencies) { + if (byLowerName.TryGetValue(dep.Id.ToLowerInvariant(), out var child) && child != parent) { + if (!children.TryGetValue(parent, out var list)) children[parent] = list = new List(); + list.Add(child); + } + } + } + + var directProd = new HashSet(StringComparer.Ordinal); + var directDev = new HashSet(StringComparer.Ordinal); + foreach (var dep in frameworkInfo != null ? DependenciesOf(frameworkInfo) : Enumerable.Empty()) { + if (string.IsNullOrEmpty(dep.Name)) continue; + if (!byLowerName.TryGetValue(dep.Name.ToLowerInvariant(), out var coordId)) continue; + // PrivateAssets=all (analyzers, build tooling) doesn't flow to + // consumers: dev, mirroring the JVM scripts' non-prod configs. + if (dep.SuppressParent == LibraryIncludeFlags.All) directDev.Add(coordId); + else directProd.Add(coordId); + } + // Match project references by their MSBuild project path (the lock-file + // library name is the PackageSpec/PackageId name, which can differ from + // the csproj file name); the file-name lookup stays as a fallback. + var projectLibByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + var projectDir = Path.GetDirectoryName(projectKey)!; + foreach (var library in lockFile.Libraries) { + if (!string.Equals(library.Type, "project", StringComparison.OrdinalIgnoreCase)) continue; + if (string.IsNullOrEmpty(library.MSBuildProject) || string.IsNullOrEmpty(library.Name)) continue; + projectLibByPath[Path.GetFullPath(library.MSBuildProject, projectDir)] = + library.Name.ToLowerInvariant(); + } + var restoreFrameworkInfo = lockFile.PackageSpec.RestoreMetadata?.TargetFrameworks + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + foreach (var projectRef in restoreFrameworkInfo?.ProjectReferences ?? Enumerable.Empty()) { + var refPath = projectRef.ProjectPath; + if (string.IsNullOrEmpty(refPath)) continue; + if (!projectLibByPath.TryGetValue(Path.GetFullPath(refPath, projectDir), out var lowerName)) { + lowerName = Path.GetFileNameWithoutExtension(refPath).ToLowerInvariant(); + } + if (byLowerName.TryGetValue(lowerName, out var coordId)) { + directProd.Add(coordId); + } + } + + var prodReach = Reach(directProd, children); + var devReach = Reach(directDev, children); + foreach (var key in nodes.Keys) { + if (!prodReach.Contains(key) && !devReach.Contains(key)) prodReach.Add(key); + } + + EmitRoot(projectKey, config, "prod", !isTestProject, prodReach, directProd, nodes, children, lockFile); + EmitRoot(projectKey, config, "dev", prod: false, devReach, directDev, nodes, children, lockFile); + } + + private void EmitRoot( + string projectKey, string config, string kind, bool prod, + HashSet keys, HashSet direct, + Dictionary nodes, + Dictionary> children, LockFile lockFile + ) { + if (keys.Count == 0) return; + var rootId = $"{projectKey}|{config}|{kind}"; + records.Root(rootId, projectKey, config, prod); + foreach (var key in keys.OrderBy(k => k, StringComparer.Ordinal)) { + var lib = nodes[key]; + records.Node(rootId, key, lib.Name!, lib.Version?.ToNormalizedString() ?? "", direct.Contains(key)); + if (opts.WithFiles && string.Equals(lib.Type, "package", StringComparison.OrdinalIgnoreCase)) { + var (found, missing) = ResolveRuntimeAssemblies(lockFile, lib); + foreach (var file in found) { + records.File(rootId, key, file); + } + if (missing.Count > 0) { + // Never silently claim an artifact: the lock file lists runtime + // assemblies this cache doesn't hold (pruned cache, stale assets + // with --no-restore). A package with NO runtime assemblies at all + // (analyzer/content-only) is fine and takes neither branch. + records.Failure( + key, + $"runtime assemblies listed in project.assets.json are missing from the package cache ({missing[0]}{(missing.Count > 1 ? $" +{missing.Count - 1} more" : "")}); re-run restore", + config + ); + } + } + if (children.TryGetValue(key, out var kids)) { + foreach (var child in kids) { + if (keys.Contains(child)) records.Edge(rootId, key, child); + } + } + } + } + + // Runtime (lib/) assemblies, not compile (ref/) assemblies: reference + // assemblies have no method bodies, which breaks reachability analysis. + // First package folder holding the file wins (NuGet's own probe order); + // an item found in NO folder is reported so it can fail the run. + private static (List Found, List Missing) ResolveRuntimeAssemblies( + LockFile lockFile, LockFileTargetLibrary lib + ) { + var found = new List(); + var missing = new List(); + var library = lockFile.GetLibrary(lib.Name, lib.Version); + if (library?.Path == null) return (found, missing); + var items = (lib.RuntimeAssemblies ?? new List()) + .Select(item => item.Path) + .Where(p => !string.IsNullOrEmpty(p) + && p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && Path.GetFileName(p) != "_._") + .ToList(); + foreach (var item in items) { + string? hit = null; + foreach (var folder in lockFile.PackageFolders) { + if (string.IsNullOrEmpty(folder.Path)) continue; + var full = Path.GetFullPath(Path.Combine(folder.Path, library.Path, item)); + if (File.Exists(full)) { + hit = full; + break; + } + } + if (hit != null) { + found.Add(hit); + } else { + missing.Add(item!); + } + } + return (found, missing); + } + + private static HashSet Reach(HashSet seeds, Dictionary> children) { + var seen = new HashSet(seeds, StringComparer.Ordinal); + var stack = new Stack(seen); + while (stack.Count > 0) { + var key = stack.Pop(); + if (!children.TryGetValue(key, out var kids)) continue; + foreach (var child in kids) { + if (seen.Add(child)) stack.Push(child); + } + } + return seen; + } + + private bool ConfigMatches(IReadOnlyList names) { + if (_excludes.Any(p => names.Any(n => p.IsMatch(n)))) return false; + return _includes.Count == 0 || _includes.Any(p => names.Any(n => p.IsMatch(n))); + } + + private string Rel(string path) { + var rel = Path.GetRelativePath(opts.RootDir, path).Replace('\\', '/'); + return string.IsNullOrEmpty(rel) || rel == "." ? "." : rel; + } + + private void Log(string message) { + if (opts.Verbose) Console.Error.WriteLine($"socket-facts-dotnet: {message}"); + } + } + + // TargetFrameworkInformation.Dependencies changed shape across NuGet + // versions (IList -> ImmutableArray), + // so a compiled getter call binds on some SDKs and MissingMethodExceptions + // on others. Read it reflectively: both shapes implement + // IEnumerable, and type identity holds because the + // runtime NuGet assemblies are always the locator-selected SDK's own. + private static readonly System.Reflection.PropertyInfo? TfiDependenciesProperty = + typeof(TargetFrameworkInformation).GetProperty("Dependencies"); + + private static IEnumerable DependenciesOf(TargetFrameworkInformation framework) { + return TfiDependenciesProperty?.GetValue(framework) as IEnumerable + ?? Enumerable.Empty(); + } + + private static string CoordId(LockFileTargetLibrary lib) { + return CoordIdOf(lib.Name!, lib.Version?.ToNormalizedString() ?? ""); + } + + private static string CoordIdOf(string name, string version) { + return string.IsNullOrEmpty(version) ? name : $"{name}:{version}"; + } + + private static bool IsProjectFile(string path) => + path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase); + + private static string FirstLine(string s) { + var idx = s.IndexOfAny(new[] { '\n', '\r' }); + return idx < 0 ? s : s.Substring(0, idx); + } + + // Pre-compiled anchored pattern sources from the CLI (config-glob.mts is the + // single glob implementation); an uncompilable pattern is dropped, never + // thrown — it only guards against a broken transport. + private static List ParsePatterns(string csv) { + var patterns = new List(); + foreach (var raw in (csv ?? "").Split(',')) { + var p = raw.Trim(); + if (p.Length == 0) continue; + try { + patterns.Add(new Regex(p)); + } catch (ArgumentException) { + // Dropped; see contract above. + } + } + return patterns; + } + + // Collects restore errors; NuGet logs NU-coded restore failures as build + // errors, which become failure records after the build session ends. + private sealed class ErrorCaptureLogger : Microsoft.Build.Framework.ILogger { + public readonly List<(string Coord, string Detail)> Errors = new(); + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Quiet; + public string? Parameters { get; set; } + + public void Initialize(IEventSource eventSource) { + eventSource.ErrorRaised += (_, e) => { + lock (Errors) { + var coord = string.IsNullOrEmpty(e.ProjectFile) ? (e.File ?? "restore") : e.ProjectFile; + Errors.Add((coord, $"{e.Code}: {e.Message}")); + } + }; + } + + public void Shutdown() { } + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/Program.cs b/src/commands/manifest/scripts/dotnet-tool/Program.cs new file mode 100644 index 000000000..85f81475f --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Build.Locator; + +namespace Socket.Facts.Dotnet; + +internal static class Program { + private static int Main(string[] args) { + ToolOptions opts; + try { + opts = ToolOptions.Parse(args); + } catch (ArgumentException e) { + Console.Error.WriteLine(e.Message); + Console.Error.WriteLine(ToolOptions.Usage); + return 2; + } + var instance = MSBuildLocator.RegisterDefaults(); + // FactsRunner lives in a separate class: MSBuild types must not be JITed + // before the locator has registered the SDK assemblies. + return FactsRunner.Run(opts, instance.Version.ToString()); + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs b/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs new file mode 100644 index 000000000..4373738a0 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace Socket.Facts.Dotnet; + +// Emits the flat TSV records line protocol shared with the JVM build-tool +// scripts (grammar documented in records.mts). Buffered and flushed on +// Dispose so a crash mid-run still leaves whatever was recorded. +internal sealed class RecordsWriter : IDisposable { + private readonly StreamWriter _writer; + + public RecordsWriter(string path) { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // No BOM: the TS records parser reads lines verbatim and a BOM would + // corrupt the first record's tag. + _writer = new StreamWriter(path, append: false, new UTF8Encoding(false)); + } + + public void Rec(params string[] fields) { + var sb = new StringBuilder(); + for (var i = 0; i < fields.Length; i += 1) { + if (i > 0) sb.Append('\t'); + sb.Append(Escape(fields[i])); + } + _writer.WriteLine(sb.ToString()); + } + + public void Meta(string toolVersion) => Rec("meta", "dotnet", toolVersion, ""); + + public void Project(string projectKey, string name, string version, string dir) => + Rec("project", projectKey, "", name, version, dir); + + public void ProjectSrc(string projectKey, string path) => Rec("projectSrc", projectKey, path); + + public void ProjectTgt(string projectKey, string path) => Rec("projectTgt", projectKey, path); + + public void Root(string rootId, string projectKey, string config, bool prod) => + Rec("root", rootId, projectKey, config, prod ? "1" : "0"); + + public void Node(string rootId, string coordId, string name, string version, bool direct) => + Rec("node", rootId, coordId, "", name, version, "", "", direct ? "1" : "0"); + + public void Edge(string rootId, string parentCoordId, string childCoordId) => + Rec("edge", rootId, parentCoordId, childCoordId); + + public void File(string rootId, string coordId, string path) => Rec("file", rootId, coordId, path); + + public void Scanned(string config) => Rec("scanned", config); + + public void Failure(string coord, string detail, string config) => Rec("failure", coord, detail, config); + + public void Unscannable(string config, string detail) => Rec("unscannable", config, detail); + + public void Dispose() => _writer.Dispose(); + + private static string Escape(string? v) { + if (string.IsNullOrEmpty(v)) return ""; + return v.Replace("\\", "\\\\").Replace("\t", "\\t").Replace("\n", "\\n").Replace("\r", "\\r"); + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs b/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs new file mode 100644 index 000000000..a2b620135 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs @@ -0,0 +1,102 @@ +namespace Socket.Facts.Dotnet; + +internal sealed class ToolOptions { + public const string Usage = """ + Usage: socket-facts-dotnet --records --root [options] [-p:Key=Value ...] + + Options: + --records Records output file (TSV line protocol). Required. + --root Project root to scan (top-level *.sln/*.slnx, else *proj). Required. + --with-files Also emit resolved artifact/source paths. + --include-configs Comma-separated anchored regex patterns for target framework names. + --exclude-configs Comma-separated anchored regex patterns; applied after includes. + --no-restore Skip the in-process restore (use existing restore output). + --restore-timeout-sec Cancel restore after n seconds (default 900). + --verbose Log progress to stderr. + -p:Key=Value MSBuild global property, applied to the WHOLE session + (evaluation, restore, and reading). Also accepts --property:. + """; + + public string RecordsPath = ""; + public string RootDir = ""; + public bool WithFiles; + public string IncludeConfigs = ""; + public string ExcludeConfigs = ""; + public bool NoRestore; + public int RestoreTimeoutSec = 900; + public bool Verbose; + public Dictionary GlobalProperties = new(StringComparer.OrdinalIgnoreCase); + + public static ToolOptions Parse(string[] args) { + var opts = new ToolOptions(); + for (var i = 0; i < args.Length; i += 1) { + var arg = args[i]; + switch (arg) { + case "--records": + opts.RecordsPath = Next(args, ref i, arg); + break; + case "--root": + opts.RootDir = Next(args, ref i, arg); + break; + case "--with-files": + opts.WithFiles = true; + break; + case "--include-configs": + opts.IncludeConfigs = Next(args, ref i, arg); + break; + case "--exclude-configs": + opts.ExcludeConfigs = Next(args, ref i, arg); + break; + case "--no-restore": + opts.NoRestore = true; + break; + case "--restore-timeout-sec": + if (!int.TryParse(Next(args, ref i, arg), out opts.RestoreTimeoutSec) || opts.RestoreTimeoutSec <= 0) { + throw new ArgumentException("--restore-timeout-sec expects a positive integer"); + } + break; + case "--verbose": + opts.Verbose = true; + break; + default: + if (TryParseProperty(arg, out var key, out var value)) { + opts.GlobalProperties[key] = value; + } else { + throw new ArgumentException( + $"Unknown argument `{arg}`. --dotnet-opts accepts MSBuild property tokens only (-p:Key=Value or --property:Key=Value)." + ); + } + break; + } + } + if (string.IsNullOrEmpty(opts.RecordsPath) || string.IsNullOrEmpty(opts.RootDir)) { + throw new ArgumentException("--records and --root are required"); + } + opts.RootDir = Path.GetFullPath(opts.RootDir); + return opts; + } + + private static string Next(string[] args, ref int i, string arg) { + i += 1; + if (i >= args.Length) throw new ArgumentException($"{arg} expects a value"); + return args[i]; + } + + private static bool TryParseProperty(string arg, out string key, out string value) { + key = ""; + value = ""; + string? rest = null; + foreach (var prefix in new[] { "-p:", "/p:", "--property:", "-property:" }) { + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + rest = arg.Substring(prefix.Length); + break; + } + } + if (rest == null) return false; + var eq = rest.IndexOf('='); + if (eq <= 0) return false; + key = rest.Substring(0, eq); + value = rest.Substring(eq + 1); + return true; + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/build-tool.sh b/src/commands/manifest/scripts/dotnet-tool/build-tool.sh new file mode 100644 index 000000000..d658466c9 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/build-tool.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Builds the socket-facts-dotnet tool into ./publish, which the rollup dist +# build copies to dist/manifest-scripts/dotnet-tool. Requires a .NET 8+ SDK. +set -euo pipefail + +cd "$(dirname "$0")" +# Publish into a fresh dir and swap, so stale artifacts never linger in +# publish/ without recursive deletes; the displaced old dir parks in the +# system temp dir, where the OS reclaims it. +staging="$(mktemp -d "${TMPDIR:-/tmp}/socket-facts-dotnet.XXXXXX")" +dotnet publish socket-facts-dotnet.csproj -c Release -o "$staging" --nologo -v quiet +if [ -d publish ]; then + mv publish "$staging.old" +fi +mv "$staging" publish +# Trim non-runtime publish artifacts. +rm -f publish/*.pdb +echo "socket-facts-dotnet tool: $(pwd)/publish/socket-facts-dotnet.dll" diff --git a/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj new file mode 100644 index 000000000..c7e1d1f32 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj @@ -0,0 +1,49 @@ + + + + Exe + + net6.0 + LatestMajor + 12 + enable + enable + socket-facts-dotnet + Socket.Facts.Dotnet + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 66859d183..aa301b58d 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -1,5 +1,7 @@ +import type { PURL_Type } from '../../../utils/ecosystem.mts' + export type AnyPURL = { - type: string + type: PURL_Type namespace?: string | undefined name: string version?: string | undefined @@ -16,7 +18,7 @@ export type SocketFactsSbom = { export type SocketFactsSbomMetadata = { format: 'socket-facts-sbom' - tool: 'gradle' | 'maven' | 'sbt' + tool: 'dotnet' | 'gradle' | 'maven' | 'sbt' toolVersion: string javaVersion?: string | undefined } 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 index edcb5fe8c..be008957f 100644 --- 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 @@ -8,8 +8,8 @@ /** * 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. + * build-root-relative workspace path, the full-coordinate {@code id} key, and the config-pattern + * parsing. */ public final class SocketSupport { private SocketSupport() {} @@ -40,49 +40,23 @@ public static String bareId(String groupId, String artifactId, String version) { } /** - * Translate a config-name glob to a case-sensitive regex. Supports {@code *}, {@code ?}, and - * {@code [...]} character classes: enumerations ({@code [cC]}), ranges ({@code [a-z]}), and - * {@code [!..]}/{@code [^..]} negation. A malformed glob falls back to a literal match, never throws. + * Parse a comma-separated list of PRE-COMPILED anchored regex pattern sources. The CLI compiles + * the user-facing globs in config-glob.mts (the single glob implementation, tested in CI); this + * extension only {@code Pattern.compile()}s what it receives. A pattern that doesn't compile is + * dropped, never thrown: the CLI emits a dialect-portable subset, so this only guards against a + * broken transport. */ - public static Pattern globToRegex(String glob) { - StringBuilder sb = new StringBuilder(); - int i = 0; - int n = glob.length(); - while (i < n) { - char c = glob.charAt(i); - if (c == '*') { sb.append(".*"); i++; } - else if (c == '?') { sb.append('.'); i++; } - else if (c == '[') { - int j = glob.indexOf(']', i + 1); - // Treat as a class only with a non-empty body; else a literal '['. - if (j <= i + 1) { sb.append("\\["); i++; } - else { - String body = glob.substring(i + 1, j); - boolean neg = body.startsWith("!"); - if (neg) body = body.substring(1); - // Only literal chars and '-' ranges are meaningful; neutralize regex-class tricks. - body = body.replace("\\", "\\\\").replace("[", "\\[").replace("&", "\\&"); - sb.append('[').append(neg ? "^" : "").append(body).append(']'); - i = j + 1; - } - } else if ("\\.^$|+(){}]".indexOf(c) >= 0) { - sb.append('\\').append(c); i++; - } else { sb.append(c); i++; } - } - try { - return Pattern.compile(sb.toString()); - } catch (java.util.regex.PatternSyntaxException e) { - return Pattern.compile(Pattern.quote(glob)); - } - } - - /** Parse a comma-separated list of globs into case-sensitive 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)); + if (p.isEmpty()) continue; + try { + out.add(Pattern.compile(p)); + } catch (java.util.regex.PatternSyntaxException ignored) { + // Dropped; see contract above. + } } return out; } diff --git a/src/commands/manifest/scripts/resolution-report-nuget.mts b/src/commands/manifest/scripts/resolution-report-nuget.mts new file mode 100644 index 000000000..afb8fbf72 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-nuget.mts @@ -0,0 +1,90 @@ +import type { + FailureCategory, + ResolutionDialect, +} from './resolution-report-render.mts' + +// NuGet restore: failures come from the assets file's `logs` section, whose +// messages carry NU-prefixed codes, plus the producer's synthetic +// missing-assets-file failure. No variant ambiguity, so every kind blocks. +export function classifyNugetFailure(detail: string): FailureCategory { + const t = (detail || '').toLowerCase() + // Assembly-load/runtime failures inside the tool are environment problems, + // not feed problems — check FIRST: NuGet wraps them in NU1301-style messages + // whose wording would otherwise classify as repository-or-network and send + // users chasing connectivity. showReason on config-problem surfaces the + // real loader message in the summary. + if ( + t.includes('could not load file or assembly') || + t.includes('missingmethodexception') || + t.includes('0x80131040') + ) { + return 'config-problem' + } + if ( + t.includes('nu1301') || + t.includes('nu1302') || + t.includes('nu1303') || + t.includes('nu1304') || + t.includes('unable to load the service index') || + t.includes('401') || + t.includes('403') || + t.includes('unauthorized') || + t.includes('forbidden') || + t.includes('connection refused') || + t.includes('timed out') + ) { + return 'repository-or-network' + } + if ( + t.includes('nu1101') || + t.includes('nu1102') || + t.includes('nu1103') || + t.includes('unable to find package') + ) { + return 'not-found' + } + // Project/framework incompatibilities and a restore that never produced an + // assets file are project-configuration problems, not missing packages. + if ( + t.includes('nu1105') || + t.includes('nu1201') || + t.includes('nu1202') || + t.includes('is not compatible with') || + t.includes('produced no project.assets.json') + ) { + return 'config-problem' + } + return 'other' +} + +export const NUGET_DIALECT: ResolutionDialect = { + label: 'NuGet', + classify: classifyNugetFailure, + configNoun: 'target framework', + excludeConfigsFlag: '--exclude-target-frameworks', + categories: [ + { + key: 'not-found', + header: () => ` Not found on any feed:`, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Feed or network error — ${n} could not reach or authenticate to a package feed:`, + blocking: true, + }, + { + key: 'config-problem', + header: n => ` Project/restore problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other restore 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 index 58b973d30..5714c1508 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -1,6 +1,7 @@ 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 { NUGET_DIALECT } from './resolution-report-nuget.mts' import type { BuildTool } from './build-tool.mts' import type { @@ -37,6 +38,10 @@ export type ResolutionDialect = { label: string classify: (detail: string) => FailureCategory categories: FailureCategorySpec[] + // What a "config" is called in this ecosystem (defaults below); NuGet + // resolves per target framework and its exclude flag is named accordingly. + configNoun?: string | undefined + excludeConfigsFlag?: string | undefined } export type RenderedResolutionReport = { @@ -96,6 +101,8 @@ export function renderResolutionReport( } = {}, ): RenderedResolutionReport { const name = dialect.label + const noun = dialect.configNoun ?? 'configuration' + const excludeFlag = dialect.excludeConfigsFlag ?? '--exclude-configs' const unscannable = opts.unscannable ?? [] const unscannableConfigs = new Set(unscannable.map(u => u.config)) const specOf = new Map(dialect.categories.map(c => [c.key, c])) @@ -172,10 +179,15 @@ export function renderResolutionReport( const out: string[] = [] if (hasBlockingFailures) { if (blockingCount > 0) { + // Failures without config attribution (e.g. restore-level errors) would + // read "in 0 …(s)"; drop the clause instead. + const inConfigs = perDepBlockingConfigs.size + ? ` in ${perDepBlockingConfigs.size} ${noun}(s)` + : '' out.push( opts.ignoreUnresolved - ? `Ignored ${blockingCount} unresolved dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):` - : `Could not resolve ${blockingCount} dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):`, + ? `Ignored ${blockingCount} unresolved dependency(ies)${inConfigs}:` + : `Could not resolve ${blockingCount} dependency(ies)${inConfigs}:`, ) for (const { infos, spec } of blockingGroups) { out.push('') @@ -200,8 +212,8 @@ export function renderResolutionReport( } out.push( opts.ignoreUnresolved - ? `Ignored ${blockingUnscannable.length} configuration(s) that could not be scanned:` - : `Could not scan ${blockingUnscannable.length} configuration(s) (reason from ${name}):`, + ? `Ignored ${blockingUnscannable.length} ${noun}(s) that could not be scanned:` + : `Could not scan ${blockingUnscannable.length} ${noun}(s) (reason from ${name}):`, ) for (const u of blockingUnscannable.slice( 0, @@ -232,7 +244,7 @@ export function renderResolutionReport( out.push(`To proceed, re-run with either:`) out.push(` --ignore-unresolved`) if (blockingFailed.length) { - out.push(` --exclude-configs '${blockingFailed.join(',')}'`) + out.push(` ${excludeFlag} '${blockingFailed.join(',')}'`) } } out.push('') @@ -252,7 +264,7 @@ export function renderResolutionReport( if (nonBlockingUnscannable.length) { const n = new Set(nonBlockingUnscannable.map(u => u.config)).size notices.push( - `Could not scan ${n} configuration(s) — re-run with --verbose for ${name}'s messages.`, + `Could not scan ${n} ${noun}(s) — re-run with --verbose for ${name}'s messages.`, ) } @@ -266,7 +278,7 @@ export function renderResolutionReport( } if (unscannable.length) { detailLines.push('') - detailLines.push(`${name} configurations that could not be scanned:`) + detailLines.push(`${name} ${noun}s that could not be scanned:`) for (const u of unscannable) { detailLines.push('') detailLines.push(` ${u.config}:`) @@ -286,6 +298,8 @@ export function renderResolutionReport( function dialectFor(tool: BuildTool): ResolutionDialect { switch (tool) { + case 'dotnet': + return NUGET_DIALECT case 'gradle': return GRADLE_DIALECT case 'sbt': diff --git a/src/commands/manifest/scripts/resolution-report.mts b/src/commands/manifest/scripts/resolution-report.mts index eb6f645e6..e4e9598bd 100644 --- a/src/commands/manifest/scripts/resolution-report.mts +++ b/src/commands/manifest/scripts/resolution-report.mts @@ -15,5 +15,9 @@ export type UnscannableConfig = { export type ResolutionReport = { failures: ResolutionFailure[] scannedConfigs: string[] + // Which configs each first-party project resolved, for attribution when the + // flat scannedConfigs union spans projects with different configs (common + // for dotnet, where every project picks its own target frameworks). + configsByProject: Array<{ project: string; configs: string[] }> unscannable: UnscannableConfig[] } diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 13544c602..c6889fc9c 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -6,6 +6,7 @@ import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' import { resolveBuildToolBin } from './build-tool.mts' +import { serializeConfigPatterns } from './config-glob.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' @@ -151,6 +152,8 @@ export async function runManifestScript( opts: ManifestScriptOptions, ): Promise { switch (tool) { + case 'dotnet': + return await runDotnet(opts) case 'gradle': return await runGradle(opts) case 'sbt': @@ -171,15 +174,61 @@ function commonProps( if (opts.populateFilesFor) { props.push(`${prefix}socket.populateFilesFor=${opts.populateFilesFor}`) } - if (opts.includeConfigs) { - props.push(`${prefix}socket.includeConfigs=${opts.includeConfigs}`) + // Globs compile to regex pattern sources HERE (config-glob.mts is the single + // glob implementation); the scripts just Pattern.compile what they receive. + const includePatterns = serializeConfigPatterns(opts.includeConfigs) + if (includePatterns) { + props.push(`${prefix}socket.includeConfigs=${includePatterns}`) } - if (opts.excludeConfigs) { - props.push(`${prefix}socket.excludeConfigs=${opts.excludeConfigs}`) + const excludePatterns = serializeConfigPatterns(opts.excludeConfigs) + if (excludePatterns) { + props.push(`${prefix}socket.excludeConfigs=${excludePatterns}`) } return props } +// Missing only in an unbuilt local checkout. Fail loudly: without the tool, +// dotnet facts generation is impossible. +function assertDotnetToolBuilt(dllPath: string): void { + if (existsSync(dllPath)) { + return + } + throw new Error( + `socket-facts-dotnet tool not found at ${dllPath}. It is bundled in the published CLI; for local dev build it with: bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh`, + ) +} + +// The bundled C# tool runs one MSBuild session (evaluate -> restore -> read +// project.assets.json via NuGet's own APIs) under a single global-property +// bag, so user -p: opts apply to resolution and the emitted graph alike. It +// emits the same records protocol as the JVM scripts. +async function runDotnet( + opts: ManifestScriptOptions, +): Promise { + const toolDll = manifestScriptsPath('dotnet-tool', 'socket-facts-dotnet.dll') + assertDotnetToolBuilt(toolDll) + const bin = resolveBuildToolBin('dotnet', opts.projectDir, opts.bin) + return await withTmpDir('socket-dotnet-facts-', async tmp => { + const recordsFile = path.join(tmp, 'records.tsv') + const includePatterns = serializeConfigPatterns(opts.includeConfigs) + const excludePatterns = serializeConfigPatterns(opts.excludeConfigs) + const args = [ + toolDll, + '--records', + recordsFile, + '--root', + opts.projectDir, + ...(opts.withFiles ? ['--with-files'] : []), + ...(includePatterns ? ['--include-configs', includePatterns] : []), + ...(excludePatterns ? ['--exclude-configs', excludePatterns] : []), + ...(opts.stdio === 'inherit' ? ['--verbose'] : []), + ...(opts.toolOpts ?? []), + ] + const out = await runNeverThrow(bin, args, opts) + return await assembleFromRecords(out, recordsFile) + }) +} + async function runGradle( opts: ManifestScriptOptions, ): Promise { diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index d4cd81a27..76af37c33 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,18 +1,32 @@ import { mavenCoordinateKey } from './facts.mts' import type { ResolvedArtifactPaths, SocketFactsSbom } from './facts.mts' +import type { PURL_Type } from '../../../utils/ecosystem.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. +// (pom/BOM, or a nuget package with no runtime assemblies — the fail-closed +// producer guarantees empty never means "missing"); absent → coana degrades +// that vuln to precomputed. +// +// The producer ALWAYS tags `ecosystem` (strict producer); the consumer treats +// a missing tag as `maven` (liberal consumer), so a hand-written or older +// sidecar still parses. Every consumer reads only its own ecosystem's entries. export type ResolvedComponent = { group: string name: string version: string ext: string classifier: string | null - // Classpath entries (jars / first-party output dirs). + // The artifact's purl `type`, carried verbatim as the ecosystem + // discriminator each consumer filters on ('maven' for gradle/sbt/maven, + // 'nuget' for dotnet). It is exactly the facts component's `type` (the + // shared PURL_Type), so no narrowing or re-derivation; coana validates the + // supported set (fail-loud on an unknown ecosystem, never a silent relabel). + ecosystem: PURL_Type + // Classpath entries (jars / first-party output dirs) — for nuget, runtime + // (lib/) assemblies and first-party build outputs. targets: string[] // First-party source roots; [] for external deps. sources: string[] @@ -41,6 +55,7 @@ function addEntry( version: string, ext: string, classifier: string | null, + ecosystem: PURL_Type, ): void { const coordKey = mavenCoordinateKey( group, @@ -52,10 +67,22 @@ function addEntry( if (!coordKey) { return } - let entry = acc.get(coordKey) + // Namespaced by ecosystem so a groupless nuget coordinate can never merge + // with a maven one; the wire format carries the ecosystem tag itself. + const accKey = `${ecosystem}|${coordKey}` + let entry = acc.get(accKey) if (!entry) { - entry = { group, name, version, ext, classifier, targets: [], sources: [] } - acc.set(coordKey, entry) + entry = { + group, + name, + version, + ext, + classifier, + ecosystem, + targets: [], + sources: [], + } + acc.set(accKey, entry) } pushUnique(entry.targets, artifactPaths.targetsByCoord.get(coordKey) ?? []) pushUnique(entry.sources, artifactPaths.sourcesByCoord.get(coordKey) ?? []) @@ -63,7 +90,8 @@ function addEntry( // 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. +// roots are where reachability starts, so the sidecar must carry them. The +// ecosystem is each artifact's own purl `type`, passed through verbatim. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, @@ -78,6 +106,7 @@ export function accumulateSidecar( comp.version ?? '', comp.qualifiers?.['ext'] ?? '', comp.qualifiers?.['classifier'] ?? null, + comp.type, ) } // First-party modules have no ext/classifier. @@ -90,6 +119,7 @@ export function accumulateSidecar( proj.version ?? '', '', null, + proj.type, ) } } diff --git a/src/commands/manifest/scripts/sidecar.test.mts b/src/commands/manifest/scripts/sidecar.test.mts index 86a74ff2e..de695ba15 100644 --- a/src/commands/manifest/scripts/sidecar.test.mts +++ b/src/commands/manifest/scripts/sidecar.test.mts @@ -70,6 +70,7 @@ describe('compute-artifacts sidecar', () => { version: 'da517db', ext: 'jar', classifier: null, + ecosystem: 'maven', targets: ['/abs/lib.jar'], sources: ['/abs/lib/src/main/java'], }, @@ -152,6 +153,7 @@ describe('compute-artifacts sidecar', () => { version: '1.0', ext: '', classifier: null, + ecosystem: 'maven', targets: ['/abs/app/build/classes'], sources: ['/abs/app/src/main/java'], }, @@ -170,3 +172,109 @@ describe('compute-artifacts sidecar', () => { expect(resolved[0]!.targets).toEqual(['/root-a/a.jar', '/root-b/a.jar']) }) }) + +describe('nuget (dotnet) sidecar entries', () => { + it('tags dotnet facts entries with ecosystem and keeps them separate from maven', () => { + const acc: SidecarAccumulator = new Map() + + const dotnetFacts = { + metadata: { + format: 'socket-facts-sbom' as const, + tool: 'dotnet' as const, + toolVersion: '8.0.414', + }, + components: [ + { + type: 'nuget', + name: 'Newtonsoft.Json', + version: '13.0.3', + id: 'Newtonsoft.Json:13.0.3', + direct: true, + }, + ], + projects: [ + { + type: 'nuget', + name: 'MyApp', + version: '1.0.0', + subprojectDir: 'MyApp', + dependencies: ['Newtonsoft.Json:13.0.3'], + resolvedAs: [], + }, + ], + } + const dotnetPaths = { + targetsByCoord: new Map([ + [ + 'Newtonsoft.Json:13.0.3', + ['/nuget/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll'], + ], + ['MyApp:1.0.0', ['/repo/MyApp/bin/Debug/net8.0/MyApp.dll']], + ]), + targetsByGav: new Map(), + sourcesByCoord: new Map([['MyApp:1.0.0', ['/repo/MyApp']]]), + coords: new Set(['Newtonsoft.Json:13.0.3', 'MyApp:1.0.0']), + } + accumulateSidecar(acc, dotnetFacts, dotnetPaths) + + // A maven artifact that would key identically without the ecosystem + // namespace (groupless, ext-less) must not merge with the nuget entry. + const mavenFacts = { + metadata: { + format: 'socket-facts-sbom' as const, + tool: 'gradle' as const, + toolVersion: '8.0', + }, + components: [ + { + type: 'maven', + namespace: '', + name: 'Newtonsoft.Json', + version: '13.0.3', + id: 'Newtonsoft.Json:13.0.3', + }, + ], + } + const mavenPaths = { + targetsByCoord: new Map([['Newtonsoft.Json:13.0.3', ['/m2/weird.jar']]]), + targetsByGav: new Map(), + sourcesByCoord: new Map(), + coords: new Set(['Newtonsoft.Json:13.0.3']), + } + accumulateSidecar(acc, mavenFacts, mavenPaths) + + const resolved = serializeSidecar(acc) + expect(resolved).toHaveLength(3) + + const nugetPkg = resolved.find( + c => c.ecosystem === 'nuget' && c.name === 'Newtonsoft.Json', + ) + expect(nugetPkg).toMatchObject({ + group: '', + version: '13.0.3', + ext: '', + classifier: null, + targets: ['/nuget/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll'], + sources: [], + }) + + const nugetProject = resolved.find( + c => c.ecosystem === 'nuget' && c.name === 'MyApp', + ) + expect(nugetProject).toMatchObject({ + targets: ['/repo/MyApp/bin/Debug/net8.0/MyApp.dll'], + sources: ['/repo/MyApp'], + }) + + // The maven entry is explicitly tagged 'maven' (strict producer) and keyed + // separately from the same-coordinate nuget entry — no cross-ecosystem merge. + const mavenPkg = resolved.find( + c => c.ecosystem === 'maven' && c.name === 'Newtonsoft.Json', + ) + expect(mavenPkg).toMatchObject({ + ecosystem: 'maven', + name: 'Newtonsoft.Json', + targets: ['/m2/weird.jar'], + }) + }) +}) diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index bb9269d11..cb0f53da1 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -286,49 +286,21 @@ allprojects { project -> n.contains('classpath') || n == 'compile' || n == 'runtime' } - // Config-name glob to a case-SENSITIVE regex (matching is documented as case-sensitive). - // Supports `*`, `?`, and `[...]` character classes: enumerations (`[cC]`), ranges (`[a-z]`), - // and `[!..]`/`[^..]` negation. A malformed glob falls back to a literal match, never throws. - def globToRegex = { String glob -> - def sb = new StringBuilder() - int i = 0 - int n = glob.length() - while (i < n) { - def ch = glob[i] - if (ch == '*') { sb << '.*'; i++ } - else if (ch == '?') { sb << '.'; i++ } - else if (ch == '[') { - int j = glob.indexOf(']', i + 1) - // Treat as a class only with a non-empty body; else a literal `[`. - if (j <= i + 1) { sb << '\\['; i++ } - else { - def body = glob.substring(i + 1, j) - boolean neg = body.startsWith('!') - if (neg) { body = body.substring(1) } - // Only literal chars and `-` ranges are meaningful; neutralize regex-class tricks. - body = body.replace('\\', '\\\\').replace('[', '\\[').replace('&', '\\&') - sb << '[' << (neg ? '^' : '') << body << ']' - i = j + 1 - } - } - else if ('.\\^$|+(){}]'.contains(ch)) { sb << '\\' << ch; i++ } - else { sb << ch; i++ } - } - try { - java.util.regex.Pattern.compile(sb.toString()) - } catch (java.util.regex.PatternSyntaxException e) { - java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(glob)) - } - } - - // `-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. + // `-Psocket.includeConfigs`/`-Psocket.excludeConfigs`: comma-separated, PRE-COMPILED anchored + // regex pattern sources. The CLI compiles the user-facing globs in config-glob.mts (the single + // glob implementation, tested in CI); this script only Pattern.compile()s what it receives. + // 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) + if (!p.isEmpty()) { + // A pattern that doesn't compile is dropped, never thrown: the CLI emits a + // dialect-portable subset, so this only guards against a broken transport. + try { out << java.util.regex.Pattern.compile(p) } + catch (java.util.regex.PatternSyntaxException ignored) { } + } } } out diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 5d1f518ac..6c8813fa9 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -319,11 +319,19 @@ object SocketFactsPlugin extends AutoPlugin { // ---- config selection --------------------------------------------------- // With no includes the default is ALL configurations (captures build/tooling deps). + // `-Dsocket.includeConfigs`/`-Dsocket.excludeConfigs`: comma-separated, PRE-COMPILED anchored + // regex pattern sources. The CLI compiles the user-facing globs in config-glob.mts (the single + // glob implementation, tested in CI); this plugin only Pattern.compile()s what it receives. A + // pattern that doesn't compile is dropped, never thrown: the CLI emits a dialect-portable + // subset, so this only guards against a broken transport. 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) + s.split(",").map(_.trim).filter(_.nonEmpty).toList.flatMap { p => + try List(java.util.regex.Pattern.compile(p)) + catch { case _: java.util.regex.PatternSyntaxException => Nil } + } case _ => Nil } val includes = parse("socket.includeConfigs") @@ -336,41 +344,6 @@ object SocketFactsPlugin extends AutoPlugin { } } - // Case-SENSITIVE (matching is documented as case-sensitive). Supports `*`, `?`, and `[...]` - // character classes: enumerations (`[cC]`), ranges (`[a-z]`), and `[!..]`/`[^..]` negation. A - // malformed glob falls back to a literal match, never throws. - private def globToRegex(glob: String): java.util.regex.Pattern = { - val sb = new StringBuilder - var i = 0 - val n = glob.length - while (i < n) { - val c = glob.charAt(i) - if (c == '*') { sb.append(".*"); i += 1 } - else if (c == '?') { sb.append('.'); i += 1 } - else if (c == '[') { - val j = glob.indexOf(']', i + 1) - // Treat as a class only with a non-empty body; else a literal `[`. - if (j <= i + 1) { sb.append("\\["); i += 1 } - else { - var body = glob.substring(i + 1, j) - val neg = body.startsWith("!") - if (neg) body = body.substring(1) - // Only literal chars and `-` ranges are meaningful; neutralize regex-class tricks. - body = body.replace("\\", "\\\\").replace("[", "\\[").replace("&", "\\&") - sb.append('[').append(if (neg) "^" else "").append(body).append(']') - i = j + 1 - } - } else if ("\\.^$|+(){}]".indexOf(c.toInt) >= 0) { - sb.append('\\').append(c); i += 1 - } else { sb.append(c); i += 1 } - } - try java.util.regex.Pattern.compile(sb.toString) - catch { - case _: java.util.regex.PatternSyntaxException => - java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(glob)) - } - } - // 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 diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index 1e13a0f90..a8d46357e 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -58,6 +58,11 @@ export async function setupManifestConfig( value: 'conda', description: `Generate ${REQUIREMENTS_TXT} from a Conda environment.yml`, }, + { + name: '.NET (dotnet)'.padEnd(30, ' '), + value: 'dotnet', + description: 'Generate a Socket facts file through the dotnet SDK', + }, { name: 'Gradle'.padEnd(30, ' '), value: 'gradle', @@ -145,6 +150,13 @@ export async function setupManifestConfig( result = await setupConda(sockJson.defaults.manifest.conda) break } + case 'dotnet': { + if (!sockJson.defaults.manifest.dotnet) { + sockJson.defaults.manifest.dotnet = {} + } + result = await setupDotnet(sockJson.defaults.manifest.dotnet) + break + } case 'gradle': { if (!sockJson.defaults.manifest.gradle) { sockJson.defaults.manifest.gradle = {} @@ -269,6 +281,85 @@ async function setupConda( return notCanceled() } +async function setupDotnet( + config: NonNullable< + NonNullable['manifest']>['dotnet'] + >, +): Promise> { + const bin = await askForBin(config.bin || 'dotnet') + if (bin === undefined) { + return canceledByUser() + } else if (bin) { + config.bin = bin + } else { + delete config.bin + } + + const opts = await input({ + message: + '(--dotnet-opts) Enter MSBuild property tokens to apply to the whole facts session (e.g. -p:Configuration=Release)', + default: config.dotnetOpts || '', + required: false, + }) + if (opts === undefined) { + return canceledByUser() + } else if (opts) { + config.dotnetOpts = opts + } else { + delete config.dotnetOpts + } + + const targetFrameworks = await input({ + message: + '(--target-frameworks) Comma-separated target-framework globs to resolve, e.g. net8.0 or net* (blank = all restored target frameworks)', + default: config.targetFrameworks || '', + required: false, + }) + if (targetFrameworks === undefined) { + return canceledByUser() + } else if (targetFrameworks) { + config.targetFrameworks = targetFrameworks + } else { + delete config.targetFrameworks + } + + const excludeTargetFrameworks = await input({ + message: + '(--exclude-target-frameworks) Comma-separated target-framework globs to skip (blank = none)', + default: config.excludeTargetFrameworks || '', + required: false, + }) + if (excludeTargetFrameworks === undefined) { + return canceledByUser() + } else if (excludeTargetFrameworks) { + config.excludeTargetFrameworks = excludeTargetFrameworks + } else { + delete config.excludeTargetFrameworks + } + + const ignoreUnresolved = await askForIgnoreUnresolvedFlag( + config.ignoreUnresolved, + ) + if (ignoreUnresolved === undefined) { + return canceledByUser() + } else if (ignoreUnresolved === 'yes' || ignoreUnresolved === 'no') { + config.ignoreUnresolved = ignoreUnresolved === 'yes' + } else { + delete config.ignoreUnresolved + } + + const verbose = await askForVerboseFlag(config.verbose) + if (verbose === undefined) { + return canceledByUser() + } else if (verbose === 'yes' || verbose === 'no') { + config.verbose = verbose === 'yes' + } else { + delete config.verbose + } + + return notCanceled() +} + async function setupGradle( config: NonNullable< NonNullable['manifest']>['gradle'] diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 3cbbfee23..4cd1369cb 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -59,6 +59,15 @@ export interface SocketJson { target?: string | undefined verbose?: boolean | undefined } + dotnet?: { + disabled?: boolean | undefined + bin?: string | undefined + dotnetOpts?: string | undefined + excludeTargetFrameworks?: string | undefined + ignoreUnresolved?: boolean | undefined + targetFrameworks?: string | undefined + verbose?: boolean | undefined + } gradle?: { disabled?: boolean | undefined bin?: string | undefined