From 8bcfcc4ba8d44a3de0ecc7e54d835cbc16e257ea Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Sat, 31 Oct 2026 18:41:20 +0100 Subject: [PATCH 1/4] feat(manifest): generate Socket facts for .NET solutions and projects `socket manifest dotnet` writes `.socket.facts.json` per solution, plus one per project no solution builds, from the user's own `dotnet restore`: a bundled C# tool evaluates projects in-process on the selected SDK (MSBuildLocator) and reads project.assets.json with NuGet's own API. PackageReference (central package management, lock files, multi-targeting, runtime targets) and packages.config are supported, for C#, F# and VB. --auto-manifest, `socket manifest auto`, dynamic SBOM inference and the setup wizards detect .NET builds and skip them with a warning where no .NET SDK runs. --reach sidecars carry each project's compiler inputs per target framework from a design-time build, and scan and fix pass Coana --use-only-socket-facts-artifacts when NuGet facts are present. Resolution, restore and design-time build failures fail closed. --- .config/rollup.dist.config.mjs | 14 + .github/workflows/publish-npm.yml | 6 + CHANGELOG.md | 4 + package.json | 1 + src/commands/fix/coana-fix.mts | 7 +- src/commands/fix/generated-socket-facts.mts | 6 + src/commands/manifest/cmd-manifest-dotnet.mts | 218 +++++ src/commands/manifest/cmd-manifest.mts | 2 + src/commands/manifest/cmd-manifest.test.mts | 1 + .../manifest/convert-dotnet-to-facts.mts | 107 +++ .../manifest/detect-manifest-actions.mts | 31 +- .../manifest/discover-manifest-roots.mts | 90 +- .../manifest/generate-recursive-manifests.mts | 36 +- .../manifest/generate_auto_manifest.mts | 26 + src/commands/manifest/run-manifest-facts.mts | 69 +- src/commands/manifest/scripts/assemble.mts | 277 ++++++- src/commands/manifest/scripts/build-tool.mts | 38 +- .../manifest/scripts/dotnet-tool/.gitignore | 3 + .../scripts/dotnet-tool/FactsRunner.cs | 339 ++++++++ .../manifest/scripts/dotnet-tool/NuGetShim.cs | 23 + .../manifest/scripts/dotnet-tool/Patterns.cs | 135 +++ .../manifest/scripts/dotnet-tool/Program.cs | 23 + .../manifest/scripts/dotnet-tool/Reader.cs | 779 ++++++++++++++++++ .../scripts/dotnet-tool/RecordsWriter.cs | 81 ++ .../manifest/scripts/dotnet-tool/Restorer.cs | 91 ++ .../scripts/dotnet-tool/SolutionContext.cs | 103 +++ .../scripts/dotnet-tool/ToolOptions.cs | 157 ++++ .../scripts/dotnet-tool/build-tool.sh | 16 + .../scripts/dotnet-tool/packages.lock.json | 279 +++++++ .../dotnet-tool/socket-facts-dotnet.csproj | 44 + src/commands/manifest/scripts/facts.mts | 43 +- src/commands/manifest/scripts/records.mts | 78 +- .../scripts/resolution-report-nuget.mts | 77 ++ .../scripts/resolution-report-render.mts | 12 +- src/commands/manifest/scripts/run.mts | 201 ++++- src/commands/manifest/scripts/sidecar.mts | 33 +- .../manifest/setup-manifest-config.mts | 83 +- .../setup-recursive-manifest-config.mts | 34 +- src/commands/scan/handle-create-new-scan.mts | 1 + .../scan/perform-reachability-analysis.mts | 7 +- src/utils/socket-json.mts | 22 + 41 files changed, 3470 insertions(+), 127 deletions(-) create mode 100644 src/commands/manifest/cmd-manifest-dotnet.mts create mode 100644 src/commands/manifest/convert-dotnet-to-facts.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/NuGetShim.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/Patterns.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/Program.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/Reader.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/Restorer.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs create mode 100644 src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs create mode 100755 src/commands/manifest/scripts/dotnet-tool/build-tool.sh create mode 100644 src/commands/manifest/scripts/dotnet-tool/packages.lock.json 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 e124df240..bfd2f751e 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -131,6 +131,20 @@ async function copyManifestScripts() { path.join(destDir, 'socket-workspaces.plugin.scala'), ), ]) + // The dotnet tool's publish output (scripts/dotnet-tool/build-tool.sh), + // like the Maven jar absent from a fresh checkout. + const dotnetToolDir = path.join(srcDir, 'dotnet-tool', 'publish') + if (existsSync(path.join(dotnetToolDir, 'socket-facts-dotnet.dll'))) { + await fs.cp(dotnetToolDir, path.join(destDir, 'dotnet-tool'), { + recursive: true, + }) + } else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) { + throw new Error( + 'dotnet manifest tool not found at ' + + dotnetToolDir + + ' for a published build. Build it first: pnpm run build:dotnet-tool', + ) + } const jarPath = path.join( srcDir, 'maven-extension', diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 32416a7ca..246ab618f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -386,6 +386,12 @@ jobs: fi bash src/commands/manifest/scripts/maven-extension/build-jar.sh + # Same for the dotnet manifest tool: built here, bundled into + # dist/manifest-scripts, never committed. The runner image's preinstalled + # .NET SDK builds it (actions/setup-dotnet is not on the org allowlist). + - name: Build dotnet manifest tool + run: bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh + # PACK ONCE, PUBLISH THOSE BYTES. Each variant is built, packed to a # tarball, and smoke-tested from that tarball; the three tarballs are # uploaded as an artifact and the publish job stages those exact files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bb3b26b..a80629a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `socket manifest dotnet` [beta] generates Socket facts files for .NET solutions and projects from your own `dotnet restore`, covering PackageReference (central package management and lock files included) and packages.config projects. +- `socket manifest auto`, `socket manifest setup` and `--auto-manifest` now detect .NET solutions and projects, and skip them with a warning where no .NET SDK is installed. + ### Changed - Modules outside the scan root can no longer be excluded with `--exclude-paths`; `**/x` used to match a `../x` module by accident. Single-root runs whose Maven or Gradle options relocate the build root (`-f`, `-p`) now match patterns relative to the current directory, as documented. diff --git a/package.json b/package.json index ae08017bd..c9d16df63 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,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/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 8df4de75b..30bbc92b8 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -54,6 +54,7 @@ import { } from '../../utils/github.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { setupSdk } from '../../utils/sdk.mts' +import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts' @@ -392,7 +393,7 @@ async function coanaFixWithFacts( if (!silence) { spinner?.stop() logger.info( - 'Generating Socket facts for Gradle, sbt and Maven builds ...', + 'Generating Socket facts for .NET, Gradle, sbt and Maven builds ...', ) } factsSlot.generated = await generateSocketFactsForFix({ @@ -412,7 +413,9 @@ async function coanaFixWithFacts( // Discovery only needs which artifacts the facts files resolve. Applying // fixes reads each project's tree; a Coana predating facts trees can only // tell from the sidecar's classpaths which projects resolve an artifact. - const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : [] + const discoveryFlags = factsSlot + ? useOnlySocketFactsArtifactsFlags(factsSlot.generated?.sidecar) + : [] const factsFlags = [ ...discoveryFlags, ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index e2e3f658e..a04826222 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -3,10 +3,13 @@ import path from 'node:path' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' +import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' + export type GeneratedSocketFacts = { paths: string[] // The facts files' per-project classpaths, outside the repository. sidecarFile: string | undefined + sidecar: ResolvedPathsSidecar | undefined remove: () => Promise restore: () => Promise } @@ -54,6 +57,7 @@ export async function generateSocketFactsForFix({ } let paths: string[] let sidecarFile: string | undefined + let sidecar: ResolvedPathsSidecar | undefined try { const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ cwd, @@ -63,6 +67,7 @@ export async function generateSocketFactsForFix({ withFiles: false, }) paths = factsPaths.map(p => path.resolve(cwd, p)) + sidecar = resolvedPathsSidecar if (resolvedPathsSidecar) { sidecarFile = path.join(tmpDir, 'sidecar.json') await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) @@ -79,6 +84,7 @@ export async function generateSocketFactsForFix({ ] return { paths, + sidecar, sidecarFile, remove: () => reset(paths), restore: () => restoreAll(backups), diff --git a/src/commands/manifest/cmd-manifest-dotnet.mts b/src/commands/manifest/cmd-manifest-dotnet.mts new file mode 100644 index 000000000..e6b6055eb --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dotnet.mts @@ -0,0 +1,218 @@ +import { statSync } from 'node:fs' +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 { excludePathsFlag } from './manifest-flags.mts' +import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import constants, { SOCKET_JSON } from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { cmdFlagValueToArray } from '../../utils/cmd.mts' +import { getOutputKind } from '../../utils/get-output-kind.mts' +import { meowOrExit } from '../../utils/meow-with-subcommands.mts' +import { getFlagListOutput } from '../../utils/output-formatting.mts' +import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' +import { assertValidExcludePaths } from '../scan/exclude-paths.mts' + +import type { + CliCommandConfig, + CliCommandContext, +} from '../../utils/meow-with-subcommands.mts' + +const config: CliCommandConfig = { + commandName: 'dotnet', + description: + '[beta] Generate Socket facts files from .NET solutions and projects', + hidden: false, + flags: { + ...commonFlags, + bin: { + type: 'string', + description: + 'Location of the dotnet binary to use, default: dotnet on PATH', + }, + dotnetOpts: { + type: 'string', + description: + 'Additional options to pass on to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`. MSBuild properties among them also apply to project evaluation', + }, + includeConfigs: { + type: 'string', + description: + 'Comma-separated glob patterns matched against target frameworks (case-sensitive; `*`, `?`, and `[...]` wildcards). Only target frameworks matching at least one pattern are read, e.g. `net8.0`. A runtime-specific target (`net8.0/win-x64`) also matches its framework. Default: every target framework', + }, + excludeConfigs: { + type: 'string', + description: + 'Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs)', + }, + ...excludePathsFlag, + ignoreUnresolved: { + type: 'boolean', + description: + 'Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)', + }, + verbose: { + type: 'boolean', + description: 'Print debug messages', + }, + }, + help: (command, config) => ` + Usage + $ ${command} [options] [CWD=.|FILE] + + Options + ${getFlagListOutput(config.flags)} + + Emits a \`.socket.facts.json\` per solution or project describing its + resolved NuGet dependency graph. Given a directory, it builds every + solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top + level, solutions first, skipping projects a solution already covers; given + a file, just that one (a .slnf solution filter too). + + Dependencies come from your own \`dotnet restore\` (same SDK, global.json, + nuget.config and credential providers), so a build that restores for you + restores here. PackageReference projects (central package management and + lock files included) and packages.config projects are supported. An + unresolved dependency is a fatal error unless --ignore-unresolved. + + Support is beta. Please report issues or give us feedback on what's missing. + + Examples + + $ ${command} . + $ ${command} ./App.sln + $ ${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) + + const wasValidInput = checkCommandInput(outputKind, { + nook: true, + test: cli.input.length <= 1, + message: 'Can only accept one DIR or FILE (make sure to escape spaces!)', + fail: 'received ' + cli.input.length, + }) + if (!wasValidInput) { + return + } + + const target = path.resolve(process.cwd(), cli.input[0] ?? '.') + let isFile = false + try { + isFile = statSync(target).isFile() + } catch {} + const cwd = isFile ? path.dirname(target) : target + const entryFile = isFile ? path.basename(target) : undefined + + const sockJson = readOrDefaultSocketJson(cwd) + const defaults = sockJson.defaults?.manifest?.dotnet + + debugFn('inspect', `override: ${SOCKET_JSON} dotnet`, defaults) + + let { + bin, + dotnetOpts, + excludeConfigs, + ignoreUnresolved, + includeConfigs, + verbose, + } = cli.flags + + function useDefault(flag: string, value: T): T { + logger.info(`Using default --${flag} from ${SOCKET_JSON}:`, value) + return value + } + if (!bin) { + bin = defaults?.bin + ? useDefault('bin', defaults.bin) + : resolveBuildToolBin('dotnet', cwd) + } + if (!dotnetOpts) { + dotnetOpts = defaults?.dotnetOpts + ? useDefault('dotnet-opts', defaults.dotnetOpts) + : '' + } + if (includeConfigs === undefined) { + includeConfigs = defaults?.includeConfigs + ? useDefault('include-configs', defaults.includeConfigs) + : '' + } + if (excludeConfigs === undefined) { + excludeConfigs = defaults?.excludeConfigs + ? useDefault('exclude-configs', defaults.excludeConfigs) + : '' + } + if (ignoreUnresolved === undefined) { + ignoreUnresolved = + defaults?.ignoreUnresolved !== undefined + ? useDefault('ignore-unresolved', defaults.ignoreUnresolved) + : false + } + if (verbose === undefined) { + verbose = + defaults?.verbose !== undefined + ? useDefault('verbose', defaults.verbose) + : false + } + + if (verbose) { + logger.group('- ', parentName, config.commandName, ':') + logger.group('- flags:', cli.flags) + logger.groupEnd() + logger.log('- input:', cli.input) + logger.groupEnd() + logger.group() + logger.info('- cwd:', cwd) + logger.info('- entry:', entryFile ?? '(every solution and project)') + logger.info('- dotnet bin:', bin) + logger.groupEnd() + } + + if (dryRun) { + logger.log(constants.DRY_RUN_BAILING_NOW) + return + } + + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertValidExcludePaths(excludePaths) + + await convertDotnetToFacts({ + bin: String(bin), + cwd, + dotnetOpts: parseBuildToolOpts(String(dotnetOpts || '')), + entryFile, + excludeConfigs: String(excludeConfigs || ''), + excludePaths, + ignoreUnresolved: Boolean(ignoreUnresolved), + includeConfigs: String(includeConfigs || ''), + verbose: Boolean(verbose), + }) +} diff --git a/src/commands/manifest/cmd-manifest.mts b/src/commands/manifest/cmd-manifest.mts index fa94fe1b0..448aaea6d 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 { cmdManifestDynamicSbomInference } from './cmd-manifest-dynamic-sbom-inference.mts' import { cmdManifestGradle } from './cmd-manifest-gradle.mts' import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts' @@ -74,6 +75,7 @@ async function run( bazel: cmdManifestBazel, cdxgen: cmdManifestCdxgen, conda: cmdManifestConda, + dotnet: cmdManifestDotnet, 'dynamic-sbom-inference': cmdManifestDynamicSbomInference, gradle: cmdManifestGradle, kotlin: cmdManifestKotlin, diff --git a/src/commands/manifest/cmd-manifest.test.mts b/src/commands/manifest/cmd-manifest.test.mts index 124258616..05c276c35 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 Socket facts files from .NET solutions and projects dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each 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 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..0956cf03e --- /dev/null +++ b/src/commands/manifest/convert-dotnet-to-facts.mts @@ -0,0 +1,107 @@ +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { findDotnetEntries } from './detect-manifest-actions.mts' +import { + addCoveredBuildFiles, + isBuildRootCovered, + isSolutionCovered, + realpathOrResolved, +} from './discover-manifest-roots.mts' +import { runManifestFacts } from './run-manifest-facts.mts' + +import type { RunManifestFactsOutcome } from './run-manifest-facts.mts' +import type { BuildOptionHints } from './scripts/resolution-report-render.mts' +import type { SidecarAccumulator } from './scripts/sidecar.mts' + +// Generates `.socket.facts.json` for the given solution or project +// file, or for every solution and project at the top of `cwd`. A project a +// solution there already covers is not built again. +export async function convertDotnetToFacts({ + bin, + cwd, + dotnetOpts, + entryFile, + excludeConfigs, + excludePaths, + hints, + ignoreUnresolved, + includeConfigs, + sidecarAcc, + verbose, + withFiles, +}: { + bin: string + cwd: string + dotnetOpts: string[] + // Builds only this file (relative to cwd) when set. + entryFile?: string | undefined + excludeConfigs: string + excludePaths?: string[] | undefined + hints?: BuildOptionHints | undefined + ignoreUnresolved: boolean + includeConfigs: string + sidecarAcc?: SidecarAccumulator | undefined + verbose: boolean + withFiles?: boolean | undefined +}): Promise { + const entries = entryFile ? [entryFile] : findDotnetEntries(cwd) + if (!entries.length) { + process.exitCode = 1 + logger.fail( + `No .NET solution or project file found at \`${cwd}\` (expected a .sln, .slnx, .csproj, .fsproj or .vbproj file).`, + ) + return null + } + const dir = await realpathOrResolved(cwd) + const covered = new Set() + let last: RunManifestFactsOutcome + for (const entry of entries) { + const root = { dir, entryFile: entry } + if ( + isBuildRootCovered(covered, 'dotnet', root) || + // eslint-disable-next-line no-await-in-loop + (await isSolutionCovered(covered, root, { + bin, + excludePaths, + toolOpts: dotnetOpts, + })) + ) { + if (verbose) { + logger.info(`Skipping \`${entry}\`: earlier builds cover its projects.`) + } + continue + } + // eslint-disable-next-line no-await-in-loop + const outcome = await runManifestFacts({ + bin, + buildOpts: dotnetOpts, + cwd, + ecosystem: 'dotnet', + entryFile: entry, + excludeConfigs, + excludePaths, + hints, + ignoreUnresolved, + includeConfigs, + sidecarAcc, + verbose, + withFiles, + }) + if (outcome === null) { + return null + } + if (outcome) { + // eslint-disable-next-line no-await-in-loop + await addCoveredBuildFiles( + covered, + 'dotnet', + path.dirname(outcome.factsPath), + outcome.projects, + ) + last = outcome + } + } + return last +} diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts index 4198e1729..5e709768f 100644 --- a/src/commands/manifest/detect-manifest-actions.mts +++ b/src/commands/manifest/detect-manifest-actions.mts @@ -1,11 +1,12 @@ // 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' +import { compareEntryFiles, isBuildFile } from './discover-manifest-roots.mts' import { ENVIRONMENT_YAML, ENVIRONMENT_YML, @@ -21,6 +22,7 @@ export interface GeneratableManifests { conda: boolean // The Conda file found at cwd, empty when there is none. condaFile: string + dotnet: boolean gradle: boolean maven: boolean sbt: boolean @@ -37,6 +39,21 @@ export function findCondaFile(cwd: string): string { return '' } +// The solutions and projects at the top of `cwd`, in the order builds there +// run. +export function findDotnetEntries(cwd: string): string[] { + let entries + try { + entries = readdirSync(cwd, { withFileTypes: true }) + } catch { + return [] + } + return entries + .filter(e => e.isFile() && isBuildFile('dotnet', e.name)) + .map(e => e.name) + .sort(compareEntryFiles) +} + 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. @@ -49,6 +66,7 @@ export async function detectManifestActions( count: 0, conda: false, condaFile: '', + dotnet: false, gradle: false, maven: false, sbt: false, @@ -110,6 +128,17 @@ export async function detectManifestActions( output.count += 1 } + if (sockJson?.defaults?.manifest?.dotnet?.disabled) { + debugLog( + 'notice', + `[DEBUG] - dotnet auto-detection is disabled in ${SOCKET_JSON}`, + ) + } else if (findDotnetEntries(cwd).length) { + debugLog('notice', '[DEBUG] - Detected a .NET solution or project file') + output.dotnet = true + output.count += 1 + } + if (sockJson?.defaults?.manifest?.conda?.disabled) { debugLog( 'notice', diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index 1b7f67555..bbaf1babe 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -3,17 +3,21 @@ import path from 'node:path' import micromatch from 'micromatch' -import { BUILD_FILES } from './scripts/build-tool.mts' +import { logger } from '@socketsecurity/registry/lib/logger' + +import { BUILD_FILES, isDotnetSolution } from './scripts/build-tool.mts' +import { listDotnetEntryProjects } from './scripts/run.mts' import { realpathOrResolved } from '../../utils/fs.mts' import { globWithGitIgnore } from '../../utils/glob.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { SocketFactsSbomProject } from './scripts/facts.mts' +import type { ManifestScriptOptions } from './scripts/run.mts' import type { SocketJson } from '../../utils/socket-json.mts' import type { SocketYml } from '@socketsecurity/config' -const BUILD_TOOLS: BuildTool[] = ['gradle', 'maven', 'sbt'] +const BUILD_TOOLS: BuildTool[] = ['dotnet', 'gradle', 'maven', 'sbt'] const BUILD_FILE_MATCHERS = new Map( BUILD_TOOLS.map(tool => { @@ -35,11 +39,25 @@ export function buildRootPath(root: BuildRoot): string { return path.join(root.dir, root.entryFile ?? '') } +export function isBuildFile(tool: BuildTool, basename: string): boolean { + return !!BUILD_FILE_MATCHERS.get(tool)?.(basename) +} + function buildFileTool( tools: readonly BuildTool[], basename: string, ): BuildTool | undefined { - return tools.find(tool => BUILD_FILE_MATCHERS.get(tool)?.(basename)) + return tools.find(tool => isBuildFile(tool, basename)) +} + +function solutionsFirst(file: string): number { + return isDotnetSolution(file) ? 0 : 1 +} + +// Within one directory: solutions before projects (a solution covers the +// projects it lists), then by name. +export function compareEntryFiles(a: string, b: string): number { + return solutionsFirst(a) - solutionsFirst(b) || compareStrings(a, b) } // A disabled ecosystem is skipped entirely by the glob scan below, so a @@ -67,27 +85,35 @@ export function withoutDisabledFlags(sockJson: SocketJson): SocketJson { export { realpathOrResolved } +// Coverage compares files by their realpath-resolved directory. +async function realFilePath(file: string): Promise { + return path.join( + await realpathOrResolved(path.dirname(file)), + path.basename(file), + ) +} + // Gradle reports a project's build file even when absent, which covers // nothing. A sibling outside the build root is independently locatable. export async function addCoveredBuildFiles( covered: Set, + tool: BuildTool, buildRoot: string, projects: readonly SocketFactsSbomProject[], ): Promise { + const buildFiles = BUILD_FILES[tool] + const anywhere = buildFiles.byFile && !!buildFiles.projectsResolveAlone const realBuildRoot = await realpathOrResolved(buildRoot) const files = await Promise.all( projects .flatMap(project => project.manifestFiles ?? []) - .map(async ({ file }) => { - const resolved = path.resolve(buildRoot, file) - return path.join( - await realpathOrResolved(path.dirname(resolved)), - path.basename(resolved), - ) - }), + .map(({ file }) => realFilePath(path.resolve(buildRoot, file))), ) for (const file of files) { - if (file.startsWith(`${realBuildRoot}${path.sep}`) && existsSync(file)) { + if ( + (anywhere || file.startsWith(`${realBuildRoot}${path.sep}`)) && + existsSync(file) + ) { covered.add(file) } } @@ -106,6 +132,46 @@ export function isBuildRootCovered( return files.names.some(name => covered.has(path.join(root.dir, name))) } +// A dotnet solution is covered once every project it builds is, or, when the +// SDK cannot read it, by a same-named solution in the other format beside it +// (`dotnet sln migrate` writes App.slnx next to App.sln) that it can read. +export async function isSolutionCovered( + covered: ReadonlySet, + root: BuildRoot, + opts: Pick< + ManifestScriptOptions, + 'bin' | 'excludeBase' | 'excludePaths' | 'toolOpts' + >, + listMembers: typeof listDotnetEntryProjects = listDotnetEntryProjects, +): Promise { + const { entryFile } = root + if (!entryFile || !isDotnetSolution(entryFile)) { + return false + } + const twin = entryFile.replace(/\.slnx?$/i, ext => + ext.toLowerCase() === '.sln' ? '.slnx' : '.sln', + ) + const hasTwin = existsSync(path.join(root.dir, twin)) + if (!covered.size && !hasTwin) { + return false + } + const list = (file: string) => + listMembers({ ...opts, entryFile: file, projectDir: root.dir }) + const listed = await list(entryFile) + if (!listed) { + // Unless the twin is readable, this one is built and its failure shows. + if (!hasTwin || !(await list(twin))) { + return false + } + logger.warn( + `Skipping ${buildRootPath(root)}: the .NET SDK could not read it; ${twin} beside it is built instead.`, + ) + return true + } + const members = await Promise.all(listed.map(realFilePath)) + return !!members.length && members.every(member => covered.has(member)) +} + function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } @@ -122,7 +188,7 @@ export function sortByDepthThenPath( return ( depthA - depthB || compareStrings(relA, relB) || - compareStrings(a.entryFile ?? '', b.entryFile ?? '') + compareEntryFiles(a.entryFile ?? '', b.entryFile ?? '') ) }) } diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index b28159226..e9b9c18c6 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -7,6 +7,7 @@ import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, + isSolutionCovered, realpathOrResolved, withoutDisabledFlags, } from './discover-manifest-roots.mts' @@ -17,6 +18,7 @@ import { DSI_SETUP_COMMAND, REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG, } from './scripts/resolution-report-render.mts' +import { skipWithoutDotnetSdk } from './scripts/run.mts' import { SOCKET_JSON } from '../../constants.mts' import { loadSocketConfig } from '../../utils/config.mts' import { withTmpDir } from '../../utils/fs.mts' @@ -38,6 +40,7 @@ export type RecursiveManifestOutcomeStatus = | 'generated' | 'skippedCovered' | 'skippedDisabled' + | 'skippedNoSdk' export type RecursiveManifestOutcome = BuildRoot & { ecosystem: BuildTool @@ -214,6 +217,23 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } + // eslint-disable-next-line no-await-in-loop + if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) { + outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' }) + continue + } + if ( + // eslint-disable-next-line no-await-in-loop + await isSolutionCovered(covered, root, { + bin, + excludeBase: realCwd, + excludePaths, + toolOpts: buildOpts, + }) + ) { + outcomes.push({ ...root, ecosystem, status: 'skippedCovered' }) + continue + } // eslint-disable-next-line no-await-in-loop const result = await runManifestFacts({ @@ -222,10 +242,10 @@ async function runEcosystemCandidates({ cwd: dir, ecosystem, entryFile, - excludeConfigs, // Relative to the scan root: a build can reach modules outside its // own directory (Maven `../x` modules, relocated Gradle projects). excludeBase: realCwd, + excludeConfigs, excludePaths, hints: { continueOnInstallErrorsFlag: withFiles @@ -266,6 +286,7 @@ async function runEcosystemCandidates({ // eslint-disable-next-line no-await-in-loop await addCoveredBuildFiles( covered, + ecosystem, path.dirname(result.factsPath), result.projects, ) @@ -372,6 +393,19 @@ export function resolveEcosystemConfig( dir: string, sockJson: SocketJson, ): EcosystemBuildConfig { + if (ecosystem === 'dotnet') { + const config = sockJson.defaults?.manifest?.dotnet + return { + bin: config?.bin ?? resolveBuildToolBin('dotnet', dir), + buildOpts: parseBuildToolOpts(config?.dotnetOpts ?? undefined), + excludeConfigs: config?.excludeConfigs ?? '', + ignoreUnresolved: Boolean(config?.ignoreUnresolved), + includeConfigs: config?.includeConfigs ?? '', + javaHome: undefined, + skipReason: getSkipReason(ecosystem, config?.disabled), + verbose: Boolean(config?.verbose), + } + } if (ecosystem === 'sbt') { const config = sockJson.defaults?.manifest?.sbt const bin = config?.bin ?? undefined diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index fc449d56c..e631d5794 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -3,15 +3,18 @@ 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' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { convertSbtToMaven } from './convert_sbt_to_maven.mts' +import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG } from './scripts/resolution-report-render.mts' +import { skipWithoutDotnetSdk } from './scripts/run.mts' import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts' import { ENVIRONMENT_YML, @@ -221,6 +224,29 @@ export async function generateAutoManifest({ abortFactsRunIfFailed('maven', outcome) } + if (!sockJson?.defaults?.manifest?.dotnet?.disabled && detected.dotnet) { + const config = resolveEcosystemConfig('dotnet', cwd, sockJson) + if (!(await skipWithoutDotnetSdk(config.bin, cwd))) { + logger.log( + 'Detected a .NET solution or project, generating Socket facts...', + ) + const outcome = await convertDotnetToFacts({ + bin: config.bin, + cwd, + dotnetOpts: config.buildOpts, + excludeConfigs: config.excludeConfigs, + excludePaths, + hints, + ignoreUnresolved: config.ignoreUnresolved, + includeConfigs: config.includeConfigs, + sidecarAcc, + verbose: config.verbose, + withFiles: computeArtifactsSidecar, + }) + abortFactsRunIfFailed('dotnet', outcome) + } + } + if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) { const condaFile = sockJson.defaults?.manifest?.conda?.infile || diff --git a/src/commands/manifest/run-manifest-facts.mts b/src/commands/manifest/run-manifest-facts.mts index 8954f981a..3470dcae7 100644 --- a/src/commands/manifest/run-manifest-facts.mts +++ b/src/commands/manifest/run-manifest-facts.mts @@ -124,8 +124,8 @@ export async function runManifestFacts({ const scriptOpts = { bin: bin || undefined, entryFile, - excludeConfigs: excludeConfigs || undefined, excludeBase, + excludeConfigs: excludeConfigs || undefined, excludePaths: excludePaths?.length ? excludePaths : undefined, // `env` replaces the spawned process's whole environment, not just JAVA_HOME. env: resolvedJavaHome @@ -185,6 +185,41 @@ export async function runManifestFacts({ stdout, } = result + // A non-zero build exit with no usable output (no graph, no first-party + // modules, no failure records) means the build died before the socketFacts + // task emitted anything — a script/plugin compile error, OOM, or an unchecked + // exception in the extension. The build tool's own exit is the only signal, so + // fail closed rather than silently dropping the ecosystem with an empty SBOM + // (the empty-facts branch below would otherwise just log "nothing to upload"). + // The dotnet tool exits non-zero only when it crashed, so whatever it + // recorded before is incomplete however much there is. + if ( + code !== 0 && + (ecosystem === 'dotnet' || + (!facts.components.length && + !facts.projects?.length && + !report.failures.length && + !report.unscannable.length)) + ) { + if (!verbose) { + const tail = tailBuildOutput(stdout, stderr) + if (tail) { + logger.group('Build output:') + logger.error(tail) + logger.groupEnd() + } + } + // A crashed build is a process failure (missing JDK/build tool, unparseable + // project, OOM, plugin error), not an unresolved dependency, so it fails + // regardless of `ignoreUnresolved` — that flag only tolerates dependencies a + // successful run couldn't resolve. + process.exitCode = 1 + logger.fail( + `The ${ecosystem} build at \`${location}\` failed (exit code ${code}) without completing its Socket facts.`, + ) + return null + } + const rendered = renderResolutionErrorReport( report.failures, report.scannedConfigs, @@ -216,38 +251,6 @@ export async function runManifestFacts({ logger.info(rendered.details) } - // A non-zero build exit with no usable output (no graph, no first-party - // modules, no failure records) means the build died before the socketFacts - // task emitted anything — a script/plugin compile error, OOM, or an unchecked - // exception in the extension. The build tool's own exit is the only signal, so - // fail closed rather than silently dropping the ecosystem with an empty SBOM - // (the empty-facts branch below would otherwise just log "nothing to upload"). - if ( - code !== 0 && - !facts.components.length && - !facts.projects?.length && - !report.failures.length && - !report.unscannable.length - ) { - if (!verbose) { - const tail = tailBuildOutput(stdout, stderr) - if (tail) { - logger.group('Build output:') - logger.error(tail) - logger.groupEnd() - } - } - // A crashed build is a process failure (missing JDK/build tool, unparseable - // project, OOM, plugin error), not an unresolved dependency, so it fails - // regardless of `ignoreUnresolved` — that flag only tolerates dependencies a - // successful run couldn't resolve. - process.exitCode = 1 - logger.fail( - `The ${ecosystem} build at \`${location}\` failed (exit code ${code}) before producing any Socket facts.`, - ) - return null - } - // Nothing resolved at all — no dependencies and no first-party modules. A // project with only first-party modules (empty components, non-empty projects) // still has source roots reachability needs, so it must be written. diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 81a7d1a19..8eaae7296 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -1,6 +1,9 @@ import { existsSync } from 'node:fs' +import { PURL_TYPE_BY_TOOL } from './build-tool.mts' import { + type ComponentFramework, + type ProjectFramework, type ResolvedArtifactPaths, type SocketFactsSbom, type SocketFactsSbomComponent, @@ -13,7 +16,14 @@ import { moduleKey } from './resolution-report.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' -const PURL_TYPE_MAVEN = 'maven' +// NuGet purls have no namespace, so the key is left out rather than empty. +function purlBase( + type: string, + group: string, + name: string, +): { type: string; namespace?: string; name: string } { + return type === 'nuget' ? { type, name } : { type, namespace: group, name } +} export type AssembleResult = { facts: SocketFactsSbom @@ -45,12 +55,17 @@ type SubprojectGraph = { type PerRoot = { projectKey: string prod: boolean + config: string + framework: string + runtime: string + alias: string nodes: Map } type RootNode = { coord: RawCoord children: string[] + compileTargets: string[] direct: boolean project: string targets: string[] @@ -61,17 +76,29 @@ export function assembleFacts( parsed: ParsedRecords, opts: AssembleOptions = {}, ): AssembleResult { + // Every root that fetched an artifact lists its file, so check each path once. const fileExists = opts.fileExists ?? existsSync + const existing = new Map() + const exists = (p: string) => { + let e = existing.get(p) + if (e === undefined) { + e = fileExists(p) + existing.set(p, e) + } + return e + } const perRoot = buildPerRoot(parsed) const finalNodes = mergeById(perRoot) const graphs = buildSubprojectGraphs(perRoot) const tool = (parsed.tool || 'gradle') as SocketFactsSbomMetadata['tool'] - const components = buildComponents(finalNodes) + const purlType = PURL_TYPE_BY_TOOL[tool] + const components = buildComponents(finalNodes, purlType) const { dependencies, projects } = buildDependencyGraph( parsed, graphs, components, + purlType, ) const metadata: SocketFactsSbomMetadata = { @@ -88,13 +115,16 @@ export function assembleFacts( return { facts, report: buildReport(parsed), - artifactPaths: buildArtifactPaths( - finalNodes, - [...parsed.projects.values()], - [...parsed.excludedProjects.values()], - graphs, - fileExists, - ), + artifactPaths: { + ...buildArtifactPaths( + finalNodes, + [...parsed.projects.values()], + [...parsed.excludedProjects.values()], + graphs, + exists, + ), + ...buildFrameworks(parsed, perRoot, exists), + }, } } @@ -117,6 +147,7 @@ function buildPerRoot(parsed: ParsedRecords): Map { node = { coord: n.project ? { ...n.coord, classifier: '', ext: '' } : n.coord, children: [], + compileTargets: [], direct: false, project: n.project, targets: [], @@ -127,6 +158,7 @@ function buildPerRoot(parsed: ParsedRecords): Map { node.direct ||= n.direct node.unfetched ||= n.unfetched node.targets.push(...n.targets) + node.compileTargets.push(...n.compileTargets) } for (const [p, c] of r.edges) { if (!r.nodes.has(p) || !r.nodes.has(c)) { @@ -139,7 +171,15 @@ function buildPerRoot(parsed: ParsedRecords): Map { parent.children.push(childId) } } - out.set(rootId, { projectKey: r.projectKey, prod: r.prod, nodes }) + out.set(rootId, { + projectKey: r.projectKey, + prod: r.prod, + config: r.config, + framework: r.framework, + runtime: r.runtime, + alias: r.alias, + nodes, + }) } return out } @@ -181,6 +221,7 @@ function mergeById(perRoot: Map): Map { function buildComponents( finalNodes: Map, + purlType: string, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].sort().map(id => { const fn = finalNodes.get(id)! @@ -195,9 +236,7 @@ function buildComponents( qualifiers['ext'] = c.ext } const comp: SocketFactsSbomComponent = { - type: PURL_TYPE_MAVEN, - namespace: c.group, - name: c.name, + ...purlBase(purlType, c.group, c.name), ...(c.version ? { version: c.version } : {}), ...(Object.keys(qualifiers).length ? { qualifiers } : {}), id, @@ -249,6 +288,7 @@ function buildDependencyGraph( parsed: ParsedRecords, graphs: Map, components: SocketFactsSbomComponent[], + purlType: string, ): { dependencies: SocketFactsSbomDependency[] projects: SocketFactsSbomProject[] @@ -349,9 +389,7 @@ function buildDependencyGraph( projects: projects.map(({ p, roots }) => { const entry: SocketFactsSbomProject = { id: p.projectKey, - type: PURL_TYPE_MAVEN, - namespace: p.group, - name: p.name, + ...purlBase(purlType, p.group, p.name), ...(p.version ? { version: p.version } : {}), subprojectDir: p.dir, children: roots @@ -359,9 +397,7 @@ function buildDependencyGraph( .sort((x, y) => x - y), } if (p.buildFiles.length) { - entry.manifestFiles = [...new Set(p.buildFiles)] - .sort() - .map(file => ({ file })) + entry.manifestFiles = sortedUnique(p.buildFiles).map(file => ({ file })) } return entry }), @@ -413,34 +449,24 @@ function buildArtifactPaths( projects: RawProject[], excludedProjects: RawProject[], graphs: Map, - fileExists: (path: string) => boolean, + exists: (path: string) => boolean, ): ResolvedArtifactPaths { const pathsById: ResolvedArtifactPaths['pathsById'] = new Map() - // Every root that fetched an artifact lists its file, so check each path once. - const existing = new Map() - const exists = (p: string) => { - let e = existing.get(p) - if (e === undefined) { - e = fileExists(p) - existing.set(p, e) - } - return e - } for (const [id, fn] of finalNodes) { if (!fn.project) { - // Only a root that fetched every file of the artifact, all still on - // disk, resolved it: a partial list would claim it resolved whole. - const complete = fn.resolutions.filter( - r => !r.unfetched && r.targets.every(exists), + const complete = completeResolutions( + fn.resolutions, + exists, + r => r.targets, ) pathsById.set( id, - fn.resolutions.length && !complete.length - ? { sources: [] } - : { + complete + ? { sources: [], - targets: [...new Set(complete.flatMap(r => r.targets))].sort(), - }, + targets: sortedUnique(complete.flatMap(r => r.targets)), + } + : { sources: [] }, ) } } @@ -448,8 +474,8 @@ function buildArtifactPaths( // edges onto a sibling project. for (const p of [...projects, ...excludedProjects]) { pathsById.set(p.projectKey, { - sources: [...new Set(p.sources)].filter(fileExists).sort(), - targets: [...new Set(p.targets)].filter(fileExists).sort(), + sources: sortedUnique(p.sources).filter(exists), + targets: sortedUnique(p.targets).filter(exists), }) } return { @@ -459,6 +485,175 @@ function buildArtifactPaths( } } +// Only a root that fetched every file of the artifact, all still on disk, +// resolved it: a partial list would claim it resolved whole. Undefined when +// every root that fetched it failed. +function completeResolutions( + resolutions: readonly R[], + exists: (path: string) => boolean, + files: (resolution: R) => string[], +): R[] | undefined { + const complete = resolutions.filter( + r => !r.unfetched && files(r).every(exists), + ) + return resolutions.length && !complete.length ? undefined : complete +} + +function frameworkKey(framework: string, runtime: string): string { + return `${framework}\t${runtime}` +} + +function sortedUnique(ids: Iterable): string[] { + return [...new Set(ids)].sort() +} + +// Per target framework, for builds that resolve per framework (dotnet): what +// each project's build compiles against and each package contributes to it. +function buildFrameworks( + parsed: ParsedRecords, + perRoot: Map, + exists: (path: string) => boolean, +): Pick { + const roots = [...perRoot.values()].filter(r => r.framework) + const projects = [...parsed.projects.values()] + if (!roots.length && !projects.some(p => p.builds.size)) { + return {} + } + + const projectBuilds = new Map>() + const projectFramework = ( + projectKey: string, + alias: string, + framework: string | undefined, + runtime: string, + ): ProjectFramework => { + let builds = projectBuilds.get(projectKey) + if (!builds) { + builds = new Map() + projectBuilds.set(projectKey, builds) + } + const k = frameworkKey(alias, runtime) + let pf = builds.get(k) + if (!pf) { + const build = parsed.projects.get(projectKey)?.builds.get(alias) + const one = (name: string) => build?.info.get(name)?.[0] + pf = { + alias, + framework: framework || one('Framework') || alias, + ...(runtime ? { runtime } : {}), + targetFrameworkMoniker: one('TargetFrameworkMoniker'), + language: one('Language'), + langVersion: one('LangVersion'), + nullable: one('Nullable'), + assemblyName: one('AssemblyName'), + buildError: one('BuildError'), + frameworkReferences: sortedUnique( + build?.info.get('FrameworkReference') ?? [], + ), + defineConstants: [...new Set(build?.info.get('DefineConstant'))], + usings: (build?.usings ?? []).map(u => ({ + namespace: u.namespace, + ...(u.alias ? { alias: u.alias } : {}), + ...(u.static ? { static: true as const } : {}), + })), + // In compiler order, which F# depends on. + sources: [...new Set(build?.sources)].filter(exists), + references: [...new Set(build?.info.get('Reference'))].filter(exists), + analyzers: [...new Set(build?.info.get('Analyzer'))].filter(exists), + targets: sortedUnique(build?.targets ?? []).filter(exists), + dependencies: [], + classpath: [], + } + builds.set(k, pf) + } + return pf + } + + // Per component and framework: the roots' resolutions. + const componentBuilds = new Map< + string, + Map< + string, + Omit & { + resolutions: Array<{ + compile: string[] + targets: string[] + unfetched: boolean + }> + } + > + >() + for (const r of roots) { + const pf = projectFramework(r.projectKey, r.alias, r.framework, r.runtime) + for (const { 0: id, 1: node } of r.nodes) { + pf.classpath.push(id) + if (node.direct) { + pf.dependencies.push(id) + } + if (node.project) { + continue + } + let byFramework = componentBuilds.get(id) + if (!byFramework) { + byFramework = new Map() + componentBuilds.set(id, byFramework) + } + const fk = frameworkKey(r.framework, r.runtime) + let cb = byFramework.get(fk) + if (!cb) { + cb = { framework: r.framework, runtime: r.runtime, resolutions: [] } + byFramework.set(fk, cb) + } + if (node.unfetched || node.targets.length || node.compileTargets.length) { + cb.resolutions.push({ + compile: node.compileTargets, + targets: node.targets, + unfetched: node.unfetched, + }) + } + } + } + // A build with nothing to resolve has no root, yet still compiles. + for (const p of projects) { + for (const alias of p.builds.keys()) { + projectFramework(p.projectKey, alias, undefined, '') + } + } + + const frameworksByProject = new Map( + [...projectBuilds].map(({ 0: projectKey, 1: builds }) => [ + projectKey, + [...builds.values()].map(pf => ({ + ...pf, + dependencies: sortedUnique(pf.dependencies), + classpath: sortedUnique(pf.classpath.filter(id => id !== projectKey)), + })), + ]), + ) + const frameworksById = new Map( + [...componentBuilds].map(({ 0: id, 1: byFramework }) => [ + id, + [...byFramework.values()].map(({ framework, resolutions, runtime }) => { + const complete = completeResolutions(resolutions, exists, r => [ + ...r.targets, + ...r.compile, + ]) + return { + framework, + ...(runtime ? { runtime } : {}), + ...(complete + ? { + targets: sortedUnique(complete.flatMap(r => r.targets)), + compile: sortedUnique(complete.flatMap(r => r.compile)), + } + : {}), + } + }), + ]), + ) + return { frameworksByProject, frameworksById } +} + function buildReport(parsed: ParsedRecords): ResolutionReport { const seen = new Set() const failures = parsed.failures.filter(f => { diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 6416cc1fd..98022ba9e 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -3,11 +3,24 @@ import { basename, resolve } from 'node:path' import constants from '../../../constants.mts' -export type BuildTool = 'gradle' | 'maven' | 'sbt' +import type { PURL_Type } from '../../../utils/ecosystem.mts' + +export type BuildTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' + +// The purl type of the packages each tool resolves; exhaustive, so a new tool +// cannot silently produce another ecosystem's purls. +export const PURL_TYPE_BY_TOOL: Record = { + __proto__: null, + dotnet: 'nuget', + gradle: 'maven', + maven: 'maven', + sbt: 'maven', +} as unknown as Record // 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', @@ -26,12 +39,29 @@ const BUILD_TOOL_WRAPPER = { // case-sensitive superset of `nocaseGlob`, for the walk. export type BuildFiles = | { byFile: false; names: readonly string[] } - | { byFile: true; nocaseGlob: string; walkGlob: string } + | { + byFile: true + nocaseGlob: string + walkGlob: string + // Each project restores on its own whichever build reaches it, so a build + // covers its projects wherever they live; solution properties aside. + projectsResolveAlone?: true + } // `settings.gradle(.kts)` covers Kotlin-DSL roots that have no root // `build.gradle`. export const BUILD_FILES: Record = { __proto__: null, + // Solutions, and the project types the .NET SDK builds. A solution filter + // (.slnf) narrows a solution, so it is only built when named explicitly. + dotnet: { + byFile: true, + nocaseGlob: '*.{sln,slnx,csproj,fsproj,vbproj}', + walkGlob: + '*.{[sS][lL][nN],[sS][lL][nN][xX],[cCfFvV][sSbB][pP][rR][oO][jJ]}', + // NuGet restores each project into its own project.assets.json. + projectsResolveAlone: true, + }, gradle: { byFile: false, names: [ @@ -74,6 +104,10 @@ export function resolveBuildToolBin( return DEFAULT_BUILD_TOOL_BIN[tool] } +export function isDotnetSolution(file: string): boolean { + return /\.slnx?$/i.test(file) +} + // Distinct for every build sharing a directory. export function socketFactsFileName( tool: BuildTool, 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..f8d587f8a --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs @@ -0,0 +1,339 @@ +using System.Text.Json; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Evaluation.Context; +using Microsoft.Build.Exceptions; + +namespace Socket.Facts.Dotnet; + +internal enum RestoreStyle { + PackageReference, + PackagesConfig, + ProjectJson, + Unknown, +} + +internal sealed class ProjectInfo { + public string FullPath = ""; + public string Key = ""; + public Dictionary GlobalProperties = new(); + public bool Excluded; + public RestoreStyle Style = RestoreStyle.Unknown; + public string? PackagesConfigPath; +} + +// Evaluate -> `dotnet restore` -> re-evaluate and read. Never throws for a +// project-level problem: those become failure/unscannable records, which the +// CLI renders and turns into the exit code. +internal static class FactsRunner { + 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) { + // The record keeps a partially written run from reading as complete. + records.Failure( + Path.GetFileName(opts.EntryPath), + $"socket-facts-dotnet failed: {e.GetType().Name}: {Text.FirstLine(e.Message)}", + "", + "" + ); + Console.Error.WriteLine($"socket-facts-dotnet: {e}"); + return 1; + } + } +} + +internal sealed partial class Session { + private readonly ToolOptions _opts; + private readonly RecordsWriter _records; + private readonly string _buildRoot; + private readonly string _excludeBase; + private readonly Globs _configs; + private readonly PathExcludes _excludes; + private readonly HashSet _scanned = new(StringComparer.Ordinal); + private readonly Dictionary _projects = new(StringComparer.Ordinal); + private SolutionContext? _solution; + private RestoreOutcome? _restore; + + public Session(ToolOptions opts, RecordsWriter records) { + _opts = opts; + _records = records; + _buildRoot = Path.GetDirectoryName(opts.EntryPath)!; + _excludeBase = string.IsNullOrEmpty(opts.ExcludeBase) ? _buildRoot : opts.ExcludeBase; + _configs = new Globs(opts.IncludeConfigs, opts.ExcludeConfigs); + _excludes = new PathExcludes(opts.ExcludePaths); + } + + public void Execute() { + _records.BuildRoot(_buildRoot); + _records.Entry(Path.GetFileName(_opts.EntryPath)); + if (!File.Exists(_opts.EntryPath)) { + Fail(Path.GetFileName(_opts.EntryPath), "entry file does not exist", ""); + return; + } + + var entryProjects = EntryProjects(); + if (entryProjects == null) return; + if (_opts.Members) { + foreach (var path in entryProjects) { + if (!IsExcludedProject(path)) _records.Member(Rel(path)); + } + return; + } + + // Listing workspaces evaluates the projects as they are before restore; + // a scan leaves finding them to restore, which follows every reference, + // conditional per target framework or not. + if (_opts.Workspaces) { + var preRestore = new ProjectCollection(); + _evaluation = EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared); + var queue = new Queue(entryProjects); + while (queue.Count > 0) { + var path = queue.Dequeue(); + if (_projects.ContainsKey(path)) continue; + var info = Register(path); + if (info.Excluded) continue; + var project = Evaluate(info, preRestore, reportFailure: true); + if (project == null) continue; + info.Style = StyleOf(project, out info.PackagesConfigPath); + EmitProject(info, project); + foreach (var reference in ProjectReferences(project, innerBuilds: true)) queue.Enqueue(reference); + } + return; + } + + RunRestore(); + + // One evaluation context for every project read: its file-system and SDK + // resolution caches are shared, and it starts after restore wrote the + // files evaluation imports. + var postRestore = new ProjectCollection(); + _evaluation = EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared); + var read = new HashSet(StringComparer.Ordinal); + var pending = new Queue( + entryProjects.Concat(_projects.Keys).Distinct(StringComparer.Ordinal).OrderBy(p => p, StringComparer.Ordinal) + ); + while (pending.Count > 0) { + var path = pending.Dequeue(); + if (!read.Add(path)) continue; + var info = _projects.TryGetValue(path, out var known) ? known : Register(path); + foreach (var reference in ReadProject(info, postRestore)) { + if (!read.Contains(reference)) pending.Enqueue(reference); + } + foreach (var loaded in postRestore.GetLoadedProjects(path).ToList()) postRestore.UnloadProject(loaded); + } + EndDesignTimeBuilds(); + ReportRestoreErrors(); + } + + private ProjectInfo Register(string path) { + var info = new ProjectInfo { + FullPath = path, + Key = Rel(path), + GlobalProperties = PropertiesFor(path), + }; + info.Excluded = IsExcludedProject(path); + _projects[path] = info; + return info; + } + + // Relative to --exclude-base when given; a project outside it never matches. + private bool IsExcludedProject(string path) { + var rel = RelTo(_excludeBase, Path.GetDirectoryName(path)!); + return rel != ".." && !rel.StartsWith("../", StringComparison.Ordinal) && _excludes.IsExcluded(rel); + } + + // The solution's members its active configuration builds, or the entry + // project itself. + private List? EntryProjects() { + var entry = _opts.EntryPath; + if (!IsSolution(entry)) return new List { entry }; + try { + _solution = new SolutionContext(entry, _opts.GlobalProperties); + return _solution.Members.Keys.OrderBy(p => p, StringComparer.Ordinal).ToList(); + } catch (Exception e) when (e is InvalidProjectFileException or IOException or JsonException) { + Fail(Rel(entry), $"could not read the solution: {Text.FirstLine(e.Message)}", ""); + return null; + } + } + + private Dictionary PropertiesFor(string projectPath) { + var props = new Dictionary(_opts.GlobalProperties, StringComparer.OrdinalIgnoreCase); + _solution?.Apply(projectPath, props); + return props; + } + + // NuGet's GetRestoreProjectStyleTask (fed by NuGet.targets' + // _GetRestoreProjectStyle): an explicit RestoreProjectStyle wins (the .NET + // SDK sets PackageReference), then PackageReference items, then + // project.json, then a packages config file. + private static RestoreStyle StyleOf(Project project, out string? packagesConfig) { + packagesConfig = FindPackagesConfig(project); + var explicitStyle = project.GetPropertyValue("RestoreProjectStyle"); + if (!string.IsNullOrEmpty(explicitStyle)) { + return Enum.TryParse(explicitStyle, ignoreCase: true, out var style) + ? style + : RestoreStyle.Unknown; + } + if (project.GetItems("PackageReference").Count > 0) return RestoreStyle.PackageReference; + var dir = project.DirectoryPath; + if (File.Exists(Path.Combine(dir, "project.json")) + || File.Exists(Path.Combine(dir, $"{project.GetPropertyValue("MSBuildProjectName")}.project.json"))) { + return RestoreStyle.ProjectJson; + } + return packagesConfig != null ? RestoreStyle.PackagesConfig : RestoreStyle.Unknown; + } + + // packages.config wins over packages..config, as in + // GetRestoreProjectStyleTask and NuGet.targets' _GenerateRestoreProjectSpec. + private static string? FindPackagesConfig(Project project) { + var dir = project.DirectoryPath; + var shared = Path.Combine(dir, "packages.config"); + if (File.Exists(shared)) return shared; + var named = Path.Combine(dir, $"packages.{project.GetPropertyValue("MSBuildProjectName")}.config"); + return File.Exists(named) ? named : null; + } + + // As MSBuild's static graph finds them: a multi-targeting project's + // references are those of each of its inner builds, which may differ by + // target framework. + private IEnumerable ProjectReferences(Project project, bool innerBuilds) { + var builds = new List { project }; + if (innerBuilds) { + foreach (var tfm in Frameworks(project)) { + try { + builds.Add(LoadInnerBuild(project, tfm)); + } catch (InvalidProjectFileException) { + // Its references surface from restore output instead. + } + } + } + return builds + .SelectMany(b => b.GetItems("ProjectReference")) + .Select(item => item.GetMetadataValue("FullPath")) + .Where(full => !string.IsNullOrEmpty(full) && File.Exists(full)) + .Select(full => Path.GetFullPath(full)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + private void RunRestore() { + // The entry exactly as the user restores it, or a solution filter of the + // members --exclude-paths keeps, restored in the solution's own context. + var target = _opts.EntryPath; + string? filter = null; + if (_solution != null) { + var kept = _solution.Members.Keys + .Where(m => !IsExcludedProject(m)) + .ToList(); + if (kept.Count == 0) return; + if (kept.Count != _solution.Members.Count) { + filter = _solution.WriteFilter(kept); + target = filter; + } + } + try { + _restore = Restorer.Run(_opts, target, Log); + } finally { + if (filter != null) Text.TryDelete(filter); + } + if (_restore.TimedOut) { + Fail(Rel(_opts.EntryPath), $"`dotnet restore` did not finish within {Restorer.TimeoutSec}s and was stopped", ""); + } + } + + // As `dotnet restore` evaluates: a project it reads despite a bad import (a + // web application's Visual Studio-only targets) is read here too. + private const ProjectLoadSettings RestoreLoadSettings = + ProjectLoadSettings.IgnoreMissingImports | ProjectLoadSettings.IgnoreEmptyImports | ProjectLoadSettings.IgnoreInvalidImports; + + private EvaluationContext? _evaluation; + + private Project Load(ProjectCollection collection, string path, IDictionary properties) { + foreach (var loaded in collection.GetLoadedProjects(path)) { + if (loaded.GlobalProperties.Count == properties.Count + && properties.All(kv => loaded.GlobalProperties.TryGetValue(kv.Key, out var v) && v == kv.Value)) { + return loaded; + } + } + return Project.FromFile(path, new ProjectOptions { + EvaluationContext = _evaluation, + GlobalProperties = properties, + LoadSettings = RestoreLoadSettings, + ProjectCollection = collection, + }); + } + + private Project LoadInnerBuild(Project project, string targetFramework) { + var props = new Dictionary(project.GlobalProperties, StringComparer.OrdinalIgnoreCase) { + ["TargetFramework"] = targetFramework, + }; + return Load(project.ProjectCollection, project.FullPath, props); + } + + private Project? Evaluate(ProjectInfo info, ProjectCollection collection, bool reportFailure) { + try { + return Load(collection, info.FullPath, info.GlobalProperties); + } catch (InvalidProjectFileException e) { + if (reportFailure) { + _records.Unscannable("", $"could not evaluate the project: {Text.FirstLine(e.Message)}", info.Key); + } + return null; + } + } + + private void Fail(string coord, string detail, string config, string projectKey = "") { + _records.Failure(coord, detail, config, projectKey); + } + + // A failed restore's errors, except for projects whose assets file already + // recorded its own (more precise) errors. + private void ReportRestoreErrors() { + if (_restore == null || _restore.Succeeded || _restore.TimedOut) return; + var reported = 0; + foreach (var error in _restore.Errors) { + var key = string.IsNullOrEmpty(error.ProjectFile) ? "" : Rel(Path.GetFullPath(error.ProjectFile)); + if (key.Length > 0 && _assetsReportedErrors.Contains(key)) continue; + var code = string.IsNullOrEmpty(error.Code) ? "" : $"{error.Code}: "; + Fail(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); + reported += 1; + } + if (reported == 0 && _assetsReportedErrors.Count == 0) { + Fail( + Rel(_opts.EntryPath), + $"`dotnet restore` failed with exit code {_restore.ExitCode}; run with --verbose to see its output", + "" + ); + } + } + + private void Log(string message) { + if (_opts.Verbose) Console.Error.WriteLine($"socket-facts-dotnet: {message}"); + } + + // Build-root-relative with `/`, the shape every record path takes. + private string Rel(string path) => RelTo(_buildRoot, path); + + private static string RelTo(string root, string path) { + var rel = Path.GetRelativePath(root, Path.GetFullPath(path)).Replace('\\', '/'); + return string.IsNullOrEmpty(rel) ? "." : rel; + } + + private static bool SamePath(string a, string b) { + return string.Equals( + RealPaths.Of(a), + RealPaths.Of(b), + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase + ); + } + + private static bool IsSolution(string path) { + var ext = Path.GetExtension(path); + return ext.Equals(".sln", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".slnx", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".slnf", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/NuGetShim.cs b/src/commands/manifest/scripts/dotnet-tool/NuGetShim.cs new file mode 100644 index 000000000..97d0c2306 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/NuGetShim.cs @@ -0,0 +1,23 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; + +namespace Socket.Facts.Dotnet; + +// NuGet changed collection members' declared types across the SDKs the tool +// runs on (IList -> ImmutableArray), which a compiled getter cannot bind. +internal static class NuGetShim { + private static readonly ConcurrentDictionary<(Type, string), PropertyInfo?> Properties = new(); + + public static IEnumerable Seq(object? owner, string member) { + if (owner == null) yield break; + var prop = Properties.GetOrAdd( + (owner.GetType(), member), + key => key.Item1.GetProperty(key.Item2, BindingFlags.Public | BindingFlags.Instance) + ); + if (prop?.GetValue(owner) is not IEnumerable items) yield break; + foreach (var item in items) { + if (item is T typed) yield return typed; + } + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/Patterns.cs b/src/commands/manifest/scripts/dotnet-tool/Patterns.cs new file mode 100644 index 000000000..e2f063d1d --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Patterns.cs @@ -0,0 +1,135 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Socket.Facts.Dotnet; + +// Target-framework name filters with the semantics of the JVM scripts' +// config globs: case-sensitive `*`, `?` and `[...]` (with `[!..]` negation); +// a malformed glob matches literally. +internal sealed class Globs { + private readonly List _includes; + private readonly List _excludes; + + public Globs(string includes, string excludes) { + _includes = Parse(includes); + _excludes = Parse(excludes); + } + + // Matches when any of `names` matches an include (or there are none) and + // none matches an exclude. + public bool Matches(IReadOnlyCollection names) { + if (_excludes.Any(p => names.Any(p.IsMatch))) return false; + return _includes.Count == 0 || _includes.Any(p => names.Any(p.IsMatch)); + } + + private static List Parse(string csv) { + return csv + .Split(',') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .Select(ToRegex) + .ToList(); + } + + private static Regex ToRegex(string glob) { + var sb = new StringBuilder("^(?:"); + var i = 0; + while (i < glob.Length) { + var ch = glob[i]; + if (ch == '*') { + sb.Append(".*"); + i += 1; + } else if (ch == '?') { + sb.Append('.'); + i += 1; + } else if (ch == '[') { + var j = glob.IndexOf(']', i + 1); + if (j <= i + 1) { + sb.Append("\\["); + i += 1; + } else { + var body = glob.Substring(i + 1, j - i - 1); + var negated = body.StartsWith('!'); + if (negated) body = body.Substring(1); + body = body.Replace("\\", "\\\\").Replace("[", "\\["); + sb.Append('[').Append(negated ? "^" : "").Append(body).Append(']'); + i = j + 1; + } + } else { + sb.Append(Regex.Escape(ch.ToString())); + i += 1; + } + } + sb.Append(")\\z"); + try { + return new Regex(sb.ToString(), RegexOptions.CultureInvariant); + } catch (ArgumentException) { + return new Regex($"^{Regex.Escape(glob)}\\z", RegexOptions.CultureInvariant); + } + } +} + +// --exclude-paths, pre-compiled by the CLI (exclude-paths-glob.mts) into +// anchored regex sources over build-root-relative directories; each already +// means "this directory or anything below it". +internal sealed class PathExcludes { + private readonly List _patterns = new(); + + public PathExcludes(string csv) { + foreach (var raw in csv.Split(',')) { + var p = raw.Trim(); + if (p.Length == 0) continue; + try { + _patterns.Add(new Regex(p, RegexOptions.CultureInvariant)); + } catch (ArgumentException) { + // The CLI only emits valid sources; this guards a broken transport. + } + } + } + + // `rel` is build-root-relative with `/`; the build root itself (`.`) is + // never excluded. + public bool IsExcluded(string rel) => rel != "." && _patterns.Any(p => p.IsMatch(rel)); +} + +internal static class Text { + public static string FirstLine(string s) { + var idx = s.IndexOfAny(new[] { '\n', '\r' }); + return idx < 0 ? s : s.Substring(0, idx); + } + + public static void TryDelete(string path) { + try { + File.Delete(path); + } catch (IOException) { + // Left in the temp dir. + } catch (UnauthorizedAccessException) { + // Left in the temp dir. + } + } +} + +// Restore reports some paths through a symlinked directory's target (macOS's +// /var -> /private/var); the last segment keeps the name it was given. +internal static class RealPaths { + private static readonly Dictionary Dirs = new(StringComparer.Ordinal); + + public static string Of(string path) { + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + var dir = Path.GetDirectoryName(full); + return dir == null ? full : Path.Combine(Dir(dir), Path.GetFileName(full)); + } + + public static string Dir(string dir) { + if (Dirs.TryGetValue(dir, out var known)) return known; + var parent = Path.GetDirectoryName(dir); + var real = parent == null ? dir : Path.Combine(Dir(parent), Path.GetFileName(dir)); + try { + if (new DirectoryInfo(real).ResolveLinkTarget(returnFinalTarget: true) is { } target) real = Dir(target.FullName); + } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + // Unresolvable links stay as written. + } + Dirs[dir] = real; + return real; + } +} 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..f2a096a9a --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Program.cs @@ -0,0 +1,23 @@ +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; + } + // Resolves the SDK the way the dotnet CLI does from the entry's directory + // (the caller's cwd), so a global.json pin selects the same SDK that + // `dotnet restore` runs. + var instance = MSBuildLocator.RegisterDefaults(); + // MSBuild types must not be JITed before the locator has registered the + // SDK assemblies, hence the separate class. + return FactsRunner.Run(opts, instance.Version.ToString()); + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/Reader.cs b/src/commands/manifest/scripts/dotnet-tool/Reader.cs new file mode 100644 index 000000000..f0bcea740 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Reader.cs @@ -0,0 +1,779 @@ +using System.Collections.Concurrent; +using System.Xml.Linq; +using Microsoft.Build.Construction; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Exceptions; +using Microsoft.Build.Execution; +using NuGet.Common; +using NuGet.Frameworks; +using NuGet.LibraryModel; +using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.ProjectModel; +using NuGet.Versioning; + +namespace Socket.Facts.Dotnet; + +// What every target of one project.assets.json shares. +internal sealed record Assets( + // NuGet's own choice of a package's directory among the package folders; + // null when a fallback folder restore used is gone. + FallbackPackagePathResolver? Resolver, + // Package libraries by lowercased name and version. + Dictionary<(string Name, NuGetVersion? Version), LockFileLibrary> Libraries, + // Lowercased project library name -> the project's key, and back. + Dictionary ProjectKeys, + Dictionary ProjectNames +); + +internal sealed partial class Session { + // The project type GUID in the ProjectTypeGuids of Visual Studio unit test + // projects. + private const string TestProjectTypeGuid = "3AC096D0-A1C2-E12C-1390-A8335801FDAB"; + + // Package and project identities NuGet compares case-insensitively. + private static string CoordId(string name, string version) => + $"{name.ToLowerInvariant()}:{version}"; + + // Keys of projects whose assets file recorded restore errors of its own, + // which say more than the restore's console errors for that project. + private readonly HashSet _assetsReportedErrors = new(StringComparer.Ordinal); + + private IEnumerable ReadProject(ProjectInfo info, ProjectCollection collection) { + var references = new List(); + var project = Evaluate(info, collection, reportFailure: !info.Excluded); + if (project == null) return references; + info.Style = StyleOf(project, out info.PackagesConfigPath); + if (info.Excluded) { + // Not resolved, but kept projects depending on it still need its paths. + if (_opts.WithFiles) { + EmitProject(info, project); + _records.ExcludedProject(info.Key); + EmitPaths(info, project, Frameworks(project)); + } + return references; + } + references.AddRange(ProjectReferences(project, innerBuilds: false)); + switch (info.Style) { + case RestoreStyle.PackageReference: + references.AddRange(ReadAssets(info, project)); + break; + case RestoreStyle.PackagesConfig: + ReadPackagesConfig(info, project); + break; + case RestoreStyle.ProjectJson: + EmitProject(info, project); + _records.Unscannable("", "project.json projects are not supported; migrate the project to PackageReference", info.Key); + break; + default: + // No NuGet dependencies; still a module whose sources matter. + EmitProject(info, project); + if (_opts.WithFiles) EmitPaths(info, project, Frameworks(project)); + break; + } + return references; + } + + // NuGet.targets' _RestoreProjectName: PackageId, then AssemblyName, for + // the PackageReference style; the project name otherwise. + private static string RestoreName(Project project, RestoreStyle style) { + if (style == RestoreStyle.PackageReference) { + foreach (var property in new[] { "PackageId", "AssemblyName" }) { + var value = project.GetPropertyValue(property); + if (!string.IsNullOrEmpty(value)) return value; + } + } + return project.GetPropertyValue("MSBuildProjectName"); + } + + // NuGet.targets' _RestoreProjectVersion. + private static string RestoreVersion(Project project) { + var v = project.GetPropertyValue("PackageVersion"); + if (string.IsNullOrEmpty(v)) v = project.GetPropertyValue("Version"); + return string.IsNullOrEmpty(v) ? "1.0.0" : v; + } + + // Named as restore names the project, unless its restore spec says. + private void EmitProject(ProjectInfo info, Project project, string? name = null, string? version = null) { + _records.Project( + info.Key, + name ?? RestoreName(project, info.Style), + version ?? RestoreVersion(project), + Rel(project.DirectoryPath) + ); + EmitBuildFiles(info, project); + } + + private IEnumerable ReadAssets(ProjectInfo info, Project project) { + var assetsPath = project.GetPropertyValue("ProjectAssetsFile"); + if (string.IsNullOrEmpty(assetsPath)) { + assetsPath = Path.Combine(project.GetPropertyValue("MSBuildProjectExtensionsPath"), "project.assets.json"); + } + assetsPath = Path.GetFullPath(assetsPath, project.DirectoryPath); + if (!RestoredByThisRun(info, assetsPath) || !File.Exists(assetsPath)) { + EmitProject(info, project); + // After a failed restore, its own errors say why (reported once all + // projects are read). + if (_restore == null || _restore.Succeeded) { + _records.Unscannable("", "`dotnet restore` wrote no project.assets.json for this project", info.Key); + } + return Array.Empty(); + } + + var lockFile = LockFileUtilities.GetLockFile(assetsPath, NullLogger.Instance); + var spec = lockFile?.PackageSpec; + if (lockFile == null || spec == null) { + EmitProject(info, project); + _records.Unscannable("", $"could not read {Rel(assetsPath)}", info.Key); + return Array.Empty(); + } + var owner = spec.RestoreMetadata?.ProjectPath; + if (!string.IsNullOrEmpty(owner) && !SamePath(owner, info.FullPath)) { + EmitProject(info, project); + _records.Unscannable( + "", + $"{Rel(assetsPath)} belongs to {Rel(owner)}: the projects share an intermediate output directory, so each overwrites the other's restore output; give each its own, e.g. obj/$(MSBuildProjectName)/ in Directory.Build.props", + info.Key + ); + return Array.Empty(); + } + + EmitProject(info, project, spec.Name, spec.Version?.ToNormalizedString()); + var lockFilePath = PackagesLockFileUtilities.GetNuGetLockFilePath(spec); + if (!string.IsNullOrEmpty(lockFilePath) && File.Exists(lockFilePath)) { + _records.ProjectBuild(info.Key, Rel(lockFilePath)); + } + ReportLogErrors(info, lockFile); + + // Project libraries are ProjectReferences as restore resolved them. + var projectKeys = new Dictionary(StringComparer.Ordinal); + var projectNames = new Dictionary(StringComparer.Ordinal); + var references = new List(); + var libraries = new Dictionary<(string, NuGetVersion?), LockFileLibrary>(); + foreach (var library in NuGetShim.Seq(lockFile, "Libraries")) { + if (string.IsNullOrEmpty(library.Name)) continue; + libraries[(library.Name.ToLowerInvariant(), library.Version)] = library; + if (!IsProject(library.Type)) continue; + if (string.IsNullOrEmpty(library.MSBuildProject)) continue; + var path = Path.GetFullPath(library.MSBuildProject, project.DirectoryPath); + var key = Rel(path); + projectKeys[library.Name.ToLowerInvariant()] = key; + projectNames[key] = library.Name.ToLowerInvariant(); + references.Add(path); + } + var assets = new Assets(PackageResolver(lockFile), libraries, projectKeys, projectNames); + + var isTest = IsTestProject(project); + var frameworks = new List(); + foreach (var target in NuGetShim.Seq(lockFile, "Targets")) { + var alias = EmitTarget(info, spec, target, assets, isTest); + if (alias != null && string.IsNullOrEmpty(target.RuntimeIdentifier)) frameworks.Add(alias); + } + if (_opts.WithFiles) EmitPaths(info, project, frameworks); + return references; + } + + // The resolver the .NET SDK builds from an assets file's package folders: + // the first is the user's, the rest fallbacks. + private static FallbackPackagePathResolver? PackageResolver(LockFile lockFile) { + var folders = NuGetShim.Seq(lockFile, "PackageFolders") + .Select(f => f.Path) + .Where(p => !string.IsNullOrEmpty(p)) + .ToList(); + if (folders.Count == 0) return null; + try { + return new FallbackPackagePathResolver(folders[0], folders.Skip(1)); + } catch (PackagingException) { + // A fallback folder restore used no longer exists. + return null; + } + } + + // An assets or cache file's error-level restore messages, as failures of the + // package they name. + private void ReportLogErrors(ProjectInfo info, object owner) { + foreach (var message in NuGetShim.Seq(owner, "LogMessages")) { + if (message.Level != LogLevel.Error) continue; + _assetsReportedErrors.Add(info.Key); + Fail( + string.IsNullOrEmpty(message.LibraryId) ? info.Key : message.LibraryId, + $"{message.Code}: {message.Message}", + "", + info.Key + ); + } + } + + // NuGet rewrites project.nuget.cache on every restore it performs but not an + // unchanged assets file, so only the cache says whether this run restored it. + private bool RestoredByThisRun(ProjectInfo info, string assetsPath) { + if (_restore == null || _restore.Succeeded) return true; + var cachePath = Path.Combine(Path.GetDirectoryName(assetsPath)!, "project.nuget.cache"); + if (!File.Exists(cachePath)) return false; + CacheFile cache; + using (var stream = File.OpenRead(cachePath)) { + cache = CacheFileFormat.Read(stream, NullLogger.Instance, cachePath); + } + if (File.GetLastWriteTimeUtc(cachePath) < _restore.StartedUtc) return cache.Success; + ReportLogErrors(info, cache); + return true; + } + + private static bool IsTestProject(Project project) { + return string.Equals(project.GetPropertyValue("IsTestProject"), "true", StringComparison.OrdinalIgnoreCase) + || project.GetPropertyValue("ProjectTypeGuids").Contains(TestProjectTypeGuid, StringComparison.OrdinalIgnoreCase); + } + + // One resolution root per (target framework [/ runtime], prod|dev). Returns + // the target framework alias when the target was read. + private string? EmitTarget(ProjectInfo info, PackageSpec spec, LockFileTarget target, Assets assets, bool isTest) { + var framework = NuGetShim.Seq(spec, "TargetFrameworks") + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + var alias = framework?.TargetAlias; + if (string.IsNullOrEmpty(alias)) alias = target.TargetFramework.GetShortFolderName(); + var rid = target.RuntimeIdentifier; + var config = string.IsNullOrEmpty(rid) ? alias : $"{alias}/{rid}"; + // A runtime-specific target also answers to its framework's name, so a + // filter on `net8.0` covers `net8.0/win-x64`. + var names = string.IsNullOrEmpty(rid) ? new[] { config } : new[] { config, alias }; + if (!_configs.Matches(names)) return null; + if (_scanned.Add(config)) _records.Scanned(config); + + var libraries = new Dictionary(StringComparer.Ordinal); + var idByName = new Dictionary(StringComparer.Ordinal); + foreach (var lib in NuGetShim.Seq(target, "Libraries")) { + if (string.IsNullOrEmpty(lib.Name)) continue; + var id = CoordId(lib.Name, lib.Version?.ToNormalizedString() ?? ""); + libraries[id] = lib; + idByName[lib.Name.ToLowerInvariant()] = id; + } + var children = new Dictionary>(StringComparer.Ordinal); + foreach (var (id, lib) in libraries) { + var kids = new List(); + foreach (var dep in NuGetShim.Seq(lib, "Dependencies")) { + if (idByName.TryGetValue(dep.Id.ToLowerInvariant(), out var child) && child != id && !kids.Contains(child)) { + kids.Add(child); + } + } + children[id] = kids; + } + + // Direct dependencies are the project's own PackageReferences and + // ProjectReferences. + var direct = new HashSet(StringComparer.Ordinal); + foreach (var dep in NuGetShim.Seq(framework, "Dependencies")) { + if (!string.IsNullOrEmpty(dep.Name) && idByName.TryGetValue(dep.Name.ToLowerInvariant(), out var id)) direct.Add(id); + } + var restoreFramework = NuGetShim.Seq(spec.RestoreMetadata, "TargetFrameworks") + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + foreach (var reference in NuGetShim.Seq(restoreFramework, "ProjectReferences")) { + if (string.IsNullOrEmpty(reference.ProjectPath)) continue; + var key = Rel(Path.GetFullPath(reference.ProjectPath, Path.GetDirectoryName(info.FullPath)!)); + if (assets.ProjectNames.TryGetValue(key, out var name) && idByName.TryGetValue(name, out var id)) direct.Add(id); + } + + var reached = Reach(direct, children); + var unreached = libraries.Keys.Where(k => !reached.Contains(k)).OrderBy(k => k).ToList(); + if (unreached.Count > 0) { + Fail( + info.Key, + $"restore resolved packages no direct dependency leads to: {string.Join(", ", unreached)}", + config, + info.Key + ); + } + + // Prod is what reaches the build output after the include flags, plus + // metapackages and packages that bring a shipped one in. + var parents = new Dictionary>(StringComparer.Ordinal); + foreach (var (id, kids) in children) { + foreach (var kid in kids) { + if (!parents.TryGetValue(kid, out var list)) parents[kid] = list = new List(); + list.Add(id); + } + } + var shipped = new HashSet(reached.Where(id => Delivers(assets, libraries[id])), StringComparer.Ordinal); + for (var grew = true; grew;) { + grew = false; + foreach (var id in reached) { + if (!shipped.Contains(id) && children[id].Any(shipped.Contains) && IsMetapackage(assets, libraries[id])) { + grew |= shipped.Add(id); + } + } + } + var shippedChildren = children.ToDictionary( + kv => kv.Key, kv => kv.Value.Where(shipped.Contains).ToList(), StringComparer.Ordinal + ); + var viaShipped = Reach(new HashSet(direct.Where(shipped.Contains), StringComparer.Ordinal), shippedChildren); + shipped.UnionWith(Reach(new HashSet(shipped.Except(viaShipped), StringComparer.Ordinal), parents)); + shipped.IntersectWith(reached); + + void EmitRoot(string kind, bool prod, HashSet ids, bool withFiles) { + if (ids.Count == 0) return; + var rootId = $"{info.Key}|{config}|{kind}"; + _records.Root(rootId, info.Key, config, prod, target.TargetFramework.GetShortFolderName(), rid ?? "", alias); + foreach (var id in ids.OrderBy(k => k, StringComparer.Ordinal)) { + var lib = libraries[id]; + var isProject = IsProject(lib.Type); + var projectKey = isProject && assets.ProjectKeys.TryGetValue(lib.Name!.ToLowerInvariant(), out var k) ? k : ""; + _records.Node(rootId, id, lib.Name!, lib.Version?.ToNormalizedString() ?? "", direct.Contains(id), projectKey); + if (withFiles && !isProject) EmitPackageAssemblies(info, rootId, id, config, assets, lib); + foreach (var child in children[id]) { + if (ids.Contains(child)) _records.Edge(rootId, id, child); + } + } + } + // The whole graph once as dev, its shipped part again as prod: a package + // is prod when any build of any project ships it. Everything a test + // project resolves is dev. + EmitRoot("all", false, reached, _opts.WithFiles); + if (!isTest) EmitRoot("prod", true, shipped, false); + return alias; + } + + // Runtime assemblies (lib/, runtimes//lib/) carry the method bodies an + // analysis follows; compile-time ones (often ref/) are what the project's + // own sources compile against. + private void EmitPackageAssemblies( + ProjectInfo info, string rootId, string id, string config, Assets assets, LockFileTargetLibrary lib + ) { + var runtime = AssetPaths(NuGetShim.Seq(lib, "RuntimeAssemblies") + .Concat(NuGetShim.Seq(lib, "RuntimeTargets") + .Where(t => string.Equals(t.AssetType, "runtime", StringComparison.OrdinalIgnoreCase)))) + .ToList(); + var compile = AssetPaths(NuGetShim.Seq(lib, "CompileTimeAssemblies")).ToList(); + if (runtime.Count == 0 && compile.Count == 0) return; + var packageDir = assets.Resolver != null && lib.Version != null + ? assets.Resolver.GetPackageDirectory(lib.Name!, lib.Version) + : null; + var missing = new List(); + string? Resolve(string item) { + var path = packageDir == null ? null : Path.GetFullPath(Path.Combine(packageDir, item)); + if (path != null && File.Exists(path)) return path; + missing.Add(item); + return null; + } + foreach (var item in runtime) { + if (Resolve(item) is { } path) _records.File(rootId, id, path); + } + // Without its runtime assemblies the package is not installed; a missing + // compile-time one still fails the run but leaves those in place. + if (missing.Count > 0) _records.Unfetched(rootId, id); + foreach (var item in compile) { + if (Resolve(item) is { } path) _records.CompileFile(rootId, id, path); + } + if (missing.Count > 0) { + Fail( + $"{lib.Name}:{lib.Version?.ToNormalizedString()}", + $"assemblies restore listed are missing from the package folders ({missing[0]}{(missing.Count > 1 ? $" and {missing.Count - 1} more" : "")}); re-run `dotnet restore`", + config, + info.Key + ); + } + } + + private static HashSet Reach(HashSet seeds, Dictionary> edges) { + var seen = new HashSet(seeds, StringComparer.Ordinal); + var stack = new Stack(seen); + while (stack.Count > 0) { + if (!edges.TryGetValue(stack.Pop(), out var next)) continue; + foreach (var n in next) { + if (seen.Add(n)) stack.Push(n); + } + } + return seen; + } + + private static readonly string[] OutputAssets = + { "RuntimeAssemblies", "RuntimeTargets", "NativeLibraries", "ResourceAssemblies", "ContentFiles" }; + + // An asset restore excluded through the include flags is listed as `_._`. + private static IEnumerable AssetPaths(IEnumerable items) => items + .Select(i => i.Path) + .Where(p => !string.IsNullOrEmpty(p) && Path.GetFileName(p) != "_._") + .Distinct(StringComparer.Ordinal); + + private static bool IsProject(string? libraryType) => + string.Equals(libraryType, "project", StringComparison.OrdinalIgnoreCase); + + private static readonly string[] BuildAssets = { "Build", "BuildMultiTargeting" }; + + // Active build files can copy anything into the output, as SqlClient.SNI's + // copy its native DLLs; only a developmentDependency declaration rules it out. + private bool Delivers(Assets assets, LockFileTargetLibrary lib) => + IsProject(lib.Type) + || AssetPaths(OutputAssets.SelectMany(member => NuGetShim.Seq(lib, member))).Any() + || (AssetPaths(BuildAssets.SelectMany(member => NuGetShim.Seq(lib, member))).Any() + && !IsDevelopmentDependency(assets, lib)); + + private readonly Dictionary _developmentDependency = new(StringComparer.Ordinal); + + // Unreadable counts as not declared, which keeps the package prod. + private bool IsDevelopmentDependency(Assets assets, LockFileTargetLibrary lib) { + if (assets.Resolver == null || lib.Version == null || string.IsNullOrEmpty(lib.Name)) return false; + var nuspec = assets.Resolver.GetPackageInfo(lib.Name, lib.Version)?.PathResolver.GetManifestFilePath(lib.Name, lib.Version); + if (nuspec == null) return false; + if (_developmentDependency.TryGetValue(nuspec, out var known)) return known; + var result = false; + try { + result = File.Exists(nuspec) && new NuspecReader(nuspec).GetDevelopmentDependency(); + } catch (Exception e) when (e is System.Xml.XmlException or PackagingException or IOException) { + Log($"{nuspec}: {Text.FirstLine(e.Message)}"); + } + _developmentDependency[nuspec] = result; + return result; + } + + // A package with no content of its own: every file it holds is package + // metadata at its root (nuspec, signature, readme, licence). + private static bool IsMetapackage(Assets assets, LockFileTargetLibrary lib) { + if (IsProject(lib.Type)) return false; + if (!assets.Libraries.TryGetValue((lib.Name!.ToLowerInvariant(), lib.Version), out var library)) return false; + return NuGetShim.Seq(library, "Files").All(file => !file.Contains('/') && !file.Contains('\\')); + } + + // packages.config holds the pinned closure NuGet installed, without edges + // or a direct/transitive split, so every package is a direct dependency of + // a flat graph. developmentDependency="true" marks dev packages. + private void ReadPackagesConfig(ProjectInfo info, Project project) { + EmitProject(info, project); + _records.ProjectBuild(info.Key, Rel(info.PackagesConfigPath!)); + if (_opts.WithFiles) EmitPaths(info, project, Frameworks(project)); + + List packages; + try { + packages = new NuGet.Packaging.PackagesConfigReader(XDocument.Load(info.PackagesConfigPath!)) + .GetPackages(allowDuplicatePackageIds: true) + .ToList(); + } catch (Exception e) when (e is System.Xml.XmlException or PackagingException or ArgumentException) { + _records.Unscannable("", $"could not read {Rel(info.PackagesConfigPath!)}: {Text.FirstLine(e.Message)}", info.Key); + return; + } + if (packages.Count == 0) return; + var config = LegacyFrameworkName(project); + if (!_configs.Matches(new[] { config })) return; + if (_scanned.Add(config)) _records.Scanned(config); + + var isTest = IsTestProject(project); + var hintPaths = _opts.WithFiles ? HintPaths(project) : new List(); + foreach (var dev in new[] { false, true }) { + var selected = packages.Where(p => p.IsDevelopmentDependency == dev).ToList(); + if (selected.Count == 0) continue; + var rootId = $"{info.Key}|{config}|{(dev ? "dev" : "prod")}"; + _records.Root(rootId, info.Key, config, !dev && !isTest, config, "", config); + foreach (var pkg in selected.OrderBy(p => p.PackageIdentity.Id, StringComparer.OrdinalIgnoreCase)) { + var identity = pkg.PackageIdentity; + var version = identity.Version?.ToNormalizedString() ?? ""; + _records.Node(rootId, CoordId(identity.Id, version), identity.Id, version, direct: true, project: ""); + if (_opts.WithFiles) EmitHintPathAssemblies(info, rootId, config, identity, hintPaths); + } + } + } + + // A packages.config project consumes a package's assemblies only through + // its Reference HintPaths into `/./`. + private void EmitHintPathAssemblies( + ProjectInfo info, string rootId, string config, PackageIdentity identity, List hintPaths + ) { + var version = identity.Version?.ToNormalizedString() ?? ""; + var id = CoordId(identity.Id, version); + var missing = new List(); + foreach (var path in hintPaths.Where(p => InPackageFolder(p, identity))) { + if (File.Exists(path)) { + // A HintPath reference is both what compiles and what runs. + _records.File(rootId, id, path); + _records.CompileFile(rootId, id, path); + } else { + missing.Add(path); + } + } + if (missing.Count > 0) { + _records.Unfetched(rootId, id); + Fail( + $"{identity.Id}:{version}", + $"referenced assembly is not installed ({Rel(missing[0])}); restore the packages.config packages (`nuget restore`, or `msbuild -t:restore -p:RestorePackagesConfig=true`)", + config, + info.Key + ); + } + } + + private static bool InPackageFolder(string path, PackageIdentity identity) { + var prefix = identity.Id + "."; + foreach (var segment in path.Split(Path.DirectorySeparatorChar)) { + if (segment.Length <= prefix.Length || !segment.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + if (NuGetVersion.TryParse(segment.Substring(prefix.Length), out var v) && v.Equals(identity.Version)) return true; + } + return false; + } + + private static List HintPaths(Project project) { + return project.GetItems("Reference") + .Select(item => NormalizeSlashes(item.GetMetadataValue("HintPath"))) + .Where(h => !string.IsNullOrEmpty(h)) + .Select(h => Path.GetFullPath(h, project.DirectoryPath)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + // MSBuild only turns `\` into `/` on Unix when a value's first segment is + // an existing directory, so a HintPath into a missing packages folder keeps + // its backslashes. + private static string NormalizeSlashes(string path) { + return Path.DirectorySeparatorChar == '/' ? path.Replace('\\', '/') : path; + } + + // NuGet's short folder name of a build's framework, as restore derives it + // from the two monikers; null when unrecognized. + private static string? ShortFrameworkName(Project build) => + ShortFrameworkName(build.GetPropertyValue("TargetFrameworkMoniker"), build.GetPropertyValue("TargetPlatformMoniker")); + + private static string? ShortFrameworkName(string moniker, string platformMoniker) { + if (string.IsNullOrEmpty(moniker)) return null; + try { + return NuGetFramework.ParseComponents(moniker, platformMoniker).GetShortFolderName(); + } catch (ArgumentException) { + return null; + } + } + + // Short folder name of a legacy project's single framework, e.g. net48. + private static string LegacyFrameworkName(Project project) { + if (ShortFrameworkName(project) is { } name) return name; + var version = project.GetPropertyValue("TargetFrameworkVersion"); + return string.IsNullOrEmpty(version) ? "unknown" : $"net{version.TrimStart('v').Replace(".", "")}"; + } + + // The inner builds of a multi-targeting project, by target framework; none + // for a project with a single framework. + private static List Frameworks(Project project) { + var multi = project.GetPropertyValue("TargetFrameworks"); + if (string.IsNullOrEmpty(project.GetPropertyValue("TargetFramework")) && !string.IsNullOrEmpty(multi)) { + return multi.Split(';').Select(f => f.Trim()).Where(f => f.Length > 0).Distinct().ToList(); + } + return new List(); + } + + // Per inner build of a multi-targeting project, whose outer evaluation has + // no Compile items or TargetPath, or of the project's single framework. An + // excluded project is only evaluated, never built. + private void EmitPaths(ProjectInfo info, Project project, List frameworks) { + var inner = frameworks.Where(f => _configs.Matches(new[] { f })).ToList(); + var single = project.GetPropertyValue("TargetFramework"); + if (inner.Count == 0 || !string.IsNullOrEmpty(single)) { + EmitBuild(info, project, null, string.IsNullOrEmpty(single) ? LegacyFrameworkName(project) : single); + return; + } + foreach (var tfm in inner) EmitBuild(info, project, tfm, tfm); + } + + // Roslyn MSBuildWorkspace's design-time build, minus its command-line parsing: + // https://github.com/dotnet/roslyn/blob/d53e65ceaf037efb4f0c43c5a2ae4a3e19f5a59f/src/Workspaces/MSBuild/BuildHost/Build/ProjectBuildManager.cs#L28-L58 + private static readonly Dictionary DesignTimeProperties = new(StringComparer.OrdinalIgnoreCase) { + ["BuildingProject"] = "false", + ["BuildProjectReferences"] = "false", + ["DesignTimeBuild"] = "true", + ["ShouldUnsetParentConfigurationAndPlatform"] = "false", + ["SkipCompilerExecution"] = "true", + }; + + // WPF's, which generates the XAML code-behind sources. + private const string MarkupCompilationTarget = "DesignTimeMarkupCompilation"; + + // As the compiler task receives it; a failed design-time build fails the + // run, and leaves the evaluated values for --ignore-unresolved. + private void EmitBuild(ProjectInfo info, Project project, string? innerFramework, string config) { + var props = new Dictionary(project.GlobalProperties, StringComparer.OrdinalIgnoreCase); + if (innerFramework != null) props["TargetFramework"] = innerFramework; + ProjectInstance? compiled = null; + if (!info.Excluded) { + var error = DesignTimeBuild(project, props, out compiled); + if (error != null) { + _records.ProjectInfo(info.Key, config, "BuildError", error); + Fail(info.Key, $"the design-time build failed: {error}", config, info.Key); + } + } + var instance = compiled; + if (instance == null) { + try { + instance = EvaluatedWithImplicitDefines(innerFramework == null ? project : LoadInnerBuild(project, innerFramework)); + } catch (InvalidProjectFileException e) { + _records.Unscannable(config, $"could not evaluate the {config} build: {Text.FirstLine(e.Message)}", info.Key); + return; + } + } + var language = instance.GetPropertyValue("Language"); + var isFSharp = string.Equals(language, "F#", StringComparison.OrdinalIgnoreCase); + var isVb = string.Equals(language, "VB", StringComparison.OrdinalIgnoreCase); + var sources = isFSharp ? FSharpCompileOrder(instance) : instance.GetItems("Compile"); + foreach (var source in FullPaths(sources)) _records.ProjectSrc(info.Key, source, config); + var targetPath = NormalizeSlashes(instance.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) { + _records.ProjectTgt(info.Key, Path.GetFullPath(targetPath, project.DirectoryPath), config); + } + foreach (var property in new[] { "TargetFrameworkMoniker", "Language", "LangVersion", "AssemblyName", "Nullable" }) { + var value = instance.GetPropertyValue(property); + if (!string.IsNullOrEmpty(value)) _records.ProjectInfo(info.Key, config, property, value); + } + // Unrecognized, the alias stands in. + if (ShortFrameworkName(instance.GetPropertyValue("TargetFrameworkMoniker"), instance.GetPropertyValue("TargetPlatformMoniker")) is { } framework) { + _records.ProjectInfo(info.Key, config, "Framework", framework); + } + foreach (var reference in instance.GetItems("FrameworkReference").Select(i => i.EvaluatedInclude).Distinct()) { + _records.ProjectInfo(info.Key, config, "FrameworkReference", reference); + } + foreach (var item in instance.GetItems("Using")) { + _records.ProjectUsing( + info.Key, config, item.EvaluatedInclude, item.GetMetadataValue("Alias"), + string.Equals(item.GetMetadataValue("Static"), "true", StringComparison.OrdinalIgnoreCase) + ); + } + // Csc, Vbc and Fsc all take ReferencePathWithRefAssemblies; Fsc takes no + // Analyzer; Vbc takes FinalDefineConstants, the others DefineConstants. + if (compiled != null) { + foreach (var path in FullPaths(compiled.GetItems("ReferencePathWithRefAssemblies"))) { + _records.ProjectInfo(info.Key, config, "Reference", path); + } + if (!isFSharp) { + foreach (var path in FullPaths(compiled.GetItems("Analyzer"))) _records.ProjectInfo(info.Key, config, "Analyzer", path); + } + } + var finalDefines = isVb ? instance.GetPropertyValue("FinalDefineConstants") : ""; + var defines = finalDefines.Length > 0 ? finalDefines : instance.GetPropertyValue("DefineConstants"); + foreach (var symbol in defines + .Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .Distinct(StringComparer.Ordinal)) { + _records.ProjectInfo(info.Key, config, "DefineConstant", symbol); + } + } + + // One build session for every build of the run, so a project that several + // others reference is evaluated and asked for its output once. + private BuildManager? _designTimeBuilds; + private readonly FirstErrorLogger _designTimeErrors = new(); + + private static IEnumerable FullPaths(IEnumerable items) => items + .Select(item => item.GetMetadataValue("FullPath")) + .Where(full => !string.IsNullOrEmpty(full)) + .Select(full => Path.GetFullPath(full)) + .Distinct(StringComparer.Ordinal); + + // As the F# SDK's FSharpSourceCodeCompileOrder target orders them for Fsc; + // idempotent once that target has run. + private static IEnumerable FSharpCompileOrder(ProjectInstance instance) { + IEnumerable Ordered(string order) => + instance.GetItems("Compile").Where(item => item.GetMetadataValue("CompileOrder") == order); + return Ordered("CompileFirst") + .Concat(instance.GetItems("CompileBefore")) + .Concat(Ordered("CompileBefore")) + .Concat(Ordered("")) + .Concat(Ordered("CompileAfter")) + .Concat(instance.GetItems("CompileAfter")) + .Concat(Ordered("CompileLast")); + } + + // Null on success, else the build's first error. Any exception is one + // build's failure, never the run's. + private string? DesignTimeBuild(Project project, Dictionary buildProperties, out ProjectInstance? instance) { + instance = null; + var props = new Dictionary(buildProperties, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in DesignTimeProperties) props[key] = value; + try { + var result = BuildSession(project.ProjectCollection).BuildRequest(new BuildRequestData( + project.FullPath, + props, + null, + project.Targets.ContainsKey(MarkupCompilationTarget) ? new[] { "Compile", MarkupCompilationTarget } : new[] { "Compile" }, + null, + BuildRequestDataFlags.ProvideProjectStateAfterBuild + )); + if (result.OverallResult == BuildResultCode.Success && result.ProjectStateAfterBuild is { } state) { + instance = state; + return null; + } + return _designTimeErrors.FirstOf(result.SubmissionId) + ?? (result.Exception is { } failure ? Text.FirstLine(failure.Message) : "the Compile target failed"); + } catch (Exception e) { + return Text.FirstLine(e.Message); + } + } + + // The SDK's implicit framework symbols (NET8_0_OR_GREATER, ...) exist only + // once its AddImplicitDefineConstants target has run. + private ProjectInstance EvaluatedWithImplicitDefines(Project build) { + var instance = build.CreateProjectInstance(); + if (!instance.Targets.ContainsKey("AddImplicitDefineConstants")) return instance; + try { + BuildSession(build.ProjectCollection).BuildRequest(new BuildRequestData(instance, new[] { "AddImplicitDefineConstants" })); + } catch (Exception e) { + Log($"{build.FullPath}: implicit preprocessor symbols unavailable: {Text.FirstLine(e.Message)}"); + } + return instance; + } + + private BuildManager BuildSession(ProjectCollection collection) { + if (_designTimeBuilds == null) { + _designTimeBuilds = BuildManager.DefaultBuildManager; + _designTimeBuilds.BeginBuild(new BuildParameters(collection) { + Loggers = new Microsoft.Build.Framework.ILogger[] { _designTimeErrors }, + // Only errors are read; the engine then skips its other messages and + // drops the tasks' before any logger sees them. + OnlyLogCriticalEvents = true, + ProjectLoadSettings = RestoreLoadSettings, + }); + } + return _designTimeBuilds; + } + + private void EndDesignTimeBuilds() { + _designTimeBuilds?.EndBuild(); + _designTimeBuilds = null; + } + + private sealed class FirstErrorLogger : Microsoft.Build.Framework.ILogger { + private readonly ConcurrentDictionary _first = new(); + public Microsoft.Build.Framework.LoggerVerbosity Verbosity { get; set; } = Microsoft.Build.Framework.LoggerVerbosity.Quiet; + public string? Parameters { get; set; } + + public string? FirstOf(int submissionId) => _first.TryGetValue(submissionId, out var e) ? e : null; + + public void Initialize(Microsoft.Build.Framework.IEventSource eventSource) { + eventSource.ErrorRaised += (_, e) => _first.TryAdd( + e.BuildEventContext?.SubmissionId ?? -1, + string.IsNullOrEmpty(e.Code) ? e.Message : $"{e.Code}: {e.Message}" + ); + } + + public void Shutdown() { } + } + + // The repository files declaring the project's dependencies; SDK- and + // package-provided definitions are not the user's to change. + private void EmitBuildFiles(ProjectInfo info, Project project) { + _records.ProjectBuild(info.Key, info.Key); + var foreign = new[] { + "NetCoreRoot", "MSBuildExtensionsPath", "MSBuildSDKsPath", "MSBuildToolsPath", "NuGetPackageRoot", + } + .Select(project.GetPropertyValue) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => Path.TrimEndingDirectorySeparator(Path.GetFullPath(p)) + Path.DirectorySeparatorChar) + .ToList(); + var definedIn = new HashSet(); + foreach (var itemType in new[] { "PackageReference", "PackageVersion", "GlobalPackageReference" }) { + foreach (var item in project.GetItems(itemType)) { + if (string.Equals(item.GetMetadataValue("IsImplicitlyDefined"), "true", StringComparison.OrdinalIgnoreCase)) continue; + if (item.Xml?.ContainingProject is { } file) definedIn.Add(file); + } + } + var files = new SortedSet(StringComparer.Ordinal); + foreach (var element in definedIn) { + var file = element.FullPath; + if (string.IsNullOrEmpty(file) || SamePath(file, info.FullPath)) continue; + if (foreign.Any(root => file.StartsWith(root, StringComparison.OrdinalIgnoreCase))) continue; + files.Add(Path.GetFullPath(file)); + } + foreach (var file in files) _records.ProjectBuild(info.Key, Rel(file)); + } +} 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..08cc4cefd --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace Socket.Facts.Dotnet; + +// Writes the TSV records line protocol shared with the JVM build-tool scripts +// (grammar in records.mts). +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, and `\n` on every OS: the parser splits on `\n` and reads the + // first tag verbatim. + _writer = new StreamWriter(path, append: false, new UTF8Encoding(false)) { NewLine = "\n" }; + } + + private 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 sdkVersion) => Rec("meta", "dotnet", sdkVersion, ""); + + public void BuildRoot(string path) => Rec("buildRoot", path); + + public void Entry(string path) => Rec("entry", path); + + public void Project(string projectKey, string name, string version, string dir) => + Rec("project", projectKey, "", name, version, dir); + + public void ProjectSrc(string projectKey, string path, string config) => Rec("projectSrc", projectKey, path, config); + + public void ProjectTgt(string projectKey, string path, string config) => Rec("projectTgt", projectKey, path, config); + + public void ProjectInfo(string projectKey, string config, string name, string value) => + Rec("projectInfo", projectKey, config, name, value); + + public void ProjectUsing(string projectKey, string config, string ns, string alias, bool isStatic) => + Rec("projectUsing", projectKey, config, ns, alias, isStatic ? "1" : "0"); + + public void ProjectBuild(string projectKey, string path) => Rec("projectBuild", projectKey, path); + + public void Member(string path) => Rec("member", path); + + public void ExcludedProject(string projectKey) => Rec("excludedProject", projectKey); + + public void Root(string rootId, string projectKey, string config, bool prod, string framework, string runtime, string alias) => + Rec("root", rootId, projectKey, config, prod ? "1" : "0", framework, runtime, alias); + + public void Node(string rootId, string coordId, string name, string version, bool direct, string project) => + Rec("node", rootId, coordId, "", name, version, "", "", direct ? "1" : "0", project); + + 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 CompileFile(string rootId, string coordId, string path) => Rec("compileFile", rootId, coordId, path); + + public void Unfetched(string rootId, string coordId) => Rec("unfetched", rootId, coordId); + + public void Scanned(string config) => Rec("scanned", config); + + public void Failure(string coord, string detail, string config, string projectKey) => + Rec("failure", coord, detail, config, projectKey); + + public void Unscannable(string config, string detail, string projectKey) => + Rec("unscannable", config, detail, projectKey); + + 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/Restorer.cs b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs new file mode 100644 index 000000000..128ca9393 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace Socket.Facts.Dotnet; + +internal sealed record RestoreError(string Code, string Message, string ProjectFile); + +internal sealed record RestoreOutcome( + int ExitCode, + bool TimedOut, + DateTime StartedUtc, + List Errors +) { + public bool Succeeded => !TimedOut && ExitCode == 0; +} + +// Runs the user's own `dotnet restore`: the same host, SDK selection +// (global.json), environment, nuget.config hierarchy and credential providers +// as when they run it themselves, so whatever restores for them restores here. +internal static class Restorer { + // Bounds a restore that hangs, e.g. on an unreachable feed. + public const int TimeoutSec = 1800; + + // MSBuild's canonical error format: `origin: error CODE: text [project]`, + // which the file logger prefixes with the building node's id (`1>`). + private static readonly Regex ErrorLine = new( + @"^(?.*?):\s*error\s*(?[A-Za-z]+\d+)?\s*:\s*(?.*?)(?:\s+\[(?[^\[\]]+)\])?\s*$", + RegexOptions.Compiled + ); + + public static RestoreOutcome Run(ToolOptions opts, string target, Action log) { + var errorLog = Path.Combine(Path.GetTempPath(), $"socket-facts-restore-{Guid.NewGuid():N}.log"); + var psi = new ProcessStartInfo(opts.Dotnet) { + UseShellExecute = false, + // Inherit stdout/stderr: the output reaches the user as their own + // restore output would, and no pipe can be held open by a child. + RedirectStandardInput = true, + WorkingDirectory = Path.GetDirectoryName(opts.EntryPath)!, + }; + psi.ArgumentList.Add("restore"); + psi.ArgumentList.Add(target); + // Socket reports advisories itself; auditing only adds network calls and + // NU190x warnings a project may promote to errors. + psi.ArgumentList.Add("-p:NuGetAudit=false"); + // No lingering MSBuild nodes holding this process's handles. + psi.ArgumentList.Add("-nodeReuse:false"); + psi.ArgumentList.Add("-fileLogger"); + psi.ArgumentList.Add($"-fileLoggerParameters:LogFile={errorLog};ErrorsOnly;Encoding=UTF-8"); + foreach (var arg in opts.RestoreArgs) psi.ArgumentList.Add(arg); + log($"running: {opts.Dotnet} {string.Join(' ', psi.ArgumentList)}"); + + var started = DateTime.UtcNow; + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"could not start `{opts.Dotnet}`"); + process.StandardInput.Close(); + var timedOut = !process.WaitForExit(TimeoutSec * 1000); + if (timedOut) { + try { + process.Kill(entireProcessTree: true); + } catch (InvalidOperationException) { + // Exited between the timeout and the kill. + } + process.WaitForExit(); + } + var errors = ReadErrors(errorLog); + Text.TryDelete(errorLog); + return new RestoreOutcome(timedOut ? -1 : process.ExitCode, timedOut, started, errors); + } + + private static bool IsProjectFile(string path) => + path.EndsWith("proj", StringComparison.OrdinalIgnoreCase); + + private static List ReadErrors(string path) { + var errors = new List(); + if (!File.Exists(path)) return errors; + foreach (var line in File.ReadAllLines(path)) { + var m = ErrorLine.Match(Regex.Replace(line.Trim().TrimStart('\uFEFF'), @"^\d+>", "")); + if (!m.Success) continue; + // The error's origin is the file it points into, the project for a + // NuGet error; the bracketed file is what was being built, which for a + // solution restore is the solution. + var origin = Regex.Replace(m.Groups["origin"].Value.Trim(), @"\(\d+(,\d+)*\)$", ""); + var built = m.Groups["project"].Value; + var project = IsProjectFile(origin) && File.Exists(origin) + ? origin + : IsProjectFile(built) && File.Exists(built) ? built : ""; + errors.Add(new RestoreError(m.Groups["code"].Value, m.Groups["text"].Value, project)); + } + return errors; + } +} diff --git a/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs b/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs new file mode 100644 index 000000000..f3d42d0a8 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Microsoft.Build.Construction; + +namespace Socket.Facts.Dotnet; + +// A solution entry: its MSBuild-format members, and the global properties +// MSBuild's solution metaproject passes each of them (Solution* properties +// and the project configuration the active solution configuration maps to). +internal sealed class SolutionContext { + public readonly Dictionary Members = new(StringComparer.Ordinal); + private readonly string _solutionPath; + private readonly string _configurationKey; + + public SolutionContext(string entry, IReadOnlyDictionary globalProperties) { + // A filter (.slnf) names its solution; the Solution* properties describe + // that solution, as when MSBuild builds the filter. + HashSet? filtered = null; + _solutionPath = entry; + if (entry.EndsWith(".slnf", StringComparison.OrdinalIgnoreCase)) { + (_solutionPath, filtered) = ReadFilter(entry); + } + var solution = SolutionFile.Parse(_solutionPath); + var configuration = globalProperties.TryGetValue("Configuration", out var c) && c.Length > 0 + ? c + : solution.GetDefaultConfigurationName(); + var platform = globalProperties.TryGetValue("Platform", out var p) && p.Length > 0 + ? p + : solution.GetDefaultPlatformName(); + _configurationKey = $"{configuration}|{platform}"; + // Like the solution's own build, restore covers only the projects the + // active solution configuration builds; a solution without + // configurations builds them all. + var configured = solution.SolutionConfigurations.Count > 0; + foreach (var project in solution.ProjectsInOrder) { + if (!IsMSBuildProject(project)) continue; + if (configured + && !(project.ProjectConfigurations.TryGetValue(_configurationKey, out var mapping) && mapping.IncludeInBuild)) { + continue; + } + var full = Path.GetFullPath(project.AbsolutePath); + if (filtered != null && !filtered.Contains(full)) continue; + if (File.Exists(full)) Members[full] = project; + } + } + + public void Apply(string projectPath, Dictionary props) { + props["SolutionDir"] = Path.GetDirectoryName(_solutionPath) + Path.DirectorySeparatorChar; + props["SolutionExt"] = Path.GetExtension(_solutionPath); + props["SolutionFileName"] = Path.GetFileName(_solutionPath); + props["SolutionName"] = Path.GetFileNameWithoutExtension(_solutionPath); + props["SolutionPath"] = _solutionPath; + if (Members.TryGetValue(projectPath, out var member) + && member.ProjectConfigurations.TryGetValue(_configurationKey, out var mapped)) { + props["Configuration"] = mapped.ConfigurationName; + props["Platform"] = mapped.PlatformName; + } + } + + // A temporary solution filter over `members`, for restoring a subset of the + // solution with the solution's own context. + public string WriteFilter(IReadOnlyList members) { + var solutionDir = Path.GetDirectoryName(_solutionPath)!; + var path = Path.Combine(Path.GetTempPath(), $"socket-facts-{Guid.NewGuid():N}.slnf"); + var doc = new { + solution = new { + path = _solutionPath, + projects = members + .Select(m => Members.TryGetValue(m, out var p) ? p.RelativePath : Path.GetRelativePath(solutionDir, m)) + .ToArray(), + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(doc)); + return path; + } + + // Solution folders, website folders and shared projects (.shproj, imported + // into others) are not projects MSBuild builds on their own. + private static bool IsMSBuildProject(ProjectInSolution project) { + return project.ProjectType == SolutionProjectType.KnownToBeMSBuildFormat + || (project.ProjectType == SolutionProjectType.Unknown + && project.AbsolutePath.EndsWith("proj", StringComparison.OrdinalIgnoreCase)); + } + + // A solution filter: {"solution": {"path": , "projects": []}}. + private static (string Solution, HashSet Projects) ReadFilter(string filter) { + using var doc = JsonDocument.Parse(File.ReadAllText(filter)); + var solution = doc.RootElement.GetProperty("solution"); + var solutionPath = FullPath(Path.GetDirectoryName(filter)!, solution.GetProperty("path").GetString() ?? ""); + var solutionDir = Path.GetDirectoryName(solutionPath)!; + var projects = new HashSet(StringComparer.Ordinal); + if (solution.TryGetProperty("projects", out var list)) { + foreach (var p in list.EnumerateArray()) { + if (p.GetString() is { Length: > 0 } rel) projects.Add(FullPath(solutionDir, rel)); + } + } + return (solutionPath, projects); + } + + private static string FullPath(string dir, string rel) { + return Path.GetFullPath(Path.Combine(dir, rel.Replace('\\', Path.DirectorySeparatorChar))); + } +} 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..9f4913750 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs @@ -0,0 +1,157 @@ +namespace Socket.Facts.Dotnet; + +internal sealed class ToolOptions { + public const string Usage = """ + Usage: socket-facts-dotnet --records --entry [options] [-- ] + + Options: + --records Records output file (TSV line protocol). Required. + --entry Solution (.sln/.slnx/.slnf) or project file to scan. Required. + --dotnet dotnet executable that runs `dotnet restore` (default: dotnet). + --with-files Also emit resolved artifact and source paths. + --include-configs Target framework globs; only matching ones are read. + --exclude-configs Target framework globs to skip (applied after includes). + --exclude-paths Anchored regex sources over build-root-relative project dirs. + --exclude-base Directory --exclude-paths is relative to (default: the build root). + --workspaces Only list the projects (no restore, no dependencies). + --members Only list the projects the entry builds (no evaluation). + --verbose Log progress to stderr. + + Arguments after `--` go to `dotnet restore` verbatim. MSBuild properties among + them (-p:K=V, --property:K=V, /p:K=V) and --runtime/-r also apply to project + evaluation, so evaluation and restore see the same build. + """; + + public string RecordsPath = ""; + public string EntryPath = ""; + public string Dotnet = "dotnet"; + public bool WithFiles; + public string IncludeConfigs = ""; + public string ExcludeConfigs = ""; + public string ExcludePaths = ""; + public string ExcludeBase = ""; + public bool Workspaces; + public bool Members; + public bool Verbose; + public List RestoreArgs = new(); + public Dictionary GlobalProperties = new(StringComparer.OrdinalIgnoreCase); + + public static ToolOptions Parse(string[] args) { + var opts = new ToolOptions(); + var i = 0; + for (; i < args.Length; i += 1) { + var arg = args[i]; + if (arg == "--") { + i += 1; + break; + } + switch (arg) { + case "--records": + opts.RecordsPath = Next(args, ref i, arg); + break; + case "--entry": + opts.EntryPath = Next(args, ref i, arg); + break; + case "--dotnet": + opts.Dotnet = 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 "--exclude-paths": + opts.ExcludePaths = Next(args, ref i, arg); + break; + case "--exclude-base": + opts.ExcludeBase = RealPaths.Dir(Path.GetFullPath(Next(args, ref i, arg))); + break; + case "--workspaces": + opts.Workspaces = true; + break; + case "--members": + opts.Members = true; + break; + case "--verbose": + opts.Verbose = true; + break; + default: + throw new ArgumentException($"Unknown argument `{arg}`"); + } + } + var runtimes = new List(); + for (; i < args.Length; i += 1) { + var arg = args[i]; + opts.RestoreArgs.Add(arg); + if (TryParseProperty(arg, out var props)) { + foreach (var (key, value) in props) opts.GlobalProperties[key] = value; + continue; + } + string? rid = null; + if ((arg == "--runtime" || arg == "-r") && i + 1 < args.Length) { + i += 1; + rid = args[i]; + opts.RestoreArgs.Add(rid); + } else if (arg.StartsWith("--runtime=", StringComparison.Ordinal)) { + rid = arg.Substring("--runtime=".Length); + } + if (!string.IsNullOrEmpty(rid)) runtimes.Add(rid); + } + // What `dotnet restore --runtime` forwards: the RuntimeIdentifiers property. + if (runtimes.Count > 0) opts.GlobalProperties["RuntimeIdentifiers"] = string.Join(';', runtimes); + if (string.IsNullOrEmpty(opts.RecordsPath) || string.IsNullOrEmpty(opts.EntryPath)) { + throw new ArgumentException("--records and --entry are required"); + } + opts.EntryPath = RealPaths.Of(opts.EntryPath); + 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]; + } + + // MSBuild's property switch: `-p:A=1;B=2`, also spelled /p:, -property:, + // --property: (case-insensitive). Per the MSBuild command-line reference, + // `;` or `,` separates properties; a quoted value keeps its separators. + private static bool TryParseProperty(string arg, out List<(string Key, string Value)> props) { + props = new(); + string? rest = null; + foreach (var prefix in new[] { "-p:", "/p:", "--property:", "-property:", "/property:" }) { + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + rest = arg.Substring(prefix.Length); + break; + } + } + if (rest == null) return false; + foreach (var part in SplitUnquoted(rest)) { + var eq = part.IndexOf('='); + if (eq <= 0) continue; + props.Add((part.Substring(0, eq).Trim(), Unquote(part.Substring(eq + 1).Trim()))); + } + return props.Count > 0; + } + + private static IEnumerable SplitUnquoted(string s) { + var start = 0; + var quoted = false; + for (var i = 0; i < s.Length; i += 1) { + if (s[i] == '"') { + quoted = !quoted; + } else if (!quoted && (s[i] == ';' || s[i] == ',')) { + yield return s.Substring(start, i - start); + start = i + 1; + } + } + yield return s.Substring(start); + } + + private static string Unquote(string v) { + return v.Length >= 2 && v[0] == '"' && v[^1] == '"' ? v.Substring(1, v.Length - 2) : v; + } +} 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 100755 index 000000000..0ebeb6cd2 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/build-tool.sh @@ -0,0 +1,16 @@ +#!/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 it in, so no stale file survives. +staging="$(mktemp -d "${TMPDIR:-/tmp}/socket-facts-dotnet.XXXXXX")" +dotnet restore socket-facts-dotnet.csproj --locked-mode --nologo -v quiet +dotnet publish socket-facts-dotnet.csproj -c Release -o "$staging" --no-restore --nologo -v quiet +rm -f "$staging"/*.pdb +if [ -d publish ]; then + mv publish "$staging.old" +fi +mv "$staging" publish +echo "socket-facts-dotnet tool: $(pwd)/publish/socket-facts-dotnet.dll" diff --git a/src/commands/manifest/scripts/dotnet-tool/packages.lock.json b/src/commands/manifest/scripts/dotnet-tool/packages.lock.json new file mode 100644 index 000000000..2e490b0e0 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/packages.lock.json @@ -0,0 +1,279 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "Microsoft.Build": { + "type": "Direct", + "requested": "[17.3.2, )", + "resolved": "17.3.2", + "contentHash": "k5+7CfF/aM/hykfnrF93VhbUnhGfpJkGaD+ce8VlhLnOqDyts7WV+8Up3YCP6qmXMZFeeH/Cp23w2wSliP0mBw==", + "dependencies": { + "Microsoft.Build.Framework": "17.3.2", + "Microsoft.NET.StringTools": "17.3.2", + "System.Collections.Immutable": "6.0.0", + "System.Configuration.ConfigurationManager": "6.0.0", + "System.Reflection.Metadata": "6.0.0", + "System.Reflection.MetadataLoadContext": "6.0.0", + "System.Security.Principal.Windows": "5.0.0", + "System.Text.Encoding.CodePages": "6.0.0", + "System.Text.Json": "6.0.0", + "System.Threading.Tasks.Dataflow": "6.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "Direct", + "requested": "[17.3.2, )", + "resolved": "17.3.2", + "contentHash": "iGfJt6rm/vIEowBG6qNX2Udn7UagI6MzalDwwdkDUkSwhvvrGCnDLphyRABAwrrsWHTD/LJlUAJsbW1SkC4CUQ==", + "dependencies": { + "System.Security.Permissions": "6.0.0" + } + }, + "Microsoft.Build.Locator": { + "type": "Direct", + "requested": "[1.7.8, )", + "resolved": "1.7.8", + "contentHash": "sPy10x527Ph16S2u0yGME4S6ohBKJ69WfjeGG/bvELYeZVmJdKjxgnlL8cJJJLGV/cZIRqSfB12UDB8ICakOog==" + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.1, )", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "NuGet.Common": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "EZulYMsyoMAkJjyKQfoUc0mEJ5D+cto/uoAgnNtHEVAFFNieToRuL3rLI3We+0tr63XfF0iOND8hAVCwkgiDag==", + "dependencies": { + "NuGet.Frameworks": "6.0.6" + } + }, + "NuGet.Configuration": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "yNqg6DmSF65DotTcKpT2f6kFygI8Oi6dHi0jggk+jT+LHTQBiQauVuLOj4VRyEDeKchrltsGjU841tzHuCjiHQ==", + "dependencies": { + "NuGet.Common": "6.0.6", + "System.Security.Cryptography.ProtectedData": "4.4.0" + } + }, + "NuGet.DependencyResolver.Core": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "blOZghvcf23rh66rX6A6Ct7qHlALjrTce1KlGtFiq1uhU/qsbkzzySv3ZN/A13aUFpH8lRvd8C4oHcFwgr/WjQ==", + "dependencies": { + "NuGet.Configuration": "6.0.6", + "NuGet.LibraryModel": "6.0.6", + "NuGet.Protocol": "6.0.6" + } + }, + "NuGet.Frameworks": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "Cntffs4It+LZpNWT14s5IVSCooTVGOlnSuO/wV097H/n1Uws0b+22+I/tO7lt8ivyvm2RckeXXO6IgCztovWlQ==" + }, + "NuGet.LibraryModel": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "nUq9/tpOYrtpqFvqy6vhQKsP52JHIT0V7InbZq+SucvuApR1Qm7liktVCJCRWUsyY0ASGfTgV3XyjaYkH/ZInw==", + "dependencies": { + "NuGet.Common": "6.0.6", + "NuGet.Versioning": "6.0.6" + } + }, + "NuGet.Packaging": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "oWk8ZCmAnljB5A1gshZNEHtR0/D04yXp5fDd8qWGcWtnFUIiYLzQvrRHQ73bGDxRNXX+VRSc3r/jphh4im0FTw==", + "dependencies": { + "Newtonsoft.Json": "13.0.1", + "NuGet.Configuration": "6.0.6", + "NuGet.Versioning": "6.0.6", + "System.Security.Cryptography.Cng": "5.0.0", + "System.Security.Cryptography.Pkcs": "5.0.0" + } + }, + "NuGet.ProjectModel": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "3PnngLphRDLUGPIEpYkU1J4TWe+bxTYtT0XV990gy3CCcott4SaBwpihUBwDb5mdZOf5aoYbjFp9KZh9mZnOeA==", + "dependencies": { + "NuGet.DependencyResolver.Core": "6.0.6" + } + }, + "NuGet.Protocol": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "qq9uzbRITI9CogNkTZ9Hqooca79gDeLdrELWvQfAj6LUHyE0ihOaeOSYi6o6U1wUxilFytX0se+noAony4gucQ==", + "dependencies": { + "NuGet.Packaging": "6.0.6" + } + }, + "NuGet.Versioning": { + "type": "Direct", + "requested": "[6.0.6, )", + "resolved": "6.0.6", + "contentHash": "9Y2fBEDbR6VRVsXUP5UJQYTsUNHyROguhJR2JGGg6lbahzBKnV5oJjtAxYafA5ffbCE7szNRLwQzDvj3CgPLmw==" + }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.3.2", + "contentHash": "3sIZECEDSY9kP7BqPLOSIHLsiqv0TSU5cIGAMung+NrefIooo1tBMVRt598CGz+kUF1xlbOsO8nPAYpgfokx/Q==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "7T+m0kDSlIPTHIkPMIu6m6tV6qsMqJpvQWW2jIc2qi7sn40qxFo0q+7mEQAhMPXZHMKnWrnv47ntGlM/ejvw3g==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "6.0.0", + "System.Security.Permissions": "6.0.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "MTvUIktmemNB+El0Fgw9egyqT9AYSIk6DTJeoDSpc3GIHxHCMo8COqkWT1mptX5tZ1SlQ6HJZ0OsSvMth1c12w==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "sffDOcex1C3HO5kDolOYcWXTwRpZY/LvJujM6SMjn63fWMJWchYAAmkoAJXlbpZ5yf4d+KMgxd+LeETa4gD9sQ==", + "dependencies": { + "System.Collections.Immutable": "6.0.0" + } + }, + "System.Reflection.MetadataLoadContext": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "SuK8qTHbmG3PToLo1TEq8YSfY31FiKhASBmjozUTAleDgiX4H2X4jm0VPFb+K2soSSmYPyHTpHp35TctfNtDzQ==", + "dependencies": { + "System.Collections.Immutable": "6.0.0", + "System.Reflection.Metadata": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "AUADIc0LIEQe7MzC+I0cl0rAT8RrTAKFHl53yHjEUzNVIaUlhFY11vc2ebiVJzVBuOzun6F7FBA+8KAbGTTedQ==" + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==", + "dependencies": { + "System.Formats.Asn1": "5.0.0" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "9TPLGjBCGKmNvG8pjwPeuYy0SMVmGZRwlTZvyPHDbYv/DRkoeumJdfumaaDNQzVGMEmbWtg07zUpSW9q70IlDQ==", + "dependencies": { + "System.Formats.Asn1": "5.0.0", + "System.Security.Cryptography.Cng": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "rp1gMNEZpvx9vP0JW0oHLxlf8oSiQgtno77Y4PLUBjSiDYoD77Y8uXHr1Ea5XG4/pIKhqAdxZ8v8OTUtqo9PeQ==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "T/uuc7AklkDoxmcJ7LGkyX1CcSviZuLCa4jg3PekfJ7SU0niF0IVTXwUiNVP9DSpzou2PpxJ+eNY2IfDM90ZCg==", + "dependencies": { + "System.Security.AccessControl": "6.0.0", + "System.Windows.Extensions": "6.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "zaJsHfESQvJ11vbXnNlkrR46IaMULk/gHxYsJphzSF+07kTjPHv+Oc14w6QEOfo3Q4hqLJgStUaYB9DBl0TmWg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "System.Threading.Tasks.Dataflow": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "+tyDCU3/B1lDdOOAJywHQoFwyXIUghIaP2BxG79uvhfTnO+D9qIgjVlL/JV2NTliYbMHpd6eKDmHp2VHpij7MA==" + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "IXoJOXIqc39AIe+CIR7koBtRGMiCt/LPM3lI+PELtDIy9XdyeSrwXFdWV9dzJ2Awl0paLWUaknLxFQ5HpHZUog==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + } + } + } +} 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..fa21d3e30 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj @@ -0,0 +1,44 @@ + + + + Exe + + net6.0 + LatestMajor + 12 + enable + enable + socket-facts-dotnet + Socket.Facts.Dotnet + true + en + + false + + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index 6c48d539c..312ceea7f 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -17,7 +17,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 // Lets a facts file be traced back to the generator release that produced it. @@ -58,6 +58,44 @@ export type SocketFactsSbomProject = AnyPURL & { children: number[] } +// One dotnet build of a project: a target framework, and a runtime where +// restore resolved one. `framework` is the NuGet short folder name, `alias` the +// project's own name for it; ids are those of the facts file's components. +export type ProjectFramework = { + alias: string + framework: string + runtime?: string | undefined + targetFrameworkMoniker?: string | undefined + language?: string | undefined + langVersion?: string | undefined + nullable?: string | undefined + assemblyName?: string | undefined + // Set when the design-time build failed; the other fields then hold the + // evaluated values, without generated sources, references or analyzers. + buildError?: string | undefined + frameworkReferences: string[] + defineConstants: string[] + usings: Array<{ namespace: string; alias?: string; static?: true }> + // What the compiler receives: sources in order, generated ones included, + // and every reference and analyzer assembly. + sources: string[] + references: string[] + analyzers: string[] + targets: string[] + dependencies: string[] + classpath: string[] +} + +// A package's runtime assemblies in one target framework (and runtime); no +// `targets` when restore's assemblies for it are missing. +export type ComponentFramework = { + framework: string + runtime?: string | undefined + targets?: string[] | undefined + // What the project's sources compile against (often reference assemblies). + compile?: string[] | undefined +} + // Resolved on-disk paths for a --with-files run, keyed by component or project // id (a project and its own component share one). `targets` = classpath // entries (jars / module output dirs), absent when the component's artifact @@ -69,4 +107,7 @@ export type ResolvedArtifactPaths = { classpathByProject: Map // Component ids of each project's direct dependencies, keyed by project id. directDependenciesByProject: Map + // dotnet only: the same per target framework. + frameworksByProject?: Map | undefined + frameworksById?: Map | undefined } diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 2e99cd630..10c396585 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -9,6 +9,7 @@ import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // meta tool toolVersion javaVersion // buildRoot path (absolute; the facts file's directory) // entry path (file-addressed builds; build-root-relative) +// member path (dotnet --members: a project the entry builds; build-root-relative) // project projectKey group name version dir // projectSrc projectKey path (--with-files only) // projectTgt projectKey path (--with-files only) @@ -18,6 +19,7 @@ import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) +// compileFile rootId coordId path (dotnet, --with-files only; what sources compile against) // unfetched rootId coordId (--with-files only; requested artifact not fetched) // scanned config // failure coord detail config projectKey (config empty: not placeable in one) @@ -47,6 +49,8 @@ export type RawNode = { project: string // --with-files only. targets: string[] + // dotnet, --with-files only. + compileTargets: string[] unfetched: boolean } @@ -55,10 +59,26 @@ export type RawRoot = { projectKey: string config: string prod: boolean + // dotnet: the target framework's NuGet short folder name, the runtime + // identifier of a runtime-specific target, and the framework's alias in + // the project; empty otherwise. + framework: string + runtime: string + alias: string nodes: Map edges: Array<[string, string]> } +export type RawUsing = { namespace: string; alias: string; static: boolean } + +// One dotnet target framework build of a project, by its alias. +export type RawBuild = { + sources: string[] + targets: string[] + info: Map + usings: RawUsing[] +} + export type RawProject = { projectKey: string group: string @@ -69,6 +89,7 @@ export type RawProject = { targets: string[] // The project's build files, build-root-relative. buildFiles: string[] + builds: Map } export type ParsedRecords = { @@ -81,6 +102,7 @@ export type ParsedRecords = { // directory-addressed build. entry: string projects: Map + members: string[] // Excluded from the scan: not projects of their own, but their paths serve // the kept projects depending on them. excludedProjects: Map @@ -119,6 +141,7 @@ export function parseRecords(text: string): ParsedRecords { buildRoot: '', entry: '', projects: new Map(), + members: [], excludedProjects: new Map(), roots: new Map(), scannedConfigs: [], @@ -136,6 +159,9 @@ export function parseRecords(text: string): ParsedRecords { projectKey: '', config: '', prod: false, + framework: '', + runtime: '', + alias: '', nodes: new Map(), edges: [], } @@ -155,13 +181,26 @@ export function parseRecords(text: string): ParsedRecords { sources: [], targets: [], buildFiles: [], + builds: new Map(), } result.projects.set(key, p) } return p } - for (const rawLine of text.split('\n')) { + const build = (key: string, config: string): RawBuild => { + const builds = project(key).builds + let b = builds.get(config) + if (!b) { + b = { sources: [], targets: [], info: new Map(), usings: [] } + builds.set(config, b) + } + return b + } + + for (const line of text.split('\n')) { + // Tolerates an emitter that writes CRLF line endings. + const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line if (!rawLine) { continue } @@ -178,6 +217,11 @@ export function parseRecords(text: string): ParsedRecords { case 'entry': result.entry = f[1] ?? '' break + case 'member': + if (f[1]) { + result.members.push(f[1]) + } + break case 'project': { const p = project(f[1] ?? '') p.group = f[2] ?? '' @@ -189,11 +233,32 @@ export function parseRecords(text: string): ParsedRecords { case 'projectSrc': if (f[2]) { project(f[1] ?? '').sources.push(f[2]) + if (f[3]) { + build(f[1] ?? '', f[3]).sources.push(f[2]) + } } break case 'projectTgt': if (f[2]) { project(f[1] ?? '').targets.push(f[2]) + if (f[3]) { + build(f[1] ?? '', f[3]).targets.push(f[2]) + } + } + break + case 'projectInfo': + if (f[2] && f[3] && f[4]) { + const info = build(f[1] ?? '', f[2]).info + info.set(f[3], [...(info.get(f[3]) ?? []), f[4]]) + } + break + case 'projectUsing': + if (f[2] && f[3]) { + build(f[1] ?? '', f[2]).usings.push({ + namespace: f[3], + alias: f[4] ?? '', + static: bool(f[5]), + }) } break case 'excludedProject': @@ -209,6 +274,9 @@ export function parseRecords(text: string): ParsedRecords { r.projectKey = f[2] ?? '' r.config = f[3] ?? '' r.prod = bool(f[4]) + r.framework = f[5] ?? '' + r.runtime = f[6] ?? '' + r.alias = f[7] ?? '' break } case 'node': { @@ -226,6 +294,7 @@ export function parseRecords(text: string): ParsedRecords { direct: bool(f[8]), project: f[9] ?? '', targets: [], + compileTargets: [], unfetched: false, }) break @@ -252,6 +321,13 @@ export function parseRecords(text: string): ParsedRecords { } break } + case 'compileFile': { + const node = root(f[1] ?? '').nodes.get(f[2] ?? '') + if (node && f[3]) { + node.compileTargets.push(f[3]) + } + break + } case 'scanned': if (f[1]) { scanned.add(f[1]) 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..86d42c2aa --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-nuget.mts @@ -0,0 +1,77 @@ +import type { + FailureCategory, + ResolutionDialect, +} from './resolution-report-render.mts' + +// NuGet's restore error codes, per +// https://learn.microsoft.com/en-us/nuget/reference/errors-and-warnings. +const NOT_FOUND_CODES = new Set(['NU1100', 'NU1101', 'NU1102', 'NU1103']) +// Loading a source's service index or package metadata failed; insecure +// (HTTP) sources. +const SOURCE_CODES = new Set(['NU1301', 'NU1302']) + +// The socket-facts-dotnet tool reports restore errors as `: ` +// and its own findings as plain sentences. +export function classifyNugetFailure(detail: string): FailureCategory { + const text = detail || '' + const code = /^(NU\d{4}|NETSDK\d{4}|MSB\d{4})\b/.exec(text)?.[1] + if (code && NOT_FOUND_CODES.has(code)) { + return 'not-found' + } + const t = text.toLowerCase() + if ( + (code && SOURCE_CODES.has(code)) || + t.includes('unable to load the service index') || + t.includes('response status code does not indicate success') || + t.includes('401 (unauthorized)') || + t.includes('403 (forbidden)') + ) { + return 'repository-or-network' + } + // Any other NuGet, .NET SDK or MSBuild error is about the project, the + // lock file, central package management or framework compatibility. + if ( + code || + t.startsWith('could not evaluate') || + t.startsWith('could not read') || + t.startsWith('the design-time build failed') || + t.includes('share an intermediate output directory') || + t.startsWith('project.json projects are not supported') + ) { + return 'config-problem' + } + return 'other' +} + +export const NUGET_DIALECT: ResolutionDialect = { + label: 'NuGet', + classify: classifyNugetFailure, + categories: [ + { + key: 'not-found', + header: () => ' Not found in any package source:', + showReason: true, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Package source or network error — ${n} could not reach or authenticate to a source:`, + showReason: true, + blocking: true, + }, + { + key: 'config-problem', + header: n => + ` Project or restore configuration problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other resolution failures (reason from ${n}):`, + showReason: true, + blocking: true, + }, + ], +} diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 0abcff773..5cbc30ef4 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 { moduleKey } from './resolution-report.mts' import constants from '../../../constants.mts' import { camelToKebab } from '../../../utils/strings.mts' @@ -92,11 +93,12 @@ const RESOLUTION_REPORT_CONFIG_LIMIT = 20 function dedupInfos }>( infos: T[], ): T[] { + const placesOf = (i: T): string[] => + i.projects.size ? [...i.projects] : [''] const versioned = new Set() for (const i of infos) { - const p = i.coord.split(':') - if (p.length >= 3) { - for (const project of i.projects) { + if (i.coord.split(':').length >= 3) { + for (const project of placesOf(i)) { versioned.add(moduleKey(project, i.coord)) } } @@ -105,7 +107,7 @@ function dedupInfos }>( .filter( i => i.coord.split(':').length >= 3 || - ![...i.projects].every(p => versioned.has(moduleKey(p, i.coord))), + !placesOf(i).every(p => versioned.has(moduleKey(p, i.coord))), ) .sort((a, b) => (a.coord < b.coord ? -1 : a.coord > b.coord ? 1 : 0)) } @@ -475,6 +477,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/run.mts b/src/commands/manifest/scripts/run.mts index 71379b25f..7ee1f6ceb 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' +import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' @@ -8,6 +9,7 @@ import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' +import { getErrorMessageOr } from '../../../utils/errors.mts' import { withTmpDir } from '../../../utils/fs.mts' import type { BuildTool } from './build-tool.mts' @@ -153,9 +155,18 @@ async function assembleFromRecords( out: RunOutput, recordsFile: string, ): Promise { - const text = existsSync(recordsFile) - ? await fs.readFile(recordsFile, 'utf8') - : '' + return assembleRecords(tool, out, await readRecordsFile(recordsFile)) +} + +async function readRecordsFile(recordsFile: string): Promise { + return existsSync(recordsFile) ? await fs.readFile(recordsFile, 'utf8') : '' +} + +function assembleRecords( + tool: BuildTool, + out: RunOutput, + text: string, +): ManifestRunResult { const parsed = parseRecords(text) const factsFileName = socketFactsFileName(tool, parsed.entry || undefined) const { artifactPaths, facts, report } = assembleFacts(parsed) @@ -171,14 +182,18 @@ async function assembleFromRecords( } } -// Missing only in an unbuilt local checkout. Fail loudly: without the extension, -// Maven silently emits an empty SBOM. -function assertMavenExtensionBuilt(jarPath: string): void { - if (existsSync(jarPath)) { +// Missing only in an unbuilt local checkout. Fail loudly: without the +// extension, Maven silently emits an empty SBOM. +function assertBundledAssetBuilt( + assetPath: string, + description: string, + buildScript: string, +): void { + if (existsSync(assetPath)) { return } throw new Error( - `Maven manifest extension jar not found at ${jarPath}. It is bundled in the published CLI; for local dev build it with: bash src/commands/manifest/scripts/maven-extension/build-jar.sh`, + `${description} not found at ${assetPath}. It is bundled in the published CLI; for local dev build it with: bash src/commands/manifest/scripts/${buildScript}`, ) } @@ -189,6 +204,8 @@ export async function runManifestScript( opts: ManifestScriptOptions, ): Promise { switch (tool) { + case 'dotnet': + return await runDotnet(opts) case 'gradle': return await runGradle(opts) case 'sbt': @@ -204,11 +221,13 @@ export async function enumerateWorkspaces( tool: BuildTool, opts: ManifestScriptOptions, ): Promise { - const result = await (tool === 'gradle' - ? enumerateGradleWorkspaces(opts) - : tool === 'sbt' - ? enumerateSbtWorkspaces(opts) - : enumerateMavenWorkspaces(opts)) + const result = await (tool === 'dotnet' + ? runDotnet(opts, '--workspaces') + : tool === 'gradle' + ? enumerateGradleWorkspaces(opts) + : tool === 'sbt' + ? enumerateSbtWorkspaces(opts) + : enumerateMavenWorkspaces(opts)) return { buildRoot: result.buildRoot, code: result.code, @@ -385,7 +404,11 @@ async function invokeMaven( 'maven-extension', 'coana-maven-extension.jar', ) - assertMavenExtensionBuilt(jarPath) + assertBundledAssetBuilt( + jarPath, + 'Maven manifest extension jar', + 'maven-extension/build-jar.sh', + ) return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('maven', opts.projectDir, opts.bin) @@ -425,3 +448,153 @@ async function enumerateMavenWorkspaces( 'socket-maven-workspaces-', ) } + +// `dotnet --version` from the build's directory: the SDK the dotnet host +// selects there (global.json included). A host that finds none says why. +// global.json pins the SDK per directory; every entry of a directory shares +// it, failures included. +const sdkVersionByDir = new Map>() + +function dotnetSdkVersion( + bin: string, + opts: ManifestScriptOptions, +): Promise { + const key = `${bin}\0${opts.projectDir}` + let version = sdkVersionByDir.get(key) + if (!version) { + version = readDotnetSdkVersion(bin, opts) + sdkVersionByDir.set(key, version) + } + return version +} + +async function readDotnetSdkVersion( + bin: string, + opts: ManifestScriptOptions, +): Promise { + const out = await runNeverThrow(bin, ['--version'], { + ...opts, + stdio: 'pipe', + }) + if (out.code !== 0) { + throw new Error( + out.stderr.trim() || + out.stdout.trim() || + `\`${bin} --version\` exited with code ${out.code}`, + ) + } + return out.stdout.trim() +} + +const noSdkWarnings = new Set() + +// True, after a warning, when no .NET SDK runs in `dir` (none installed, or +// none matching its global.json), so its .NET builds are skipped. +export async function skipWithoutDotnetSdk( + bin: string, + dir: string, +): Promise { + try { + await dotnetSdkVersion(bin, { projectDir: dir }) + return false + } catch (e) { + const reason = getErrorMessageOr(e, `\`${bin} --version\` failed`).split( + '\n', + )[0]! + if (!noSdkWarnings.has(reason)) { + noSdkWarnings.add(reason) + logger.warn( + `Skipping .NET builds where no .NET SDK runs (${reason}); their project files are scanned as they are.`, + ) + } + return true + } +} + +// Runs the bundled tool against the build's own dotnet, in `mode` (none: a +// full facts run), and returns its exit and records. +async function invokeDotnetTool( + opts: ManifestScriptOptions, + mode: '--members' | '--workspaces' | undefined, +): Promise<{ out: RunOutput; records: string }> { + if (!opts.entryFile) { + throw new Error('A dotnet build needs a solution or project file.') + } + const toolDll = manifestScriptsPath('dotnet-tool', 'socket-facts-dotnet.dll') + assertBundledAssetBuilt( + toolDll, + 'dotnet manifest tool', + 'dotnet-tool/build-tool.sh', + ) + const bin = resolveBuildToolBin('dotnet', opts.projectDir, opts.bin) + const sdkVersion = await dotnetSdkVersion(bin, opts) + // The tool runs on the newest installed runtime (RollForward=LatestMajor), + // which must be able to load the selected SDK's MSBuild; a preview SDK + // needs a preview runtime, which roll-forward only picks when told to. + const env = sdkVersion.includes('-') + ? { + ...(opts.env ?? process.env), + DOTNET_ROLL_FORWARD_TO_PRERELEASE: '1', + } + : opts.env + return await withTmpDir('socket-dotnet-facts-', async tmp => { + const recordsFile = path.join(tmp, 'records.tsv') + const excludePatterns = serializeExcludePathPatterns(opts.excludePaths) + const args = [ + toolDll, + '--records', + recordsFile, + '--entry', + opts.entryFile!, + '--dotnet', + bin, + ...(mode ? [mode] : []), + ...(opts.withFiles ? ['--with-files'] : []), + ...(opts.includeConfigs + ? ['--include-configs', opts.includeConfigs] + : []), + ...(opts.excludeConfigs + ? ['--exclude-configs', opts.excludeConfigs] + : []), + ...(excludePatterns ? ['--exclude-paths', excludePatterns] : []), + ...(excludePatterns && opts.excludeBase + ? ['--exclude-base', opts.excludeBase] + : []), + ...(opts.stdio === 'inherit' ? ['--verbose'] : []), + '--', + ...(opts.toolOpts ?? []), + ] + const out = await runNeverThrow(bin, args, { ...opts, env }) + return { out, records: await readRecordsFile(recordsFile) } + }) +} + +async function runDotnet( + opts: ManifestScriptOptions, + mode?: '--workspaces' | undefined, +): Promise { + const { out, records } = await invokeDotnetTool(opts, mode) + return assembleRecords('dotnet', out, records) +} + +// Undefined when the SDK cannot read the entry (a .slnx before SDK 9.0.200); +// empty when the listing fails, which the build itself then reports. +export async function listDotnetEntryProjects( + opts: ManifestScriptOptions, +): Promise { + let listing + try { + listing = await invokeDotnetTool({ ...opts, stdio: 'pipe' }, '--members') + } catch { + return [] + } + const { out, records } = listing + if (out.code !== 0) { + return [] + } + const parsed = parseRecords(records) + if (parsed.failures.length) { + return undefined + } + return parsed.members.map(m => path.resolve(opts.projectDir, m)) +} diff --git a/src/commands/manifest/scripts/sidecar.mts b/src/commands/manifest/scripts/sidecar.mts index a19b54212..caeb609d0 100644 --- a/src/commands/manifest/scripts/sidecar.mts +++ b/src/commands/manifest/scripts/sidecar.mts @@ -1,5 +1,7 @@ import type { AnyPURL, + ComponentFramework, + ProjectFramework, ResolvedArtifactPaths, SocketFactsSbom, SocketFactsSbomComponent, @@ -14,6 +16,8 @@ export type SidecarComponentEntry = SocketFactsSbomComponent & { // First-party source roots; `[]` for a genuinely external dependency (still // attempted, nothing to find), not undefined. sources?: string[] | undefined + // dotnet: `targets` per target framework, which differ by framework. + frameworks?: ComponentFramework[] | undefined } export type SidecarProjectEntry = Omit & { @@ -24,6 +28,8 @@ export type SidecarProjectEntry = Omit & { // Ids of this facts file's components[] forming the project's full // transitive classpath across all its configurations. classpath: string[] + // dotnet: each build of the project, by target framework. + frameworks?: ProjectFramework[] | undefined } // Frozen contract with `coana run --compute-artifacts-sidecar`; change only @@ -95,8 +101,15 @@ export function accumulateSidecar( ): void { const paths = (entry: T) => withPaths ? attachPaths(entry, artifactPaths) : { ...entry } + function frameworks(byId: Map | undefined, id: string) { + const list = withPaths ? byId?.get(id) : undefined + return list ? { frameworks: list } : {} + } acc.set(factsFile, { - components: facts.components.map(paths), + components: facts.components.map(c => ({ + ...paths(c), + ...frameworks(artifactPaths.frameworksById, c.id), + })), projects: (facts.projects ?? []).map( ({ children: _children, ...proj }) => ({ ...paths(proj), @@ -104,6 +117,7 @@ export function accumulateSidecar( ...(artifactPaths.directDependenciesByProject.get(proj.id) ?? []), ], classpath: [...(artifactPaths.classpathByProject.get(proj.id) ?? [])], + ...frameworks(artifactPaths.frameworksByProject, proj.id), }), ), }) @@ -143,3 +157,20 @@ export function serializeSidecar( } return result } + +// Tells Coana which ecosystems' artifacts only these facts files attribute. +// Maven's always are (the dynamic-SBOM-inference contract), under its own flag +// every Coana release knows unless another ecosystem's facts are present too. +export function useOnlySocketFactsArtifactsFlags( + sidecar: ResolvedPathsSidecar | undefined, +): string[] { + const types = new Set(['maven']) + for (const entry of Object.values(sidecar ?? {})) { + for (const item of [...entry.projects, ...entry.components]) { + types.add(item.type) + } + } + return types.size === 1 + ? ['--maven-use-only-socket-facts'] + : ['--use-only-socket-facts-artifacts', [...types].sort().join(',')] +} diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index f8578a76d..dc308d3a4 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -53,6 +53,12 @@ export async function setupManifestConfig( logger.log('') const choices = [ + { + name: '.NET (dotnet)'.padEnd(30, ' '), + value: 'dotnet', + description: + 'Generate a Socket facts file for .NET solutions and projects through dotnet', + }, { name: 'Conda'.padEnd(30, ' '), value: 'conda', @@ -145,6 +151,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 +282,57 @@ async function setupConda( return notCanceled() } +export async function setupDotnet( + config: NonNullable< + NonNullable['manifest']>['dotnet'] + >, +): Promise> { + const priorBin = config.bin + const bin = await askForBin(config.bin || '', 'dotnet') + if (bin === undefined) { + return canceledByUser() + } else if (bin) { + config.bin = bin + } else if (priorBin !== undefined) { + config.bin = null + } else { + delete config.bin + } + + const priorDotnetOpts = config.dotnetOpts + const opts = await input({ + message: + '(--dotnet-opts) Enter options to pass to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`', + default: config.dotnetOpts || '', + required: false, + }) + if (opts === undefined) { + return canceledByUser() + } else if (opts) { + config.dotnetOpts = opts + } else if (priorDotnetOpts !== undefined) { + config.dotnetOpts = null + } else { + delete config.dotnetOpts + } + + const factsOptions = await setupFactsOptions(config, 'target framework') + if (!factsOptions.ok || factsOptions.data.canceled) { + return factsOptions + } + + 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() +} + export async function setupGradle( config: NonNullable< NonNullable['manifest']>['gradle'] @@ -717,15 +781,17 @@ async function askForIgnoreUnresolvedFlag( // Prompts for the facts-only options shared by gradle and sbt: the config // include/exclude filters and --ignore-unresolved. Mutates `config` in place. -async function setupFactsOptions(config: { - excludeConfigs?: string | undefined | null - ignoreUnresolved?: boolean | undefined - includeConfigs?: string | undefined | null -}): Promise> { +async function setupFactsOptions( + config: { + excludeConfigs?: string | undefined | null + ignoreUnresolved?: boolean | undefined + includeConfigs?: string | undefined | null + }, + configNoun = 'config-name', +): Promise> { const priorIncludeConfigs = config.includeConfigs const includeConfigs = await input({ - message: - '(--include-configs) Comma-separated config-name globs to resolve (blank = all configurations)', + message: `(--include-configs) Comma-separated ${configNoun} globs to resolve (blank = all)`, default: config.includeConfigs || '', required: false, }) @@ -743,8 +809,7 @@ async function setupFactsOptions(config: { const priorExcludeConfigs = config.excludeConfigs const excludeConfigs = await input({ - message: - '(--exclude-configs) Comma-separated config-name globs to skip (blank = none)', + message: `(--exclude-configs) Comma-separated ${configNoun} globs to skip (blank = none)`, default: config.excludeConfigs || '', required: false, }) diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index e5bc9c041..b84bb7a72 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -11,13 +11,20 @@ import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, + isSolutionCovered, realpathOrResolved, sortByDepthThenPath, withoutDisabledFlags, } from './discover-manifest-roots.mts' import { enumerateWorkspaces } from './enumerate-workspaces.mts' import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' -import { setupGradle, setupMaven, setupSbt } from './setup-manifest-config.mts' +import { skipWithoutDotnetSdk } from './scripts/run.mts' +import { + setupDotnet, + setupGradle, + setupMaven, + setupSbt, +} from './setup-manifest-config.mts' import { SOCKET_JSON } from '../../constants.mts' import { loadSocketConfig } from '../../utils/config.mts' import { @@ -37,10 +44,11 @@ import type { SocketJson } from '../../utils/socket-json.mts' // several POMs, so several roots, there. type Candidate = { dir: string; ecosystem: BuildTool; roots: BuildRoot[] } -const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt'] +const ROOT_ECOSYSTEMS: BuildTool[] = ['maven', 'gradle', 'sbt', 'dotnet'] const ECOSYSTEM_LABELS: Record = { __proto__: null, + dotnet: '.NET', gradle: 'Gradle', maven: 'Maven', sbt: 'sbt', @@ -232,9 +240,21 @@ export async function markWorkspaceCoverage({ if (skipReason) { return { ok: true, data: undefined } } + if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) { + return { ok: true, data: undefined } + } for (const root of roots) { - if (isBuildRootCovered(covered, ecosystem, root)) { + if ( + isBuildRootCovered(covered, ecosystem, root) || + // eslint-disable-next-line no-await-in-loop + (await isSolutionCovered(covered, root, { + bin, + excludeBase: cwd, + excludePaths, + toolOpts: buildOpts, + })) + ) { continue } // eslint-disable-next-line no-await-in-loop @@ -259,6 +279,7 @@ export async function markWorkspaceCoverage({ // eslint-disable-next-line no-await-in-loop await addCoveredBuildFiles( covered, + ecosystem, enumResult.buildRoot, enumResult.projects, ) @@ -322,6 +343,13 @@ async function runEcosystemWizard( ecosystem: BuildTool, config: Record, ): Promise> { + if (ecosystem === 'dotnet') { + return await setupDotnet( + config as NonNullable< + NonNullable['manifest']>['dotnet'] + >, + ) + } if (ecosystem === 'gradle') { return await setupGradle( config as NonNullable< diff --git a/src/commands/scan/handle-create-new-scan.mts b/src/commands/scan/handle-create-new-scan.mts index e55a05c07..643e1a55b 100644 --- a/src/commands/scan/handle-create-new-scan.mts +++ b/src/commands/scan/handle-create-new-scan.mts @@ -199,6 +199,7 @@ async function createNewScan( if (reach.dynamicSbomInference) { // Already generated recursively above; resolving cwd's own build // root a second time would race on the same facts file. + detected.dotnet = false detected.gradle = false detected.sbt = false detected.maven = false diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index a8b72f6f6..4d0bdff12 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -16,7 +16,10 @@ import { import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' import { socketDevLink } from '../../utils/terminal-link.mts' -import { hasResolvedPathsSidecarEntries } from '../manifest/scripts/sidecar.mts' +import { + hasResolvedPathsSidecarEntries, + useOnlySocketFactsArtifactsFlags, +} from '../manifest/scripts/sidecar.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' import type { CResult, OutputKind } from '../../types.mts' @@ -269,7 +272,7 @@ export async function performReachabilityAnalysis( // sidecarPath is always set above when this is true - Coana rejects this // flag without one. ...(reachabilityOptions.dynamicSbomInference - ? ['--maven-use-only-socket-facts'] + ? useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar) : []), ...(reachabilityOptions.reachSkipCache ? ['--skip-cache-usage'] : []), ...(reachabilityOptions.reachUseOnlyPregeneratedSboms diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index a20ace474..59a2888b7 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -59,6 +59,22 @@ export interface SocketJson { target?: string | undefined verbose?: boolean | undefined } + dotnet?: { + // Root-only: gates auto-detection for socket manifest auto/dotnet. + // Cascaded (any level): also skips that build root in + // dynamic-sbom-inference specifically. + disabled?: boolean | undefined + // A field set to null explicitly clears an inherited cascade value. + bin?: string | undefined | null + // Arguments for `dotnet restore`; MSBuild properties among them also + // apply to project evaluation. + dotnetOpts?: string | undefined | null + // Target framework globs. + excludeConfigs?: string | undefined | null + includeConfigs?: string | undefined | null + ignoreUnresolved?: boolean | undefined + verbose?: boolean | undefined + } gradle?: { // Root-only: gates auto-detection for socket manifest auto/gradle. // Cascaded (any level): also skips that build root in @@ -178,6 +194,12 @@ function mergeManifestDefaults( if (overrideManifest.conda) { mergedManifest.conda = { ...baseManifest?.conda, ...overrideManifest.conda } } + if (overrideManifest.dotnet) { + mergedManifest.dotnet = { + ...baseManifest?.dotnet, + ...overrideManifest.dotnet, + } + } if (overrideManifest.gradle) { mergedManifest.gradle = { ...baseManifest?.gradle, From 5171c2e81caa747fae2b7eaa02bc96ab8e965a97 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 2 Nov 2026 11:28:31 +0100 Subject: [PATCH 2/4] fix(manifest): address .NET facts review feedback - Honour --include-configs/--exclude-configs in the per-framework build context the sidecar carries, for single- and multi-targeting projects. - After a failed restore, treat a project.nuget.cache older than the restore as not restored: NuGet leaves an up-to-date project's cache untouched, so it cannot tell such a project from one the restore never reached. - Keep the coverage rule in one isRootCovered, cache solution member listings, and probe the SDK once per global.json. - Build every script's options from one list, type the per-build projectInfo records, and drop dead fields and parameters. - Classify NuGet failures by error code only. Co-Authored-By: Claude Opus 5.5 --- .../coana-fix-dynamic-sbom-inference.test.mts | 4 +- src/commands/fix/coana-fix.mts | 6 +- src/commands/fix/generated-socket-facts.mts | 17 ++- .../fix/generated-socket-facts.test.mts | 6 +- src/commands/manifest/cmd-manifest-dotnet.mts | 7 +- .../manifest/convert-dotnet-to-facts.mts | 8 +- .../manifest/detect-manifest-actions.mts | 2 +- .../manifest/discover-manifest-roots.mts | 44 ++++-- .../manifest/generate-recursive-manifests.mts | 4 +- src/commands/manifest/scripts/assemble.mts | 34 ++--- src/commands/manifest/scripts/build-tool.mts | 7 + .../scripts/dotnet-tool/FactsRunner.cs | 60 ++++---- .../manifest/scripts/dotnet-tool/Patterns.cs | 3 + .../manifest/scripts/dotnet-tool/Reader.cs | 57 ++++---- .../manifest/scripts/dotnet-tool/Restorer.cs | 7 +- .../scripts/dotnet-tool/SolutionContext.cs | 6 +- .../scripts/dotnet-tool/ToolOptions.cs | 18 ++- src/commands/manifest/scripts/records.mts | 63 ++++++++- .../scripts/resolution-report-nuget.mts | 34 ++--- .../scripts/resolution-report-render.mts | 14 +- src/commands/manifest/scripts/run.mts | 133 ++++++++++++------ .../setup-recursive-manifest-config.mts | 7 +- 22 files changed, 334 insertions(+), 207 deletions(-) diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 8a64cf5be..681074fc7 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -124,7 +124,7 @@ describe('socket fix --dynamic-sbom-inference', () => { const uploadManifestFiles = vi.fn() const generated = { paths: [FACTS], - sidecarFile: '/tmp/socket-fix-facts/sidecar.json', + sidecar: { contents: {}, file: '/tmp/socket-fix-facts/sidecar.json' }, remove: vi.fn(), restore: vi.fn(), } @@ -155,7 +155,7 @@ describe('socket fix --dynamic-sbom-inference', () => { expect(args).toContain('--maven-use-only-socket-facts') expect(args).toContain('--compute-artifacts-sidecar') expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( - generated.sidecarFile, + generated.sidecar.file, ) expect(generated.remove).toHaveBeenCalledTimes(1) }) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 30bbc92b8..950423db0 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -409,16 +409,16 @@ async function coanaFixWithFacts( spinner?.start() } } - const sidecarFile = factsSlot?.generated?.sidecarFile + const sidecar = factsSlot?.generated?.sidecar // Discovery only needs which artifacts the facts files resolve. Applying // fixes reads each project's tree; a Coana predating facts trees can only // tell from the sidecar's classpaths which projects resolve an artifact. const discoveryFlags = factsSlot - ? useOnlySocketFactsArtifactsFlags(factsSlot.generated?.sidecar) + ? useOnlySocketFactsArtifactsFlags(sidecar?.contents) : [] const factsFlags = [ ...discoveryFlags, - ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), + ...(sidecar ? ['--compute-artifacts-sidecar', sidecar.file] : []), ] const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, scanFilepaths, { diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index a04826222..b652e1a8d 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -7,9 +7,8 @@ import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' export type GeneratedSocketFacts = { paths: string[] - // The facts files' per-project classpaths, outside the repository. - sidecarFile: string | undefined - sidecar: ResolvedPathsSidecar | undefined + // The facts files' per-project classpaths, written outside the repository. + sidecar: { contents: ResolvedPathsSidecar; file: string } | undefined remove: () => Promise restore: () => Promise } @@ -56,8 +55,7 @@ export async function generateSocketFactsForFix({ await restoreAll(originals) } let paths: string[] - let sidecarFile: string | undefined - let sidecar: ResolvedPathsSidecar | undefined + let sidecar: GeneratedSocketFacts['sidecar'] try { const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ cwd, @@ -67,10 +65,12 @@ export async function generateSocketFactsForFix({ withFiles: false, }) paths = factsPaths.map(p => path.resolve(cwd, p)) - sidecar = resolvedPathsSidecar if (resolvedPathsSidecar) { - sidecarFile = path.join(tmpDir, 'sidecar.json') - await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) + sidecar = { + contents: resolvedPathsSidecar, + file: path.join(tmpDir, 'sidecar.json'), + } + await writeFile(sidecar.file, JSON.stringify(resolvedPathsSidecar)) } } catch (e) { // A failed build root aborts inference after others wrote their facts. @@ -85,7 +85,6 @@ export async function generateSocketFactsForFix({ return { paths, sidecar, - sidecarFile, remove: () => reset(paths), restore: () => restoreAll(backups), } diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 2d5a4a9cd..61d92ff4b 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -68,13 +68,13 @@ describe('generateSocketFactsForFix', () => { return { factsPaths: [fresh], resolvedPathsSidecar: { [fresh]: {} } } }) - const { sidecarFile } = await generate() + const { sidecar } = await generate() expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( expect.objectContaining({ sidecar: true }), ) - expect(path.dirname(sidecarFile!)).toBe(tmpDir) - expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({ + expect(path.dirname(sidecar!.file)).toBe(tmpDir) + expect(JSON.parse(await fs.readFile(sidecar!.file, 'utf8'))).toEqual({ [fresh]: {}, }) }) diff --git a/src/commands/manifest/cmd-manifest-dotnet.mts b/src/commands/manifest/cmd-manifest-dotnet.mts index e6b6055eb..9d40102f6 100644 --- a/src/commands/manifest/cmd-manifest-dotnet.mts +++ b/src/commands/manifest/cmd-manifest-dotnet.mts @@ -1,7 +1,7 @@ -import { statSync } from 'node:fs' import path from 'node:path' import { debugFn } from '@socketsecurity/registry/lib/debug' +import { safeStatsSync } from '@socketsecurity/registry/lib/fs' import { logger } from '@socketsecurity/registry/lib/logger' import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' @@ -125,10 +125,7 @@ async function run( } const target = path.resolve(process.cwd(), cli.input[0] ?? '.') - let isFile = false - try { - isFile = statSync(target).isFile() - } catch {} + const isFile = !!safeStatsSync(target)?.isFile() const cwd = isFile ? path.dirname(target) : target const entryFile = isFile ? path.basename(target) : undefined diff --git a/src/commands/manifest/convert-dotnet-to-facts.mts b/src/commands/manifest/convert-dotnet-to-facts.mts index 0956cf03e..5989c3d02 100644 --- a/src/commands/manifest/convert-dotnet-to-facts.mts +++ b/src/commands/manifest/convert-dotnet-to-facts.mts @@ -5,8 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { findDotnetEntries } from './detect-manifest-actions.mts' import { addCoveredBuildFiles, - isBuildRootCovered, - isSolutionCovered, + isRootCovered, realpathOrResolved, } from './discover-manifest-roots.mts' import { runManifestFacts } from './run-manifest-facts.mts' @@ -60,13 +59,12 @@ export async function convertDotnetToFacts({ for (const entry of entries) { const root = { dir, entryFile: entry } if ( - isBuildRootCovered(covered, 'dotnet', root) || // eslint-disable-next-line no-await-in-loop - (await isSolutionCovered(covered, root, { + await isRootCovered(covered, 'dotnet', root, { bin, excludePaths, toolOpts: dotnetOpts, - })) + }) ) { if (verbose) { logger.info(`Skipping \`${entry}\`: earlier builds cover its projects.`) diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts index 5e709768f..5cee9f39c 100644 --- a/src/commands/manifest/detect-manifest-actions.mts +++ b/src/commands/manifest/detect-manifest-actions.mts @@ -51,7 +51,7 @@ export function findDotnetEntries(cwd: string): string[] { return entries .filter(e => e.isFile() && isBuildFile('dotnet', e.name)) .map(e => e.name) - .sort(compareEntryFiles) + .sort((a, b) => compareEntryFiles('dotnet', a, b)) } export async function detectManifestActions( diff --git a/src/commands/manifest/discover-manifest-roots.mts b/src/commands/manifest/discover-manifest-roots.mts index bbaf1babe..3dd651bca 100644 --- a/src/commands/manifest/discover-manifest-roots.mts +++ b/src/commands/manifest/discover-manifest-roots.mts @@ -50,14 +50,14 @@ function buildFileTool( return tools.find(tool => isBuildFile(tool, basename)) } -function solutionsFirst(file: string): number { - return isDotnetSolution(file) ? 0 : 1 -} - -// Within one directory: solutions before projects (a solution covers the -// projects it lists), then by name. -export function compareEntryFiles(a: string, b: string): number { - return solutionsFirst(a) - solutionsFirst(b) || compareStrings(a, b) +export function compareEntryFiles( + tool: BuildTool, + a: string, + b: string, +): number { + const files = BUILD_FILES[tool] + const rank = files.byFile ? files.entryRank : undefined + return (rank ? rank(a) - rank(b) : 0) || compareStrings(a, b) } // A disabled ecosystem is skipped entirely by the glob scan below, so a @@ -135,7 +135,7 @@ export function isBuildRootCovered( // A dotnet solution is covered once every project it builds is, or, when the // SDK cannot read it, by a same-named solution in the other format beside it // (`dotnet sln migrate` writes App.slnx next to App.sln) that it can read. -export async function isSolutionCovered( +async function isSolutionCovered( covered: ReadonlySet, root: BuildRoot, opts: Pick< @@ -172,13 +172,34 @@ export async function isSolutionCovered( return !!members.length && members.every(member => covered.has(member)) } +// Whether earlier builds cover `root`: its own build files, or every project +// a solution builds. isBuildRootCovered alone is the cheap first half. +export async function isRootCovered( + covered: ReadonlySet, + tool: BuildTool, + root: BuildRoot, + opts: Pick< + ManifestScriptOptions, + 'bin' | 'excludeBase' | 'excludePaths' | 'toolOpts' + >, + listMembers: typeof listDotnetEntryProjects = listDotnetEntryProjects, +): Promise { + return ( + isBuildRootCovered(covered, tool, root) || + (tool === 'dotnet' && + (await isSolutionCovered(covered, root, opts, listMembers))) + ) +} + function compareStrings(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } +// `tool` orders the entry files of one tool's builds in the same directory. export function sortByDepthThenPath( roots: readonly T[], cwd: string, + tool?: BuildTool | undefined, ): T[] { return [...roots].sort((a, b) => { const relA = path.relative(cwd, a.dir) @@ -188,7 +209,9 @@ export function sortByDepthThenPath( return ( depthA - depthB || compareStrings(relA, relB) || - compareEntryFiles(a.entryFile ?? '', b.entryFile ?? '') + (tool + ? compareEntryFiles(tool, a.entryFile ?? '', b.entryFile ?? '') + : compareStrings(a.entryFile ?? '', b.entryFile ?? '')) ) }) } @@ -259,6 +282,7 @@ export async function findBuildToolCandidates({ sortByDepthThenPath( [...(rootsByTool.get(tool)?.values() ?? [])], realCwd, + tool, ), ) } diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index e9b9c18c6..05a140207 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -7,7 +7,7 @@ import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, - isSolutionCovered, + isRootCovered, realpathOrResolved, withoutDisabledFlags, } from './discover-manifest-roots.mts' @@ -224,7 +224,7 @@ async function runEcosystemCandidates({ } if ( // eslint-disable-next-line no-await-in-loop - await isSolutionCovered(covered, root, { + await isRootCovered(covered, ecosystem, root, { bin, excludeBase: realCwd, excludePaths, diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 8eaae7296..764a9b619 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' -import { PURL_TYPE_BY_TOOL } from './build-tool.mts' +import { NAMESPACELESS_PURL_TYPES, PURL_TYPE_BY_TOOL } from './build-tool.mts' import { type ComponentFramework, type ProjectFramework, @@ -16,13 +16,14 @@ import { moduleKey } from './resolution-report.mts' import type { ParsedRecords, RawCoord, RawProject } from './records.mts' import type { ResolutionReport } from './resolution-report.mts' -// NuGet purls have no namespace, so the key is left out rather than empty. function purlBase( type: string, group: string, name: string, ): { type: string; namespace?: string; name: string } { - return type === 'nuget' ? { type, name } : { type, namespace: group, name } + return NAMESPACELESS_PURL_TYPES.has(type) + ? { type, name } + : { type, namespace: group, name } } export type AssembleResult = { @@ -55,7 +56,6 @@ type SubprojectGraph = { type PerRoot = { projectKey: string prod: boolean - config: string framework: string runtime: string alias: string @@ -174,7 +174,6 @@ function buildPerRoot(parsed: ParsedRecords): Map { out.set(rootId, { projectKey: r.projectKey, prod: r.prod, - config: r.config, framework: r.framework, runtime: r.runtime, alias: r.alias, @@ -536,21 +535,18 @@ function buildFrameworks( let pf = builds.get(k) if (!pf) { const build = parsed.projects.get(projectKey)?.builds.get(alias) - const one = (name: string) => build?.info.get(name)?.[0] pf = { alias, - framework: framework || one('Framework') || alias, + framework: framework || build?.framework || alias, ...(runtime ? { runtime } : {}), - targetFrameworkMoniker: one('TargetFrameworkMoniker'), - language: one('Language'), - langVersion: one('LangVersion'), - nullable: one('Nullable'), - assemblyName: one('AssemblyName'), - buildError: one('BuildError'), - frameworkReferences: sortedUnique( - build?.info.get('FrameworkReference') ?? [], - ), - defineConstants: [...new Set(build?.info.get('DefineConstant'))], + targetFrameworkMoniker: build?.targetFrameworkMoniker, + language: build?.language, + langVersion: build?.langVersion, + nullable: build?.nullable, + assemblyName: build?.assemblyName, + buildError: build?.buildError, + frameworkReferences: sortedUnique(build?.frameworkReferences ?? []), + defineConstants: [...new Set(build?.defineConstants)], usings: (build?.usings ?? []).map(u => ({ namespace: u.namespace, ...(u.alias ? { alias: u.alias } : {}), @@ -558,8 +554,8 @@ function buildFrameworks( })), // In compiler order, which F# depends on. sources: [...new Set(build?.sources)].filter(exists), - references: [...new Set(build?.info.get('Reference'))].filter(exists), - analyzers: [...new Set(build?.info.get('Analyzer'))].filter(exists), + references: [...new Set(build?.references)].filter(exists), + analyzers: [...new Set(build?.analyzers)].filter(exists), targets: sortedUnique(build?.targets ?? []).filter(exists), dependencies: [], classpath: [], diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 98022ba9e..5db050c58 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -17,6 +17,9 @@ export const PURL_TYPE_BY_TOOL: Record = { sbt: 'maven', } as unknown as Record +// Purl types whose packages have no namespace (NuGet ids are flat). +export const NAMESPACELESS_PURL_TYPES: ReadonlySet = new Set(['nuget']) + // PATH fallback when no `bin` and no project wrapper. const DEFAULT_BUILD_TOOL_BIN: Record = { __proto__: null, @@ -46,6 +49,8 @@ export type BuildFiles = // Each project restores on its own whichever build reaches it, so a build // covers its projects wherever they live; solution properties aside. projectsResolveAlone?: true + // Within one directory, lower runs first; by name otherwise. + entryRank?: (file: string) => number } // `settings.gradle(.kts)` covers Kotlin-DSL roots that have no root @@ -61,6 +66,8 @@ export const BUILD_FILES: Record = { '*.{[sS][lL][nN],[sS][lL][nN][xX],[cCfFvV][sSbB][pP][rR][oO][jJ]}', // NuGet restores each project into its own project.assets.json. projectsResolveAlone: true, + // A solution covers the projects it lists. + entryRank: (file: string) => (isDotnetSolution(file) ? 0 : 1), }, gradle: { byFile: false, diff --git a/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs index f8d587f8a..b9785965d 100644 --- a/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs +++ b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs @@ -19,7 +19,6 @@ internal sealed class ProjectInfo { public Dictionary GlobalProperties = new(); public bool Excluded; public RestoreStyle Style = RestoreStyle.Unknown; - public string? PackagesConfigPath; } // Evaluate -> `dotnet restore` -> re-evaluate and read. Never throws for a @@ -77,7 +76,7 @@ public void Execute() { var entryProjects = EntryProjects(); if (entryProjects == null) return; - if (_opts.Members) { + if (_opts.Mode == ToolMode.Members) { foreach (var path in entryProjects) { if (!IsExcludedProject(path)) _records.Member(Rel(path)); } @@ -87,7 +86,7 @@ public void Execute() { // Listing workspaces evaluates the projects as they are before restore; // a scan leaves finding them to restore, which follows every reference, // conditional per target framework or not. - if (_opts.Workspaces) { + if (_opts.Mode == ToolMode.Workspaces) { var preRestore = new ProjectCollection(); _evaluation = EvaluationContext.Create(EvaluationContext.SharingPolicy.Shared); var queue = new Queue(entryProjects); @@ -96,9 +95,9 @@ public void Execute() { if (_projects.ContainsKey(path)) continue; var info = Register(path); if (info.Excluded) continue; - var project = Evaluate(info, preRestore, reportFailure: true); + var project = Evaluate(info, preRestore); if (project == null) continue; - info.Style = StyleOf(project, out info.PackagesConfigPath); + info.Style = StyleOf(project, out _); EmitProject(info, project); foreach (var reference in ProjectReferences(project, innerBuilds: true)) queue.Enqueue(reference); } @@ -140,10 +139,12 @@ private ProjectInfo Register(string path) { return info; } - // Relative to --exclude-base when given; a project outside it never matches. + // Relative to --exclude-base when given; a project outside it, including on + // another Windows drive (which GetRelativePath returns as is), never matches. private bool IsExcludedProject(string path) { var rel = RelTo(_excludeBase, Path.GetDirectoryName(path)!); - return rel != ".." && !rel.StartsWith("../", StringComparison.Ordinal) && _excludes.IsExcluded(rel); + return rel != ".." && !rel.StartsWith("../", StringComparison.Ordinal) && !Path.IsPathRooted(rel) + && _excludes.IsExcluded(rel); } // The solution's members its active configuration builds, or the entry @@ -274,11 +275,13 @@ private Project LoadInnerBuild(Project project, string targetFramework) { return Load(project.ProjectCollection, project.FullPath, props); } - private Project? Evaluate(ProjectInfo info, ProjectCollection collection, bool reportFailure) { + // An excluded project's failure is not reported: it is only read for the + // paths of the kept projects depending on it. + private Project? Evaluate(ProjectInfo info, ProjectCollection collection) { try { return Load(collection, info.FullPath, info.GlobalProperties); } catch (InvalidProjectFileException e) { - if (reportFailure) { + if (!info.Excluded) { _records.Unscannable("", $"could not evaluate the project: {Text.FirstLine(e.Message)}", info.Key); } return null; @@ -289,24 +292,31 @@ private void Fail(string coord, string detail, string config, string projectKey _records.Failure(coord, detail, config, projectKey); } - // A failed restore's errors, except for projects whose assets file already - // recorded its own (more precise) errors. + // Restore errors not already reported from an assets file, then the projects + // left without current assets and without errors of their own. private void ReportRestoreErrors() { - if (_restore == null || _restore.Succeeded || _restore.TimedOut) return; - var reported = 0; - foreach (var error in _restore.Errors) { - var key = string.IsNullOrEmpty(error.ProjectFile) ? "" : Rel(Path.GetFullPath(error.ProjectFile)); - if (key.Length > 0 && _assetsReportedErrors.Contains(key)) continue; - var code = string.IsNullOrEmpty(error.Code) ? "" : $"{error.Code}: "; - Fail(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); - reported += 1; + if (_restore == null || _restore.Succeeded) return; + var withErrors = new HashSet(_assetsReportedErrors, StringComparer.Ordinal); + if (!_restore.TimedOut) { + var reported = 0; + foreach (var error in _restore.Errors) { + var key = string.IsNullOrEmpty(error.ProjectFile) ? "" : Rel(Path.GetFullPath(error.ProjectFile)); + if (key.Length > 0 && _assetsReportedErrors.Contains(key)) continue; + if (key.Length > 0) withErrors.Add(key); + var code = string.IsNullOrEmpty(error.Code) ? "" : $"{error.Code}: "; + Fail(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); + reported += 1; + } + if (reported == 0 && _assetsReportedErrors.Count == 0) { + Fail( + Rel(_opts.EntryPath), + $"`dotnet restore` failed with exit code {_restore.ExitCode}; run with --verbose to see its output", + "" + ); + } } - if (reported == 0 && _assetsReportedErrors.Count == 0) { - Fail( - Rel(_opts.EntryPath), - $"`dotnet restore` failed with exit code {_restore.ExitCode}; run with --verbose to see its output", - "" - ); + foreach (var key in _notRestored.Where(k => !withErrors.Contains(k))) { + _records.Unscannable("", "`dotnet restore` did not restore this project in this run, so its dependencies are unknown", key); } } diff --git a/src/commands/manifest/scripts/dotnet-tool/Patterns.cs b/src/commands/manifest/scripts/dotnet-tool/Patterns.cs index e2f063d1d..78b26c7f1 100644 --- a/src/commands/manifest/scripts/dotnet-tool/Patterns.cs +++ b/src/commands/manifest/scripts/dotnet-tool/Patterns.cs @@ -93,6 +93,9 @@ public PathExcludes(string csv) { } internal static class Text { + // An MSBuild project file: .csproj, .fsproj, .vbproj and the like. + public static bool IsProjectFile(string path) => path.EndsWith("proj", StringComparison.OrdinalIgnoreCase); + public static string FirstLine(string s) { var idx = s.IndexOfAny(new[] { '\n', '\r' }); return idx < 0 ? s : s.Substring(0, idx); diff --git a/src/commands/manifest/scripts/dotnet-tool/Reader.cs b/src/commands/manifest/scripts/dotnet-tool/Reader.cs index f0bcea740..c04ed2588 100644 --- a/src/commands/manifest/scripts/dotnet-tool/Reader.cs +++ b/src/commands/manifest/scripts/dotnet-tool/Reader.cs @@ -38,18 +38,20 @@ private static string CoordId(string name, string version) => // Keys of projects whose assets file recorded restore errors of its own, // which say more than the restore's console errors for that project. private readonly HashSet _assetsReportedErrors = new(StringComparer.Ordinal); + // Read without current assets after a failed restore; see ReportRestoreErrors. + private readonly List _notRestored = new(); private IEnumerable ReadProject(ProjectInfo info, ProjectCollection collection) { var references = new List(); - var project = Evaluate(info, collection, reportFailure: !info.Excluded); + var project = Evaluate(info, collection); if (project == null) return references; - info.Style = StyleOf(project, out info.PackagesConfigPath); + info.Style = StyleOf(project, out var packagesConfig); if (info.Excluded) { // Not resolved, but kept projects depending on it still need its paths. if (_opts.WithFiles) { EmitProject(info, project); _records.ExcludedProject(info.Key); - EmitPaths(info, project, Frameworks(project)); + EmitPaths(info, project); } return references; } @@ -58,8 +60,8 @@ private IEnumerable ReadProject(ProjectInfo info, ProjectCollection coll case RestoreStyle.PackageReference: references.AddRange(ReadAssets(info, project)); break; - case RestoreStyle.PackagesConfig: - ReadPackagesConfig(info, project); + case RestoreStyle.PackagesConfig when packagesConfig != null: + ReadPackagesConfig(info, project, packagesConfig); break; case RestoreStyle.ProjectJson: EmitProject(info, project); @@ -68,7 +70,7 @@ private IEnumerable ReadProject(ProjectInfo info, ProjectCollection coll default: // No NuGet dependencies; still a module whose sources matter. EmitProject(info, project); - if (_opts.WithFiles) EmitPaths(info, project, Frameworks(project)); + if (_opts.WithFiles) EmitPaths(info, project); break; } return references; @@ -112,10 +114,10 @@ private IEnumerable ReadAssets(ProjectInfo info, Project project) { assetsPath = Path.GetFullPath(assetsPath, project.DirectoryPath); if (!RestoredByThisRun(info, assetsPath) || !File.Exists(assetsPath)) { EmitProject(info, project); - // After a failed restore, its own errors say why (reported once all - // projects are read). if (_restore == null || _restore.Succeeded) { _records.Unscannable("", "`dotnet restore` wrote no project.assets.json for this project", info.Key); + } else { + _notRestored.Add(info.Key); } return Array.Empty(); } @@ -204,18 +206,23 @@ private void ReportLogErrors(ProjectInfo info, object owner) { } } + // FAT records write times to 2 s. + // https://learn.microsoft.com/en-us/windows/win32/sysinfo/file-times + private static readonly TimeSpan MtimeResolution = TimeSpan.FromSeconds(2); + // NuGet rewrites project.nuget.cache on every restore it performs but not an // unchanged assets file, so only the cache says whether this run restored it. + // It skips the cache of a project it found up to date too, so after a failed + // restore an older cache cannot tell such a project from one the restore + // never reached: both count as not restored. + // https://github.com/NuGet/NuGet.Client/blob/aa539166b2ff5abf970ee44281d73c96e3dbaa1a/src/NuGet.Core/NuGet.Commands/RestoreCommand/RequestFactory/NoOpRestoreResult.cs#L34-L35 private bool RestoredByThisRun(ProjectInfo info, string assetsPath) { if (_restore == null || _restore.Succeeded) return true; var cachePath = Path.Combine(Path.GetDirectoryName(assetsPath)!, "project.nuget.cache"); - if (!File.Exists(cachePath)) return false; - CacheFile cache; + if (!File.Exists(cachePath) || File.GetLastWriteTimeUtc(cachePath) < _restore.StartedUtc - MtimeResolution) return false; using (var stream = File.OpenRead(cachePath)) { - cache = CacheFileFormat.Read(stream, NullLogger.Instance, cachePath); + ReportLogErrors(info, CacheFileFormat.Read(stream, NullLogger.Instance, cachePath)); } - if (File.GetLastWriteTimeUtc(cachePath) < _restore.StartedUtc) return cache.Success; - ReportLogErrors(info, cache); return true; } @@ -435,18 +442,18 @@ private static bool IsMetapackage(Assets assets, LockFileTargetLibrary lib) { // packages.config holds the pinned closure NuGet installed, without edges // or a direct/transitive split, so every package is a direct dependency of // a flat graph. developmentDependency="true" marks dev packages. - private void ReadPackagesConfig(ProjectInfo info, Project project) { + private void ReadPackagesConfig(ProjectInfo info, Project project, string packagesConfig) { EmitProject(info, project); - _records.ProjectBuild(info.Key, Rel(info.PackagesConfigPath!)); - if (_opts.WithFiles) EmitPaths(info, project, Frameworks(project)); + _records.ProjectBuild(info.Key, Rel(packagesConfig)); + if (_opts.WithFiles) EmitPaths(info, project); List packages; try { - packages = new NuGet.Packaging.PackagesConfigReader(XDocument.Load(info.PackagesConfigPath!)) + packages = new NuGet.Packaging.PackagesConfigReader(XDocument.Load(packagesConfig)) .GetPackages(allowDuplicatePackageIds: true) .ToList(); } catch (Exception e) when (e is System.Xml.XmlException or PackagingException or ArgumentException) { - _records.Unscannable("", $"could not read {Rel(info.PackagesConfigPath!)}: {Text.FirstLine(e.Message)}", info.Key); + _records.Unscannable("", $"could not read {Rel(packagesConfig)}: {Text.FirstLine(e.Message)}", info.Key); return; } if (packages.Count == 0) return; @@ -557,14 +564,16 @@ private static List Frameworks(Project project) { // Per inner build of a multi-targeting project, whose outer evaluation has // no Compile items or TargetPath, or of the project's single framework. An // excluded project is only evaluated, never built. - private void EmitPaths(ProjectInfo info, Project project, List frameworks) { - var inner = frameworks.Where(f => _configs.Matches(new[] { f })).ToList(); - var single = project.GetPropertyValue("TargetFramework"); - if (inner.Count == 0 || !string.IsNullOrEmpty(single)) { - EmitBuild(info, project, null, string.IsNullOrEmpty(single) ? LegacyFrameworkName(project) : single); + // `frameworks`: the inner builds to read, when restore names them. + private void EmitPaths(ProjectInfo info, Project project, List? frameworks = null) { + var multi = Frameworks(project); + if (multi.Count == 0) { + var single = project.GetPropertyValue("TargetFramework"); + var config = string.IsNullOrEmpty(single) ? LegacyFrameworkName(project) : single; + if (_configs.Matches(new[] { config })) EmitBuild(info, project, null, config); return; } - foreach (var tfm in inner) EmitBuild(info, project, tfm, tfm); + foreach (var tfm in (frameworks ?? multi).Where(f => _configs.Matches(new[] { f }))) EmitBuild(info, project, tfm, tfm); } // Roslyn MSBuildWorkspace's design-time build, minus its command-line parsing: diff --git a/src/commands/manifest/scripts/dotnet-tool/Restorer.cs b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs index 128ca9393..c487d3fed 100644 --- a/src/commands/manifest/scripts/dotnet-tool/Restorer.cs +++ b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs @@ -67,9 +67,6 @@ public static RestoreOutcome Run(ToolOptions opts, string target, Action return new RestoreOutcome(timedOut ? -1 : process.ExitCode, timedOut, started, errors); } - private static bool IsProjectFile(string path) => - path.EndsWith("proj", StringComparison.OrdinalIgnoreCase); - private static List ReadErrors(string path) { var errors = new List(); if (!File.Exists(path)) return errors; @@ -81,9 +78,9 @@ private static List ReadErrors(string path) { // solution restore is the solution. var origin = Regex.Replace(m.Groups["origin"].Value.Trim(), @"\(\d+(,\d+)*\)$", ""); var built = m.Groups["project"].Value; - var project = IsProjectFile(origin) && File.Exists(origin) + var project = Text.IsProjectFile(origin) && File.Exists(origin) ? origin - : IsProjectFile(built) && File.Exists(built) ? built : ""; + : Text.IsProjectFile(built) && File.Exists(built) ? built : ""; errors.Add(new RestoreError(m.Groups["code"].Value, m.Groups["text"].Value, project)); } return errors; diff --git a/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs b/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs index f3d42d0a8..1f42af6de 100644 --- a/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs +++ b/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs @@ -59,13 +59,12 @@ public void Apply(string projectPath, Dictionary props) { // A temporary solution filter over `members`, for restoring a subset of the // solution with the solution's own context. public string WriteFilter(IReadOnlyList members) { - var solutionDir = Path.GetDirectoryName(_solutionPath)!; var path = Path.Combine(Path.GetTempPath(), $"socket-facts-{Guid.NewGuid():N}.slnf"); var doc = new { solution = new { path = _solutionPath, projects = members - .Select(m => Members.TryGetValue(m, out var p) ? p.RelativePath : Path.GetRelativePath(solutionDir, m)) + .Select(m => Members[m].RelativePath) .ToArray(), }, }; @@ -77,8 +76,7 @@ public string WriteFilter(IReadOnlyList members) { // into others) are not projects MSBuild builds on their own. private static bool IsMSBuildProject(ProjectInSolution project) { return project.ProjectType == SolutionProjectType.KnownToBeMSBuildFormat - || (project.ProjectType == SolutionProjectType.Unknown - && project.AbsolutePath.EndsWith("proj", StringComparison.OrdinalIgnoreCase)); + || (project.ProjectType == SolutionProjectType.Unknown && Text.IsProjectFile(project.AbsolutePath)); } // A solution filter: {"solution": {"path": , "projects": [ --entry [options] [-- ] @@ -30,8 +36,7 @@ Arguments after `--` go to `dotnet restore` verbatim. MSBuild properties among public string ExcludeConfigs = ""; public string ExcludePaths = ""; public string ExcludeBase = ""; - public bool Workspaces; - public bool Members; + public ToolMode Mode = ToolMode.Facts; public bool Verbose; public List RestoreArgs = new(); public Dictionary GlobalProperties = new(StringComparer.OrdinalIgnoreCase); @@ -71,10 +76,10 @@ public static ToolOptions Parse(string[] args) { opts.ExcludeBase = RealPaths.Dir(Path.GetFullPath(Next(args, ref i, arg))); break; case "--workspaces": - opts.Workspaces = true; + opts.SetMode(ToolMode.Workspaces, arg); break; case "--members": - opts.Members = true; + opts.SetMode(ToolMode.Members, arg); break; case "--verbose": opts.Verbose = true; @@ -110,6 +115,11 @@ public static ToolOptions Parse(string[] args) { return opts; } + private void SetMode(ToolMode mode, string arg) { + if (Mode != ToolMode.Facts) throw new ArgumentException($"{arg} cannot be combined with another mode"); + Mode = mode; + } + private static string Next(string[] args, ref int i, string arg) { i += 1; if (i >= args.Length) throw new ArgumentException($"{arg} expects a value"); diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 10c396585..547792c90 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -57,7 +57,6 @@ export type RawNode = { export type RawRoot = { rootId: string projectKey: string - config: string prod: boolean // dotnet: the target framework's NuGet short folder name, the runtime // identifier of a runtime-specific target, and the framework's alias in @@ -73,12 +72,50 @@ export type RawUsing = { namespace: string; alias: string; static: boolean } // One dotnet target framework build of a project, by its alias. export type RawBuild = { + analyzers: string[] + assemblyName?: string | undefined + buildError?: string | undefined + defineConstants: string[] + framework?: string | undefined + frameworkReferences: string[] + langVersion?: string | undefined + language?: string | undefined + nullable?: string | undefined + references: string[] sources: string[] + targetFrameworkMoniker?: string | undefined targets: string[] - info: Map usings: RawUsing[] } +type RawBuildList = { + [K in keyof RawBuild]-?: RawBuild[K] extends string[] ? K : never +}[keyof RawBuild] + +type RawBuildValue = { + [K in keyof RawBuild]-?: RawBuild[K] extends string | undefined ? K : never +}[keyof RawBuild] + +// projectInfo names, as the dotnet tool's Reader.cs writes them. +const PROJECT_INFO_LISTS = { + __proto__: null, + Analyzer: 'analyzers', + DefineConstant: 'defineConstants', + FrameworkReference: 'frameworkReferences', + Reference: 'references', +} as unknown as Record + +const PROJECT_INFO_VALUES = { + __proto__: null, + AssemblyName: 'assemblyName', + BuildError: 'buildError', + Framework: 'framework', + LangVersion: 'langVersion', + Language: 'language', + Nullable: 'nullable', + TargetFrameworkMoniker: 'targetFrameworkMoniker', +} as unknown as Record + export type RawProject = { projectKey: string group: string @@ -157,7 +194,6 @@ export function parseRecords(text: string): ParsedRecords { r = { rootId: id, projectKey: '', - config: '', prod: false, framework: '', runtime: '', @@ -192,7 +228,15 @@ export function parseRecords(text: string): ParsedRecords { const builds = project(key).builds let b = builds.get(config) if (!b) { - b = { sources: [], targets: [], info: new Map(), usings: [] } + b = { + analyzers: [], + defineConstants: [], + frameworkReferences: [], + references: [], + sources: [], + targets: [], + usings: [], + } builds.set(config, b) } return b @@ -248,8 +292,14 @@ export function parseRecords(text: string): ParsedRecords { break case 'projectInfo': if (f[2] && f[3] && f[4]) { - const info = build(f[1] ?? '', f[2]).info - info.set(f[3], [...(info.get(f[3]) ?? []), f[4]]) + const b = build(f[1] ?? '', f[2]) + const list = PROJECT_INFO_LISTS[f[3]] + const value = PROJECT_INFO_VALUES[f[3]] + if (list) { + b[list].push(f[4]) + } else if (value) { + b[value] ??= f[4] + } } break case 'projectUsing': @@ -272,7 +322,6 @@ export function parseRecords(text: string): ParsedRecords { case 'root': { const r = root(f[1] ?? '') r.projectKey = f[2] ?? '' - r.config = f[3] ?? '' r.prod = bool(f[4]) r.framework = f[5] ?? '' r.runtime = f[6] ?? '' diff --git a/src/commands/manifest/scripts/resolution-report-nuget.mts b/src/commands/manifest/scripts/resolution-report-nuget.mts index 86d42c2aa..1df27f6e3 100644 --- a/src/commands/manifest/scripts/resolution-report-nuget.mts +++ b/src/commands/manifest/scripts/resolution-report-nuget.mts @@ -10,37 +10,23 @@ const NOT_FOUND_CODES = new Set(['NU1100', 'NU1101', 'NU1102', 'NU1103']) // (HTTP) sources. const SOURCE_CODES = new Set(['NU1301', 'NU1302']) -// The socket-facts-dotnet tool reports restore errors as `: ` -// and its own findings as plain sentences. +// The socket-facts-dotnet tool reports restore errors as `: `, +// and a design-time build failure as `: : `; its +// findings without a code count as other failures. export function classifyNugetFailure(detail: string): FailureCategory { - const text = detail || '' - const code = /^(NU\d{4}|NETSDK\d{4}|MSB\d{4})\b/.exec(text)?.[1] - if (code && NOT_FOUND_CODES.has(code)) { + const code = /(?:^|: )(NU\d{4}|NETSDK\d{4}|MSB\d{4}):/.exec(detail || '')?.[1] + if (!code) { + return 'other' + } + if (NOT_FOUND_CODES.has(code)) { return 'not-found' } - const t = text.toLowerCase() - if ( - (code && SOURCE_CODES.has(code)) || - t.includes('unable to load the service index') || - t.includes('response status code does not indicate success') || - t.includes('401 (unauthorized)') || - t.includes('403 (forbidden)') - ) { + if (SOURCE_CODES.has(code)) { return 'repository-or-network' } // Any other NuGet, .NET SDK or MSBuild error is about the project, the // lock file, central package management or framework compatibility. - if ( - code || - t.startsWith('could not evaluate') || - t.startsWith('could not read') || - t.startsWith('the design-time build failed') || - t.includes('share an intermediate output directory') || - t.startsWith('project.json projects are not supported') - ) { - return 'config-problem' - } - return 'other' + return 'config-problem' } export const NUGET_DIALECT: ResolutionDialect = { diff --git a/src/commands/manifest/scripts/resolution-report-render.mts b/src/commands/manifest/scripts/resolution-report-render.mts index 5cbc30ef4..4b2b61b20 100644 --- a/src/commands/manifest/scripts/resolution-report-render.mts +++ b/src/commands/manifest/scripts/resolution-report-render.mts @@ -93,12 +93,11 @@ const RESOLUTION_REPORT_CONFIG_LIMIT = 20 function dedupInfos }>( infos: T[], ): T[] { - const placesOf = (i: T): string[] => - i.projects.size ? [...i.projects] : [''] const versioned = new Set() for (const i of infos) { - if (i.coord.split(':').length >= 3) { - for (const project of placesOf(i)) { + const p = i.coord.split(':') + if (p.length >= 3) { + for (const project of i.projects) { versioned.add(moduleKey(project, i.coord)) } } @@ -107,7 +106,7 @@ function dedupInfos }>( .filter( i => i.coord.split(':').length >= 3 || - !placesOf(i).every(p => versioned.has(moduleKey(p, i.coord))), + ![...i.projects].every(p => versioned.has(moduleKey(p, i.coord))), ) .sort((a, b) => (a.coord < b.coord ? -1 : a.coord > b.coord ? 1 : 0)) } @@ -241,6 +240,7 @@ export function renderResolutionReport( blocking: boolean detail: string configs: Set + // '' for a failure that belongs to no project. projects: Set // Some failure of this coord belongs to no configuration. configless: boolean @@ -268,9 +268,7 @@ export function renderResolutionReport( } else { info.configless = true } - if (f.project) { - info.projects.add(f.project) - } + info.projects.add(f.project) } const allInfos = [...byKey.values()] const isInfoBlocking = (info: CoordInfo): boolean => info.blocking diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index 7ee1f6ceb..b495cea36 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -11,6 +11,7 @@ import { parseRecords } from './records.mts' import constants from '../../../constants.mts' import { getErrorMessageOr } from '../../../utils/errors.mts' import { withTmpDir } from '../../../utils/fs.mts' +import { camelToKebab } from '../../../utils/strings.mts' import type { BuildTool } from './build-tool.mts' import type { @@ -187,13 +188,14 @@ function assembleRecords( function assertBundledAssetBuilt( assetPath: string, description: string, + // The package.json script that builds it, as the dist build names it too. buildScript: string, ): void { if (existsSync(assetPath)) { return } throw new Error( - `${description} not found at ${assetPath}. It is bundled in the published CLI; for local dev build it with: bash src/commands/manifest/scripts/${buildScript}`, + `${description} not found at ${assetPath}. It is bundled in the published CLI; for local dev build it with: pnpm run ${buildScript}`, ) } @@ -237,34 +239,53 @@ export async function enumerateWorkspaces( } } -function commonProps( +// The options every build script reads, each in its own argument syntax. +function scriptOptions( opts: ManifestScriptOptions, - prefix: '-D' | '-P', -): string[] { - const props: string[] = [] +): Array<[string, string | true]> { + const options: Array<[string, string | true]> = [] if (opts.withFiles) { - props.push(`${prefix}socket.withFiles=true`) + options.push(['withFiles', true]) } if (opts.populateFilesFor) { - props.push(`${prefix}socket.populateFilesFor=${opts.populateFilesFor}`) + options.push(['populateFilesFor', opts.populateFilesFor]) } if (opts.includeConfigs) { - props.push(`${prefix}socket.includeConfigs=${opts.includeConfigs}`) + options.push(['includeConfigs', opts.includeConfigs]) } if (opts.excludeConfigs) { - props.push(`${prefix}socket.excludeConfigs=${opts.excludeConfigs}`) + options.push(['excludeConfigs', opts.excludeConfigs]) } // Globs compile to regex pattern sources HERE (exclude-paths-glob.mts is - // the single glob implementation); the scripts just Pattern.compile what - // they receive. + // the single glob implementation); the scripts just compile what they + // receive. const excludePatterns = serializeExcludePathPatterns(opts.excludePaths) if (excludePatterns) { - props.push(`${prefix}socket.excludePaths=${excludePatterns}`) - props.push( - `${prefix}socket.excludeBase=${opts.excludeBase ?? opts.projectDir}`, - ) + options.push(['excludePaths', excludePatterns]) + options.push(['excludeBase', opts.excludeBase ?? opts.projectDir]) } - return props + return options +} + +function commonProps( + opts: ManifestScriptOptions, + prefix: '-D' | '-P', +): string[] { + return scriptOptions(opts).map( + ({ 0: name, 1: value }) => + `${prefix}socket.${name}=${value === true ? 'true' : value}`, + ) +} + +// The dotnet tool's flags are the kebab-cased script option names. +function dotnetToolArgs(opts: ManifestScriptOptions): string[] { + if (opts.populateFilesFor) { + throw new Error('The dotnet manifest tool does not scope --with-files.') + } + return scriptOptions(opts).flatMap(({ 0: name, 1: value }) => { + const flag = `--${camelToKebab(name)}` + return value === true ? [flag] : [flag, value] + }) } async function invokeGradle( @@ -407,7 +428,7 @@ async function invokeMaven( assertBundledAssetBuilt( jarPath, 'Maven manifest extension jar', - 'maven-extension/build-jar.sh', + 'build:maven-extension', ) return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') @@ -449,21 +470,41 @@ async function enumerateMavenWorkspaces( ) } -// `dotnet --version` from the build's directory: the SDK the dotnet host -// selects there (global.json included). A host that finds none says why. -// global.json pins the SDK per directory; every entry of a directory shares -// it, failures included. -const sdkVersionByDir = new Map>() +// The global.json the dotnet host applies in `dir`: the first one in it or +// any parent directory, the filesystem root included, which findUp skips. +// https://github.com/dotnet/runtime/blob/b4548da6edfce1996a1d6ffe1521c9ce6128be2e/src/native/corehost/fxr/sdk_resolver.cpp#L296-L321 +function nearestGlobalJson(dir: string): string { + for (let current = path.resolve(dir); ; ) { + const file = path.join(current, 'global.json') + if (existsSync(file)) { + return file + } + const parent = path.dirname(current) + if (parent === current) { + return '' + } + current = parent + } +} + +// Keyed by the nearest global.json, the only per-directory input to the +// host's SDK selection; a failed probe is shared too. +const sdkVersionByPin = new Map>() function dotnetSdkVersion( bin: string, opts: ManifestScriptOptions, ): Promise { - const key = `${bin}\0${opts.projectDir}` - let version = sdkVersionByDir.get(key) + // A relative path names a different host in each directory. + const host = + path.isAbsolute(bin) || path.basename(bin) === bin + ? bin + : path.resolve(opts.projectDir, bin) + const key = `${host}\0${nearestGlobalJson(opts.projectDir)}` + let version = sdkVersionByPin.get(key) if (!version) { version = readDotnetSdkVersion(bin, opts) - sdkVersionByDir.set(key, version) + sdkVersionByPin.set(key, version) } return version } @@ -521,11 +562,7 @@ async function invokeDotnetTool( throw new Error('A dotnet build needs a solution or project file.') } const toolDll = manifestScriptsPath('dotnet-tool', 'socket-facts-dotnet.dll') - assertBundledAssetBuilt( - toolDll, - 'dotnet manifest tool', - 'dotnet-tool/build-tool.sh', - ) + assertBundledAssetBuilt(toolDll, 'dotnet manifest tool', 'build:dotnet-tool') const bin = resolveBuildToolBin('dotnet', opts.projectDir, opts.bin) const sdkVersion = await dotnetSdkVersion(bin, opts) // The tool runs on the newest installed runtime (RollForward=LatestMajor), @@ -539,7 +576,6 @@ async function invokeDotnetTool( : opts.env return await withTmpDir('socket-dotnet-facts-', async tmp => { const recordsFile = path.join(tmp, 'records.tsv') - const excludePatterns = serializeExcludePathPatterns(opts.excludePaths) const args = [ toolDll, '--records', @@ -549,17 +585,7 @@ async function invokeDotnetTool( '--dotnet', bin, ...(mode ? [mode] : []), - ...(opts.withFiles ? ['--with-files'] : []), - ...(opts.includeConfigs - ? ['--include-configs', opts.includeConfigs] - : []), - ...(opts.excludeConfigs - ? ['--exclude-configs', opts.excludeConfigs] - : []), - ...(excludePatterns ? ['--exclude-paths', excludePatterns] : []), - ...(excludePatterns && opts.excludeBase - ? ['--exclude-base', opts.excludeBase] - : []), + ...dotnetToolArgs(opts), ...(opts.stdio === 'inherit' ? ['--verbose'] : []), '--', ...(opts.toolOpts ?? []), @@ -577,9 +603,30 @@ async function runDotnet( return assembleRecords('dotnet', out, records) } +const entryProjectsByRun = new Map>() + // Undefined when the SDK cannot read the entry (a .slnx before SDK 9.0.200); // empty when the listing fails, which the build itself then reports. -export async function listDotnetEntryProjects( +export function listDotnetEntryProjects( + opts: ManifestScriptOptions, +): Promise { + const key = JSON.stringify([ + opts.bin, + opts.projectDir, + opts.entryFile, + opts.excludePaths, + opts.excludeBase, + opts.toolOpts, + ]) + let listing = entryProjectsByRun.get(key) + if (!listing) { + listing = readDotnetEntryProjects(opts) + entryProjectsByRun.set(key, listing) + } + return listing +} + +async function readDotnetEntryProjects( opts: ManifestScriptOptions, ): Promise { let listing diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index b84bb7a72..9fe84f305 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -11,7 +11,7 @@ import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, - isSolutionCovered, + isRootCovered, realpathOrResolved, sortByDepthThenPath, withoutDisabledFlags, @@ -246,14 +246,13 @@ export async function markWorkspaceCoverage({ for (const root of roots) { if ( - isBuildRootCovered(covered, ecosystem, root) || // eslint-disable-next-line no-await-in-loop - (await isSolutionCovered(covered, root, { + await isRootCovered(covered, ecosystem, root, { bin, excludeBase: cwd, excludePaths, toolOpts: buildOpts, - })) + }) ) { continue } From 593b5af69c2e8c55a59734fbdbabf6e39f05055c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 2 Nov 2026 13:05:49 +0100 Subject: [PATCH 3/4] fix(manifest): pin patched System.Text.Json and System.Formats.Asn1 The .NET tool's compile-time closure resolved System.Text.Json 6.0.0 (CVE-2024-43485) and System.Formats.Asn1 5.0.0 (CVE-2024-38095). Neither ships, since the shared framework supplies both at run time, but the lock file still carried them. Reference the patched versions compile-only. Co-Authored-By: Claude Opus 5.5 --- .../scripts/dotnet-tool/packages.lock.json | 30 ++++++++++--------- .../dotnet-tool/socket-facts-dotnet.csproj | 4 +++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/commands/manifest/scripts/dotnet-tool/packages.lock.json b/src/commands/manifest/scripts/dotnet-tool/packages.lock.json index 2e490b0e0..e4d3badc9 100644 --- a/src/commands/manifest/scripts/dotnet-tool/packages.lock.json +++ b/src/commands/manifest/scripts/dotnet-tool/packages.lock.json @@ -124,6 +124,22 @@ "resolved": "6.0.6", "contentHash": "9Y2fBEDbR6VRVsXUP5UJQYTsUNHyROguhJR2JGGg6lbahzBKnV5oJjtAxYafA5ffbCE7szNRLwQzDvj3CgPLmw==" }, + "System.Formats.Asn1": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "glgtKqWJpH9GDw0m9I5xFiF6WDIQqi/eZXU6MkMRPzAWEERGGAJh+qztkrlWSDbokQ1jalj5NcBNIvVoSDpSSA==" + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[6.0.10, )", + "resolved": "6.0.10", + "contentHash": "NSB0kDipxn2ychp88NXWfFRFlmi1bst/xynOutbnpEfRCT9JZkZ7KOmF/I/hNKo2dILiMGnqblm+j1sggdLB9g==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, "Microsoft.NET.StringTools": { "type": "Transitive", "resolved": "17.3.2", @@ -163,11 +179,6 @@ "Microsoft.Win32.SystemEvents": "6.0.0" } }, - "System.Formats.Asn1": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "MTvUIktmemNB+El0Fgw9egyqT9AYSIk6DTJeoDSpc3GIHxHCMo8COqkWT1mptX5tZ1SlQ6HJZ0OsSvMth1c12w==" - }, "System.Memory": { "type": "Transitive", "resolved": "4.5.5", @@ -252,15 +263,6 @@ "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "zaJsHfESQvJ11vbXnNlkrR46IaMULk/gHxYsJphzSF+07kTjPHv+Oc14w6QEOfo3Q4hqLJgStUaYB9DBl0TmWg==", - "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "6.0.0", - "System.Text.Encodings.Web": "6.0.0" - } - }, "System.Threading.Tasks.Dataflow": { "type": "Transitive", "resolved": "6.0.0", diff --git a/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj index fa21d3e30..376def028 100644 --- a/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj +++ b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj @@ -39,6 +39,10 @@ + + + From e10e2dde9ec5d7c9425a993719e258af02c60b1f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Mon, 2 Nov 2026 15:44:43 +0100 Subject: [PATCH 4/4] refactor(manifest): simplify .NET facts generation - Share the per-root build step (coverage check, run, covered files) between the recursive generator and `manifest dotnet`, which now takes the ecosystem config whole and starts from resolveEcosystemConfig. - Keep covered build files per tool, so JVM coverage no longer makes every solution list its members. - Keep only Coana's flags and the sidecar path for `socket fix`, not the parsed sidecar. - Carry a root's target framework as one optional field; reuse the build and root nodes when assembling per-framework sidecar data. - Add getOrInsert, ManifestDefaults and wizard answer helpers. - C# tool: nullable restore exit code, Failure's default project key, one unscannable exit in ReadAssets, ShortFrameworkName(ProjectInstance). Co-Authored-By: Claude Opus 5.5 --- .../coana-fix-dynamic-sbom-inference.test.mts | 5 +- src/commands/fix/coana-fix.mts | 9 +- src/commands/fix/generated-socket-facts.mts | 21 +- .../fix/generated-socket-facts.test.mts | 14 +- src/commands/manifest/cmd-manifest-dotnet.mts | 95 +++-- .../manifest/convert-dotnet-to-facts.mts | 68 +--- .../manifest/generate-recursive-manifests.mts | 143 ++++--- .../manifest/generate_auto_manifest.mts | 7 +- src/commands/manifest/scripts/assemble.mts | 171 ++++----- .../scripts/dotnet-tool/FactsRunner.cs | 14 +- .../manifest/scripts/dotnet-tool/Reader.cs | 43 +-- .../scripts/dotnet-tool/RecordsWriter.cs | 2 +- .../manifest/scripts/dotnet-tool/Restorer.cs | 9 +- src/commands/manifest/scripts/records.mts | 21 +- src/commands/manifest/scripts/run.mts | 19 +- .../manifest/setup-manifest-config.mts | 354 +++++++----------- .../setup-recursive-manifest-config.mts | 42 +-- src/utils/get-or-insert.mts | 8 + src/utils/socket-json.mts | 4 + 19 files changed, 476 insertions(+), 573 deletions(-) create mode 100644 src/utils/get-or-insert.mts diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 681074fc7..7173f6d03 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -123,8 +123,9 @@ describe('socket fix --dynamic-sbom-inference', () => { } const uploadManifestFiles = vi.fn() const generated = { + artifactsFlags: ['--maven-use-only-socket-facts'], paths: [FACTS], - sidecar: { contents: {}, file: '/tmp/socket-fix-facts/sidecar.json' }, + sidecarFile: '/tmp/socket-fix-facts/sidecar.json', remove: vi.fn(), restore: vi.fn(), } @@ -155,7 +156,7 @@ describe('socket fix --dynamic-sbom-inference', () => { expect(args).toContain('--maven-use-only-socket-facts') expect(args).toContain('--compute-artifacts-sidecar') expect(args[args.indexOf('--compute-artifacts-sidecar') + 1]).toBe( - generated.sidecar.file, + generated.sidecarFile, ) expect(generated.remove).toHaveBeenCalledTimes(1) }) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 950423db0..368d118f8 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -54,7 +54,6 @@ import { } from '../../utils/github.mts' import { getPackageFilesForScan } from '../../utils/path-resolve.mts' import { setupSdk } from '../../utils/sdk.mts' -import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts' import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts' @@ -409,16 +408,14 @@ async function coanaFixWithFacts( spinner?.start() } } - const sidecar = factsSlot?.generated?.sidecar + const sidecarFile = factsSlot?.generated?.sidecarFile // Discovery only needs which artifacts the facts files resolve. Applying // fixes reads each project's tree; a Coana predating facts trees can only // tell from the sidecar's classpaths which projects resolve an artifact. - const discoveryFlags = factsSlot - ? useOnlySocketFactsArtifactsFlags(sidecar?.contents) - : [] + const discoveryFlags = factsSlot?.generated?.artifactsFlags ?? [] const factsFlags = [ ...discoveryFlags, - ...(sidecar ? ['--compute-artifacts-sidecar', sidecar.file] : []), + ...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []), ] const uploadCResult = await handleApiCall( sockSdk.uploadManifestFiles(orgSlug, scanFilepaths, { diff --git a/src/commands/fix/generated-socket-facts.mts b/src/commands/fix/generated-socket-facts.mts index b652e1a8d..fc49bae70 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,14 +1,15 @@ import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' +import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts' import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts' -import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts' - export type GeneratedSocketFacts = { + // Coana's flags selecting what the facts files resolve. + artifactsFlags: string[] paths: string[] // The facts files' per-project classpaths, written outside the repository. - sidecar: { contents: ResolvedPathsSidecar; file: string } | undefined + sidecarFile: string | undefined remove: () => Promise restore: () => Promise } @@ -54,8 +55,9 @@ export async function generateSocketFactsForFix({ ) await restoreAll(originals) } + let artifactsFlags: string[] let paths: string[] - let sidecar: GeneratedSocketFacts['sidecar'] + let sidecarFile: string | undefined try { const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({ cwd, @@ -64,13 +66,11 @@ export async function generateSocketFactsForFix({ sidecar: true, withFiles: false, }) + artifactsFlags = useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar) paths = factsPaths.map(p => path.resolve(cwd, p)) if (resolvedPathsSidecar) { - sidecar = { - contents: resolvedPathsSidecar, - file: path.join(tmpDir, 'sidecar.json'), - } - await writeFile(sidecar.file, JSON.stringify(resolvedPathsSidecar)) + sidecarFile = path.join(tmpDir, 'sidecar.json') + await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar)) } } catch (e) { // A failed build root aborts inference after others wrote their facts. @@ -83,8 +83,9 @@ export async function generateSocketFactsForFix({ ...(await backUp(paths, path.join(tmpDir, 'generated'))), ] return { + artifactsFlags, paths, - sidecar, + sidecarFile, remove: () => reset(paths), restore: () => restoreAll(backups), } diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 61d92ff4b..0a95c663a 100644 --- a/src/commands/fix/generated-socket-facts.test.mts +++ b/src/commands/fix/generated-socket-facts.test.mts @@ -65,17 +65,21 @@ describe('generateSocketFactsForFix', () => { it('writes the sidecar outside the repository', async () => { mockRunDynamicSbomInference.mockImplementation(async () => { await fs.writeFile(fresh, 'generated') - return { factsPaths: [fresh], resolvedPathsSidecar: { [fresh]: {} } } + return { + factsPaths: [fresh], + resolvedPathsSidecar: { [fresh]: { components: [], projects: [] } }, + } }) - const { sidecar } = await generate() + const { artifactsFlags, sidecarFile } = await generate() expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( expect.objectContaining({ sidecar: true }), ) - expect(path.dirname(sidecar!.file)).toBe(tmpDir) - expect(JSON.parse(await fs.readFile(sidecar!.file, 'utf8'))).toEqual({ - [fresh]: {}, + expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts']) + expect(path.dirname(sidecarFile!)).toBe(tmpDir) + expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({ + [fresh]: { components: [], projects: [] }, }) }) diff --git a/src/commands/manifest/cmd-manifest-dotnet.mts b/src/commands/manifest/cmd-manifest-dotnet.mts index 9d40102f6..8ce029236 100644 --- a/src/commands/manifest/cmd-manifest-dotnet.mts +++ b/src/commands/manifest/cmd-manifest-dotnet.mts @@ -5,9 +5,9 @@ import { safeStatsSync } from '@socketsecurity/registry/lib/fs' import { logger } from '@socketsecurity/registry/lib/logger' import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts' +import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' import { excludePathsFlag } from './manifest-flags.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' -import { resolveBuildToolBin } from './scripts/build-tool.mts' import constants, { SOCKET_JSON } from '../../constants.mts' import { commonFlags } from '../../flags.mts' import { checkCommandInput } from '../../utils/check-input.mts' @@ -16,6 +16,7 @@ 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 { camelToKebab } from '../../utils/strings.mts' import { assertValidExcludePaths } from '../scan/exclude-paths.mts' import type { @@ -134,51 +135,54 @@ async function run( debugFn('inspect', `override: ${SOCKET_JSON} dotnet`, defaults) - let { - bin, - dotnetOpts, - excludeConfigs, - ignoreUnresolved, - includeConfigs, - verbose, - } = cli.flags - - function useDefault(flag: string, value: T): T { - logger.info(`Using default --${flag} from ${SOCKET_JSON}:`, value) - return value + const dotnetConfig = resolveEcosystemConfig('dotnet', cwd, sockJson) + const given = cli.flags as { + bin?: string | undefined + dotnetOpts?: string | undefined + excludeConfigs?: string | undefined + ignoreUnresolved?: boolean | undefined + includeConfigs?: string | undefined + verbose?: boolean | undefined } - if (!bin) { - bin = defaults?.bin - ? useDefault('bin', defaults.bin) - : resolveBuildToolBin('dotnet', cwd) + // An empty --bin or --dotnet-opts leaves the default in place. + const flags = { + ...given, + bin: given.bin || undefined, + dotnetOpts: given.dotnetOpts || undefined, } - if (!dotnetOpts) { - dotnetOpts = defaults?.dotnetOpts - ? useDefault('dotnet-opts', defaults.dotnetOpts) - : '' + for (const key of [ + 'bin', + 'dotnetOpts', + 'excludeConfigs', + 'ignoreUnresolved', + 'includeConfigs', + 'verbose', + ] as const) { + const configured = defaults?.[key] + if ( + flags[key] === undefined && + configured !== undefined && + configured !== null && + configured !== '' + ) { + logger.info( + `Using default --${camelToKebab(key)} from ${SOCKET_JSON}:`, + configured, + ) + } } - if (includeConfigs === undefined) { - includeConfigs = defaults?.includeConfigs - ? useDefault('include-configs', defaults.includeConfigs) - : '' - } - if (excludeConfigs === undefined) { - excludeConfigs = defaults?.excludeConfigs - ? useDefault('exclude-configs', defaults.excludeConfigs) - : '' - } - if (ignoreUnresolved === undefined) { - ignoreUnresolved = - defaults?.ignoreUnresolved !== undefined - ? useDefault('ignore-unresolved', defaults.ignoreUnresolved) - : false - } - if (verbose === undefined) { - verbose = - defaults?.verbose !== undefined - ? useDefault('verbose', defaults.verbose) - : false + dotnetConfig.bin = flags.bin ?? dotnetConfig.bin + if (flags.dotnetOpts) { + dotnetConfig.buildOpts = parseBuildToolOpts(flags.dotnetOpts) } + dotnetConfig.excludeConfigs = + flags.excludeConfigs ?? dotnetConfig.excludeConfigs + dotnetConfig.ignoreUnresolved = + flags.ignoreUnresolved ?? dotnetConfig.ignoreUnresolved + dotnetConfig.includeConfigs = + flags.includeConfigs ?? dotnetConfig.includeConfigs + dotnetConfig.verbose = flags.verbose ?? dotnetConfig.verbose + const { bin, verbose } = dotnetConfig if (verbose) { logger.group('- ', parentName, config.commandName, ':') @@ -202,14 +206,9 @@ async function run( assertValidExcludePaths(excludePaths) await convertDotnetToFacts({ - bin: String(bin), + config: dotnetConfig, cwd, - dotnetOpts: parseBuildToolOpts(String(dotnetOpts || '')), entryFile, - excludeConfigs: String(excludeConfigs || ''), excludePaths, - ignoreUnresolved: Boolean(ignoreUnresolved), - includeConfigs: String(includeConfigs || ''), - verbose: Boolean(verbose), }) } diff --git a/src/commands/manifest/convert-dotnet-to-facts.mts b/src/commands/manifest/convert-dotnet-to-facts.mts index 5989c3d02..79150ba4b 100644 --- a/src/commands/manifest/convert-dotnet-to-facts.mts +++ b/src/commands/manifest/convert-dotnet-to-facts.mts @@ -1,15 +1,10 @@ -import path from 'node:path' - import { logger } from '@socketsecurity/registry/lib/logger' import { findDotnetEntries } from './detect-manifest-actions.mts' -import { - addCoveredBuildFiles, - isRootCovered, - realpathOrResolved, -} from './discover-manifest-roots.mts' -import { runManifestFacts } from './run-manifest-facts.mts' +import { realpathOrResolved } from './discover-manifest-roots.mts' +import { buildUncoveredRoot } from './generate-recursive-manifests.mts' +import type { EcosystemBuildConfig } from './generate-recursive-manifests.mts' import type { RunManifestFactsOutcome } from './run-manifest-facts.mts' import type { BuildOptionHints } from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' @@ -18,31 +13,21 @@ import type { SidecarAccumulator } from './scripts/sidecar.mts' // file, or for every solution and project at the top of `cwd`. A project a // solution there already covers is not built again. export async function convertDotnetToFacts({ - bin, + config, cwd, - dotnetOpts, entryFile, - excludeConfigs, excludePaths, hints, - ignoreUnresolved, - includeConfigs, sidecarAcc, - verbose, withFiles, }: { - bin: string + config: EcosystemBuildConfig cwd: string - dotnetOpts: string[] // Builds only this file (relative to cwd) when set. entryFile?: string | undefined - excludeConfigs: string excludePaths?: string[] | undefined hints?: BuildOptionHints | undefined - ignoreUnresolved: boolean - includeConfigs: string sidecarAcc?: SidecarAccumulator | undefined - verbose: boolean withFiles?: boolean | undefined }): Promise { const entries = entryFile ? [entryFile] : findDotnetEntries(cwd) @@ -57,49 +42,28 @@ export async function convertDotnetToFacts({ const covered = new Set() let last: RunManifestFactsOutcome for (const entry of entries) { - const root = { dir, entryFile: entry } - if ( - // eslint-disable-next-line no-await-in-loop - await isRootCovered(covered, 'dotnet', root, { - bin, - excludePaths, - toolOpts: dotnetOpts, - }) - ) { - if (verbose) { - logger.info(`Skipping \`${entry}\`: earlier builds cover its projects.`) - } - continue - } // eslint-disable-next-line no-await-in-loop - const outcome = await runManifestFacts({ - bin, - buildOpts: dotnetOpts, + const outcome = await buildUncoveredRoot({ + config, + covered, cwd, ecosystem: 'dotnet', - entryFile: entry, - excludeConfigs, excludePaths, hints, - ignoreUnresolved, - includeConfigs, + root: { dir, entryFile: entry }, sidecarAcc, - verbose, withFiles, }) + if (outcome === 'covered') { + if (config.verbose) { + logger.info(`Skipping \`${entry}\`: earlier builds cover its projects.`) + } + continue + } if (outcome === null) { return null } - if (outcome) { - // eslint-disable-next-line no-await-in-loop - await addCoveredBuildFiles( - covered, - 'dotnet', - path.dirname(outcome.factsPath), - outcome.projects, - ) - last = outcome - } + last = outcome ?? last } return last } diff --git a/src/commands/manifest/generate-recursive-manifests.mts b/src/commands/manifest/generate-recursive-manifests.mts index 05a140207..c73b2282a 100644 --- a/src/commands/manifest/generate-recursive-manifests.mts +++ b/src/commands/manifest/generate-recursive-manifests.mts @@ -28,8 +28,12 @@ import { } from '../../utils/socket-json.mts' import type { BuildRoot } from './discover-manifest-roots.mts' +import type { RunManifestFactsOutcome } from './run-manifest-facts.mts' import type { BuildTool } from './scripts/build-tool.mts' -import type { BuildOption } from './scripts/resolution-report-render.mts' +import type { + BuildOption, + BuildOptionHints, +} from './scripts/resolution-report-render.mts' import type { SidecarAccumulator } from './scripts/sidecar.mts' import type { SocketJson } from '../../utils/socket-json.mts' @@ -170,12 +174,13 @@ async function runEcosystemCandidates({ withFiles: boolean | undefined }): Promise { const outcomes: RecursiveManifestOutcome[] = [] - const covered = new Set() for (const [ecosystem, roots] of candidatesByTool) { + // Per tool: a solution counts as covered only by .NET projects. + const covered = new Set() const disabledRoots: DisabledRoot[] = [] const configByDir = new Map() for (const { 0: rootIndex, 1: root } of roots.entries()) { - const { dir, entryFile } = root + const { dir } = root if (isBuildRootCovered(covered, ecosystem, root)) { outcomes.push({ ...root, ecosystem, status: 'skippedCovered' }) continue @@ -203,49 +208,31 @@ async function runEcosystemCandidates({ disabledRoots.push({ dir, sockJson }) } } - const { - bin, - buildOpts, - excludeConfigs, - ignoreUnresolved, - includeConfigs, - javaHome, - skipReason, - verbose: configVerbose, - } = dirConfig - if (skipReason) { + if (dirConfig.skipReason) { outcomes.push({ ...root, ecosystem, status: 'skippedDisabled' }) continue } - // eslint-disable-next-line no-await-in-loop - if (ecosystem === 'dotnet' && (await skipWithoutDotnetSdk(bin, dir))) { - outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' }) - continue - } if ( + ecosystem === 'dotnet' && // eslint-disable-next-line no-await-in-loop - await isRootCovered(covered, ecosystem, root, { - bin, - excludeBase: realCwd, - excludePaths, - toolOpts: buildOpts, - }) + (await skipWithoutDotnetSdk(dirConfig.bin, dir)) ) { - outcomes.push({ ...root, ecosystem, status: 'skippedCovered' }) + outcomes.push({ ...root, ecosystem, status: 'skippedNoSdk' }) continue } - // eslint-disable-next-line no-await-in-loop - const result = await runManifestFacts({ - bin, - buildOpts, - cwd: dir, + const result = await buildUncoveredRoot({ + config: { + ...dirConfig, + ignoreUnresolved: + ignoreUnresolvedOverride ?? dirConfig.ignoreUnresolved, + verbose: verbose || dirConfig.verbose, + }, + covered, ecosystem, - entryFile, // Relative to the scan root: a build can reach modules outside its // own directory (Maven `../x` modules, relocated Gradle projects). excludeBase: realCwd, - excludeConfigs, excludePaths, hints: { continueOnInstallErrorsFlag: withFiles @@ -255,15 +242,15 @@ async function runEcosystemCandidates({ setupCommand: DSI_SETUP_COMMAND, socketJson: displayPath(path.join(dir, SOCKET_JSON)), }, - ignoreUnresolved: ignoreUnresolvedOverride ?? ignoreUnresolved, - includeConfigs, - javaHome, + root, sidecarAcc, tmpDir: ecosystem === 'sbt' ? sbtTmpDir : undefined, - verbose: verbose || configVerbose, withFiles, }) - + if (result === 'covered') { + outcomes.push({ ...root, ecosystem, status: 'skippedCovered' }) + continue + } if (result === null) { outcomes.push({ ...root, ecosystem, status: 'failed' }) const abortedRoots = roots.slice(rootIndex + 1) @@ -281,15 +268,6 @@ async function runEcosystemCandidates({ outcomes.push({ ...root, ecosystem, status: 'empty' }) continue } - - // Gradle's `-p` can root the build away from `dir`. - // eslint-disable-next-line no-await-in-loop - await addCoveredBuildFiles( - covered, - ecosystem, - path.dirname(result.factsPath), - result.projects, - ) outcomes.push({ ...root, ecosystem, @@ -301,6 +279,75 @@ async function runEcosystemCandidates({ return outcomes } +// Builds `root` unless earlier builds cover it, then adds the build files it +// covers. +export async function buildUncoveredRoot({ + config, + covered, + cwd, + ecosystem, + excludeBase, + excludePaths, + hints, + root, + sidecarAcc, + tmpDir, + withFiles, +}: { + config: EcosystemBuildConfig + covered: Set + // Where the build runs, as the user named it; `root.dir` by default. + cwd?: string | undefined + ecosystem: BuildTool + excludeBase?: string | undefined + excludePaths?: string[] | undefined + hints?: BuildOptionHints | undefined + root: BuildRoot + sidecarAcc?: SidecarAccumulator | undefined + tmpDir?: string | undefined + withFiles?: boolean | undefined +}): Promise { + const { bin, buildOpts } = config + if ( + await isRootCovered(covered, ecosystem, root, { + bin, + excludeBase, + excludePaths, + toolOpts: buildOpts, + }) + ) { + return 'covered' + } + const result = await runManifestFacts({ + bin, + buildOpts, + cwd: cwd ?? root.dir, + ecosystem, + entryFile: root.entryFile, + excludeBase, + excludeConfigs: config.excludeConfigs, + excludePaths, + hints, + ignoreUnresolved: config.ignoreUnresolved, + includeConfigs: config.includeConfigs, + javaHome: config.javaHome, + sidecarAcc, + tmpDir, + verbose: config.verbose, + withFiles, + }) + if (result) { + // Gradle's `-p` can root the build away from `root.dir`. + await addCoveredBuildFiles( + covered, + ecosystem, + path.dirname(result.factsPath), + result.projects, + ) + } + return result +} + // A failed root aborts only its own ecosystem's remaining walk: the build // files it would have covered are all its own tool's. export async function generateRecursiveManifests({ @@ -396,7 +443,7 @@ export function resolveEcosystemConfig( if (ecosystem === 'dotnet') { const config = sockJson.defaults?.manifest?.dotnet return { - bin: config?.bin ?? resolveBuildToolBin('dotnet', dir), + bin: config?.bin || resolveBuildToolBin('dotnet', dir), buildOpts: parseBuildToolOpts(config?.dotnetOpts ?? undefined), excludeConfigs: config?.excludeConfigs ?? '', ignoreUnresolved: Boolean(config?.ignoreUnresolved), diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index e631d5794..de975f7b1 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -231,16 +231,11 @@ export async function generateAutoManifest({ 'Detected a .NET solution or project, generating Socket facts...', ) const outcome = await convertDotnetToFacts({ - bin: config.bin, + config, cwd, - dotnetOpts: config.buildOpts, - excludeConfigs: config.excludeConfigs, excludePaths, hints, - ignoreUnresolved: config.ignoreUnresolved, - includeConfigs: config.includeConfigs, sidecarAcc, - verbose: config.verbose, withFiles: computeArtifactsSidecar, }) abortFactsRunIfFailed('dotnet', outcome) diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index 764a9b619..73520aeff 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -2,7 +2,6 @@ import { existsSync } from 'node:fs' import { NAMESPACELESS_PURL_TYPES, PURL_TYPE_BY_TOOL } from './build-tool.mts' import { - type ComponentFramework, type ProjectFramework, type ResolvedArtifactPaths, type SocketFactsSbom, @@ -12,8 +11,14 @@ import { type SocketFactsSbomProject, } from './facts.mts' import { moduleKey } from './resolution-report.mts' - -import type { ParsedRecords, RawCoord, RawProject } from './records.mts' +import { getOrInsert } from '../../../utils/get-or-insert.mts' + +import type { + ParsedRecords, + RawCoord, + RawProject, + RawTarget, +} from './records.mts' import type { ResolutionReport } from './resolution-report.mts' function purlBase( @@ -56,9 +61,7 @@ type SubprojectGraph = { type PerRoot = { projectKey: string prod: boolean - framework: string - runtime: string - alias: string + target?: RawTarget | undefined nodes: Map } @@ -174,9 +177,7 @@ function buildPerRoot(parsed: ParsedRecords): Map { out.set(rootId, { projectKey: r.projectKey, prod: r.prod, - framework: r.framework, - runtime: r.runtime, - alias: r.alias, + target: r.target, nodes, }) } @@ -513,7 +514,9 @@ function buildFrameworks( perRoot: Map, exists: (path: string) => boolean, ): Pick { - const roots = [...perRoot.values()].filter(r => r.framework) + const roots = [...perRoot.values()].filter( + (r): r is PerRoot & { target: RawTarget } => !!r.target, + ) const projects = [...parsed.projects.values()] if (!roots.length && !projects.some(p => p.builds.size)) { return {} @@ -522,66 +525,44 @@ function buildFrameworks( const projectBuilds = new Map>() const projectFramework = ( projectKey: string, - alias: string, - framework: string | undefined, - runtime: string, - ): ProjectFramework => { - let builds = projectBuilds.get(projectKey) - if (!builds) { - builds = new Map() - projectBuilds.set(projectKey, builds) - } - const k = frameworkKey(alias, runtime) - let pf = builds.get(k) - if (!pf) { - const build = parsed.projects.get(projectKey)?.builds.get(alias) - pf = { - alias, - framework: framework || build?.framework || alias, - ...(runtime ? { runtime } : {}), - targetFrameworkMoniker: build?.targetFrameworkMoniker, - language: build?.language, - langVersion: build?.langVersion, - nullable: build?.nullable, - assemblyName: build?.assemblyName, - buildError: build?.buildError, - frameworkReferences: sortedUnique(build?.frameworkReferences ?? []), - defineConstants: [...new Set(build?.defineConstants)], - usings: (build?.usings ?? []).map(u => ({ - namespace: u.namespace, - ...(u.alias ? { alias: u.alias } : {}), - ...(u.static ? { static: true as const } : {}), - })), - // In compiler order, which F# depends on. - sources: [...new Set(build?.sources)].filter(exists), - references: [...new Set(build?.references)].filter(exists), - analyzers: [...new Set(build?.analyzers)].filter(exists), - targets: sortedUnique(build?.targets ?? []).filter(exists), - dependencies: [], - classpath: [], - } - builds.set(k, pf) - } - return pf - } + { alias, framework, runtime }: RawTarget, + ): ProjectFramework => + getOrInsert( + getOrInsert(projectBuilds, projectKey, () => new Map()), + frameworkKey(alias, runtime), + () => { + const build = parsed.projects.get(projectKey)?.builds.get(alias) + return { + ...build, + alias, + framework: framework || build?.framework || alias, + ...(runtime ? { runtime } : {}), + frameworkReferences: sortedUnique(build?.frameworkReferences ?? []), + defineConstants: [...new Set(build?.defineConstants)], + usings: (build?.usings ?? []).map(u => ({ + namespace: u.namespace, + ...(u.alias ? { alias: u.alias } : {}), + ...(u.static ? { static: true as const } : {}), + })), + // In compiler order, which F# depends on. + sources: [...new Set(build?.sources)].filter(exists), + references: [...new Set(build?.references)].filter(exists), + analyzers: [...new Set(build?.analyzers)].filter(exists), + targets: sortedUnique(build?.targets ?? []).filter(exists), + dependencies: [], + classpath: [], + } + }, + ) // Per component and framework: the roots' resolutions. const componentBuilds = new Map< string, - Map< - string, - Omit & { - resolutions: Array<{ - compile: string[] - targets: string[] - unfetched: boolean - }> - } - > + Map >() - for (const r of roots) { - const pf = projectFramework(r.projectKey, r.alias, r.framework, r.runtime) - for (const { 0: id, 1: node } of r.nodes) { + for (const { nodes, projectKey, target } of roots) { + const pf = projectFramework(projectKey, target) + for (const { 0: id, 1: node } of nodes) { pf.classpath.push(id) if (node.direct) { pf.dependencies.push(id) @@ -589,30 +570,20 @@ function buildFrameworks( if (node.project) { continue } - let byFramework = componentBuilds.get(id) - if (!byFramework) { - byFramework = new Map() - componentBuilds.set(id, byFramework) - } - const fk = frameworkKey(r.framework, r.runtime) - let cb = byFramework.get(fk) - if (!cb) { - cb = { framework: r.framework, runtime: r.runtime, resolutions: [] } - byFramework.set(fk, cb) - } + const cb = getOrInsert( + getOrInsert(componentBuilds, id, () => new Map()), + frameworkKey(target.framework, target.runtime), + () => ({ target, resolutions: [] }), + ) if (node.unfetched || node.targets.length || node.compileTargets.length) { - cb.resolutions.push({ - compile: node.compileTargets, - targets: node.targets, - unfetched: node.unfetched, - }) + cb.resolutions.push(node) } } } // A build with nothing to resolve has no root, yet still compiles. for (const p of projects) { for (const alias of p.builds.keys()) { - projectFramework(p.projectKey, alias, undefined, '') + projectFramework(p.projectKey, { alias, framework: '', runtime: '' }) } } @@ -629,22 +600,26 @@ function buildFrameworks( const frameworksById = new Map( [...componentBuilds].map(({ 0: id, 1: byFramework }) => [ id, - [...byFramework.values()].map(({ framework, resolutions, runtime }) => { - const complete = completeResolutions(resolutions, exists, r => [ - ...r.targets, - ...r.compile, - ]) - return { - framework, - ...(runtime ? { runtime } : {}), - ...(complete - ? { - targets: sortedUnique(complete.flatMap(r => r.targets)), - compile: sortedUnique(complete.flatMap(r => r.compile)), - } - : {}), - } - }), + [...byFramework.values()].map( + ({ resolutions, target: { framework, runtime } }) => { + const complete = completeResolutions(resolutions, exists, r => [ + ...r.targets, + ...r.compileTargets, + ]) + return { + framework, + ...(runtime ? { runtime } : {}), + ...(complete + ? { + targets: sortedUnique(complete.flatMap(r => r.targets)), + compile: sortedUnique( + complete.flatMap(r => r.compileTargets), + ), + } + : {}), + } + }, + ), ]), ) return { frameworksByProject, frameworksById } diff --git a/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs index b9785965d..c0cfbf2fa 100644 --- a/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs +++ b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs @@ -70,7 +70,7 @@ public void Execute() { _records.BuildRoot(_buildRoot); _records.Entry(Path.GetFileName(_opts.EntryPath)); if (!File.Exists(_opts.EntryPath)) { - Fail(Path.GetFileName(_opts.EntryPath), "entry file does not exist", ""); + _records.Failure(Path.GetFileName(_opts.EntryPath), "entry file does not exist", ""); return; } @@ -156,7 +156,7 @@ private bool IsExcludedProject(string path) { _solution = new SolutionContext(entry, _opts.GlobalProperties); return _solution.Members.Keys.OrderBy(p => p, StringComparer.Ordinal).ToList(); } catch (Exception e) when (e is InvalidProjectFileException or IOException or JsonException) { - Fail(Rel(entry), $"could not read the solution: {Text.FirstLine(e.Message)}", ""); + _records.Failure(Rel(entry), $"could not read the solution: {Text.FirstLine(e.Message)}", ""); return null; } } @@ -242,7 +242,7 @@ private void RunRestore() { if (filter != null) Text.TryDelete(filter); } if (_restore.TimedOut) { - Fail(Rel(_opts.EntryPath), $"`dotnet restore` did not finish within {Restorer.TimeoutSec}s and was stopped", ""); + _records.Failure(Rel(_opts.EntryPath), $"`dotnet restore` did not finish within {Restorer.TimeoutSec}s and was stopped", ""); } } @@ -288,10 +288,6 @@ private Project LoadInnerBuild(Project project, string targetFramework) { } } - private void Fail(string coord, string detail, string config, string projectKey = "") { - _records.Failure(coord, detail, config, projectKey); - } - // Restore errors not already reported from an assets file, then the projects // left without current assets and without errors of their own. private void ReportRestoreErrors() { @@ -304,11 +300,11 @@ private void ReportRestoreErrors() { if (key.Length > 0 && _assetsReportedErrors.Contains(key)) continue; if (key.Length > 0) withErrors.Add(key); var code = string.IsNullOrEmpty(error.Code) ? "" : $"{error.Code}: "; - Fail(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); + _records.Failure(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); reported += 1; } if (reported == 0 && _assetsReportedErrors.Count == 0) { - Fail( + _records.Failure( Rel(_opts.EntryPath), $"`dotnet restore` failed with exit code {_restore.ExitCode}; run with --verbose to see its output", "" diff --git a/src/commands/manifest/scripts/dotnet-tool/Reader.cs b/src/commands/manifest/scripts/dotnet-tool/Reader.cs index c04ed2588..d7236c73d 100644 --- a/src/commands/manifest/scripts/dotnet-tool/Reader.cs +++ b/src/commands/manifest/scripts/dotnet-tool/Reader.cs @@ -112,32 +112,30 @@ private IEnumerable ReadAssets(ProjectInfo info, Project project) { assetsPath = Path.Combine(project.GetPropertyValue("MSBuildProjectExtensionsPath"), "project.assets.json"); } assetsPath = Path.GetFullPath(assetsPath, project.DirectoryPath); - if (!RestoredByThisRun(info, assetsPath) || !File.Exists(assetsPath)) { + // Null detail: ReportRestoreErrors reports the project with the failed restore. + string[] Unscannable(string? detail) { EmitProject(info, project); - if (_restore == null || _restore.Succeeded) { - _records.Unscannable("", "`dotnet restore` wrote no project.assets.json for this project", info.Key); - } else { + if (detail == null) { _notRestored.Add(info.Key); + } else { + _records.Unscannable("", detail, info.Key); } return Array.Empty(); } + if (!RestoredByThisRun(info, assetsPath) || !File.Exists(assetsPath)) { + return Unscannable( + _restore == null || _restore.Succeeded ? "`dotnet restore` wrote no project.assets.json for this project" : null + ); + } var lockFile = LockFileUtilities.GetLockFile(assetsPath, NullLogger.Instance); var spec = lockFile?.PackageSpec; - if (lockFile == null || spec == null) { - EmitProject(info, project); - _records.Unscannable("", $"could not read {Rel(assetsPath)}", info.Key); - return Array.Empty(); - } + if (lockFile == null || spec == null) return Unscannable($"could not read {Rel(assetsPath)}"); var owner = spec.RestoreMetadata?.ProjectPath; if (!string.IsNullOrEmpty(owner) && !SamePath(owner, info.FullPath)) { - EmitProject(info, project); - _records.Unscannable( - "", - $"{Rel(assetsPath)} belongs to {Rel(owner)}: the projects share an intermediate output directory, so each overwrites the other's restore output; give each its own, e.g. obj/$(MSBuildProjectName)/ in Directory.Build.props", - info.Key + return Unscannable( + $"{Rel(assetsPath)} belongs to {Rel(owner)}: the projects share an intermediate output directory, so each overwrites the other's restore output; give each its own, e.g. obj/$(MSBuildProjectName)/ in Directory.Build.props" ); - return Array.Empty(); } EmitProject(info, project, spec.Name, spec.Version?.ToNormalizedString()); @@ -197,7 +195,7 @@ private void ReportLogErrors(ProjectInfo info, object owner) { foreach (var message in NuGetShim.Seq(owner, "LogMessages")) { if (message.Level != LogLevel.Error) continue; _assetsReportedErrors.Add(info.Key); - Fail( + _records.Failure( string.IsNullOrEmpty(message.LibraryId) ? info.Key : message.LibraryId, $"{message.Code}: {message.Message}", "", @@ -282,7 +280,7 @@ private static bool IsTestProject(Project project) { var reached = Reach(direct, children); var unreached = libraries.Keys.Where(k => !reached.Contains(k)).OrderBy(k => k).ToList(); if (unreached.Count > 0) { - Fail( + _records.Failure( info.Key, $"restore resolved packages no direct dependency leads to: {string.Join(", ", unreached)}", config, @@ -370,7 +368,7 @@ private void EmitPackageAssemblies( if (Resolve(item) is { } path) _records.CompileFile(rootId, id, path); } if (missing.Count > 0) { - Fail( + _records.Failure( $"{lib.Name}:{lib.Version?.ToNormalizedString()}", $"assemblies restore listed are missing from the package folders ({missing[0]}{(missing.Count > 1 ? $" and {missing.Count - 1} more" : "")}); re-run `dotnet restore`", config, @@ -496,7 +494,7 @@ private void EmitHintPathAssemblies( } if (missing.Count > 0) { _records.Unfetched(rootId, id); - Fail( + _records.Failure( $"{identity.Id}:{version}", $"referenced assembly is not installed ({Rel(missing[0])}); restore the packages.config packages (`nuget restore`, or `msbuild -t:restore -p:RestorePackagesConfig=true`)", config, @@ -535,6 +533,9 @@ private static string NormalizeSlashes(string path) { private static string? ShortFrameworkName(Project build) => ShortFrameworkName(build.GetPropertyValue("TargetFrameworkMoniker"), build.GetPropertyValue("TargetPlatformMoniker")); + private static string? ShortFrameworkName(ProjectInstance build) => + ShortFrameworkName(build.GetPropertyValue("TargetFrameworkMoniker"), build.GetPropertyValue("TargetPlatformMoniker")); + private static string? ShortFrameworkName(string moniker, string platformMoniker) { if (string.IsNullOrEmpty(moniker)) return null; try { @@ -599,7 +600,7 @@ private void EmitBuild(ProjectInfo info, Project project, string? innerFramework var error = DesignTimeBuild(project, props, out compiled); if (error != null) { _records.ProjectInfo(info.Key, config, "BuildError", error); - Fail(info.Key, $"the design-time build failed: {error}", config, info.Key); + _records.Failure(info.Key, $"the design-time build failed: {error}", config, info.Key); } } var instance = compiled; @@ -625,7 +626,7 @@ private void EmitBuild(ProjectInfo info, Project project, string? innerFramework if (!string.IsNullOrEmpty(value)) _records.ProjectInfo(info.Key, config, property, value); } // Unrecognized, the alias stands in. - if (ShortFrameworkName(instance.GetPropertyValue("TargetFrameworkMoniker"), instance.GetPropertyValue("TargetPlatformMoniker")) is { } framework) { + if (ShortFrameworkName(instance) is { } framework) { _records.ProjectInfo(info.Key, config, "Framework", framework); } foreach (var reference in instance.GetItems("FrameworkReference").Select(i => i.EvaluatedInclude).Distinct()) { diff --git a/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs b/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs index 08cc4cefd..6db32cfce 100644 --- a/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs +++ b/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs @@ -66,7 +66,7 @@ public void Edge(string rootId, string parentCoordId, string childCoordId) => public void Scanned(string config) => Rec("scanned", config); - public void Failure(string coord, string detail, string config, string projectKey) => + public void Failure(string coord, string detail, string config, string projectKey = "") => Rec("failure", coord, detail, config, projectKey); public void Unscannable(string config, string detail, string projectKey) => diff --git a/src/commands/manifest/scripts/dotnet-tool/Restorer.cs b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs index c487d3fed..e1c8a227a 100644 --- a/src/commands/manifest/scripts/dotnet-tool/Restorer.cs +++ b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs @@ -5,13 +5,14 @@ namespace Socket.Facts.Dotnet; internal sealed record RestoreError(string Code, string Message, string ProjectFile); +// No exit code when the restore timed out and was stopped. internal sealed record RestoreOutcome( - int ExitCode, - bool TimedOut, + int? ExitCode, DateTime StartedUtc, List Errors ) { - public bool Succeeded => !TimedOut && ExitCode == 0; + public bool TimedOut => ExitCode == null; + public bool Succeeded => ExitCode == 0; } // Runs the user's own `dotnet restore`: the same host, SDK selection @@ -64,7 +65,7 @@ public static RestoreOutcome Run(ToolOptions opts, string target, Action } var errors = ReadErrors(errorLog); Text.TryDelete(errorLog); - return new RestoreOutcome(timedOut ? -1 : process.ExitCode, timedOut, started, errors); + return new RestoreOutcome(timedOut ? null : process.ExitCode, started, errors); } private static List ReadErrors(string path) { diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 547792c90..3c2bb2e46 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -54,16 +54,16 @@ export type RawNode = { unfetched: boolean } +// dotnet: the target framework's NuGet short folder name, the runtime +// identifier of a runtime-specific target (empty when none), and the +// framework's alias in the project. +export type RawTarget = { alias: string; framework: string; runtime: string } + export type RawRoot = { rootId: string projectKey: string prod: boolean - // dotnet: the target framework's NuGet short folder name, the runtime - // identifier of a runtime-specific target, and the framework's alias in - // the project; empty otherwise. - framework: string - runtime: string - alias: string + target?: RawTarget | undefined nodes: Map edges: Array<[string, string]> } @@ -195,9 +195,6 @@ export function parseRecords(text: string): ParsedRecords { rootId: id, projectKey: '', prod: false, - framework: '', - runtime: '', - alias: '', nodes: new Map(), edges: [], } @@ -323,9 +320,9 @@ export function parseRecords(text: string): ParsedRecords { const r = root(f[1] ?? '') r.projectKey = f[2] ?? '' r.prod = bool(f[4]) - r.framework = f[5] ?? '' - r.runtime = f[6] ?? '' - r.alias = f[7] ?? '' + if (f[5]) { + r.target = { alias: f[7] ?? '', framework: f[5], runtime: f[6] ?? '' } + } break } case 'node': { diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index b495cea36..7896ffb8c 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -11,6 +11,7 @@ import { parseRecords } from './records.mts' import constants from '../../../constants.mts' import { getErrorMessageOr } from '../../../utils/errors.mts' import { withTmpDir } from '../../../utils/fs.mts' +import { getOrInsert } from '../../../utils/get-or-insert.mts' import { camelToKebab } from '../../../utils/strings.mts' import type { BuildTool } from './build-tool.mts' @@ -501,12 +502,9 @@ function dotnetSdkVersion( ? bin : path.resolve(opts.projectDir, bin) const key = `${host}\0${nearestGlobalJson(opts.projectDir)}` - let version = sdkVersionByPin.get(key) - if (!version) { - version = readDotnetSdkVersion(bin, opts) - sdkVersionByPin.set(key, version) - } - return version + return getOrInsert(sdkVersionByPin, key, () => + readDotnetSdkVersion(bin, opts), + ) } async function readDotnetSdkVersion( @@ -618,12 +616,9 @@ export function listDotnetEntryProjects( opts.excludeBase, opts.toolOpts, ]) - let listing = entryProjectsByRun.get(key) - if (!listing) { - listing = readDotnetEntryProjects(opts) - entryProjectsByRun.set(key, listing) - } - return listing + return getOrInsert(entryProjectsByRun, key, () => + readDotnetEntryProjects(opts), + ) } async function readDotnetEntryProjects( diff --git a/src/commands/manifest/setup-manifest-config.mts b/src/commands/manifest/setup-manifest-config.mts index dc308d3a4..15912af18 100644 --- a/src/commands/manifest/setup-manifest-config.mts +++ b/src/commands/manifest/setup-manifest-config.mts @@ -13,7 +13,45 @@ import { } from '../../utils/socket-json.mts' import type { CResult } from '../../types.mts' -import type { SocketJson } from '../../utils/socket-json.mts' +import type { ManifestDefaults } from '../../utils/socket-json.mts' + +// Whether the prompt was canceled. A blank answer clears a value set before +// with null rather than deleting the key, so it doesn't silently start +// inheriting an ancestor's value again. +function applyAnswer( + config: { [P in K]?: string | null | undefined }, + key: K, + answer: string | undefined, +): boolean { + if (answer === undefined) { + return true + } + if (answer) { + config[key] = answer + } else if (config[key] !== undefined) { + config[key] = null + } else { + delete config[key] + } + return false +} + +// Whether the prompt was canceled. +function applyYesNo( + config: { [P in K]?: boolean | undefined }, + key: K, + answer: string | undefined, +): boolean { + if (answer === undefined) { + return true + } + if (answer === 'yes' || answer === 'no') { + config[key] = answer === 'yes' + } else { + delete config[key] + } + return false +} export async function setupManifestConfig( cwd: string, @@ -216,9 +254,7 @@ export async function setupManifestConfig( } async function setupConda( - config: NonNullable< - NonNullable['manifest']>['conda'] - >, + config: ManifestDefaults<'conda'>, ): Promise> { const on = await askForEnabled(!config.disabled) if (on === undefined) { @@ -270,50 +306,33 @@ async function setupConda( } } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { return canceledByUser() - } else if (verbose === 'yes' || verbose === 'no') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose } return notCanceled() } export async function setupDotnet( - config: NonNullable< - NonNullable['manifest']>['dotnet'] - >, + config: ManifestDefaults<'dotnet'>, ): Promise> { - const priorBin = config.bin - const bin = await askForBin(config.bin || '', 'dotnet') - if (bin === undefined) { + if (applyAnswer(config, 'bin', await askForBin(config.bin || '', 'dotnet'))) { return canceledByUser() - } else if (bin) { - config.bin = bin - } else if (priorBin !== undefined) { - config.bin = null - } else { - delete config.bin } - const priorDotnetOpts = config.dotnetOpts - const opts = await input({ - message: - '(--dotnet-opts) Enter options to pass to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`', - default: config.dotnetOpts || '', - required: false, - }) - if (opts === undefined) { + if ( + applyAnswer( + config, + 'dotnetOpts', + await input({ + message: + '(--dotnet-opts) Enter options to pass to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`', + default: config.dotnetOpts || '', + required: false, + }), + ) + ) { return canceledByUser() - } else if (opts) { - config.dotnetOpts = opts - } else if (priorDotnetOpts !== undefined) { - config.dotnetOpts = null - } else { - delete config.dotnetOpts } const factsOptions = await setupFactsOptions(config, 'target framework') @@ -321,75 +340,49 @@ export async function setupDotnet( return factsOptions } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { return canceledByUser() - } else if (verbose === 'yes' || verbose === 'no') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose } return notCanceled() } export async function setupGradle( - config: NonNullable< - NonNullable['manifest']>['gradle'] - >, + config: ManifestDefaults<'gradle'>, // Set by the recursive dynamic-sbom-inference wizard, which has no // pom-mode equivalent - skips the facts/pom question entirely. { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { - const priorBin = config.bin - const bin = await askForBin(config.bin || '', './gradlew') - if (bin === undefined) { + if ( + applyAnswer(config, 'bin', await askForBin(config.bin || '', './gradlew')) + ) { return canceledByUser() - } else if (bin) { - config.bin = bin - } else if (priorBin !== undefined) { - config.bin = null - } else { - delete config.bin } - const priorJavaHome = config.javaHome - const javaHome = await askForJavaHome(config.javaHome || '') - if (javaHome === undefined) { + if ( + applyAnswer(config, 'javaHome', await askForJavaHome(config.javaHome || '')) + ) { return canceledByUser() - } else if (javaHome) { - config.javaHome = javaHome - } else if (priorJavaHome !== undefined) { - config.javaHome = null - } else { - delete config.javaHome } - const priorGradleOpts = config.gradleOpts - const opts = await input({ - message: '(--gradle-opts) Enter gradle options to pass through', - default: config.gradleOpts || '', - required: false, - // validate: async string => bool - }) - if (opts === undefined) { + if ( + applyAnswer( + config, + 'gradleOpts', + await input({ + message: '(--gradle-opts) Enter gradle options to pass through', + default: config.gradleOpts || '', + required: false, + // validate: async string => bool + }), + ) + ) { return canceledByUser() - } else if (opts) { - config.gradleOpts = opts - } else if (priorGradleOpts !== undefined) { - config.gradleOpts = null - } else { - delete config.gradleOpts } if (!factsOnly) { - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { + if (applyYesNo(config, 'facts', await askForFactsFlag(config.facts))) { return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts } } @@ -402,61 +395,38 @@ export async function setupGradle( } } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { return canceledByUser() - } else if (verbose === 'yes' || verbose === 'no') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose } return notCanceled() } export async function setupMaven( - config: NonNullable< - NonNullable['manifest']>['maven'] - >, + config: ManifestDefaults<'maven'>, ): Promise> { - const priorBin = config.bin - const bin = await askForBin(config.bin || '', 'mvn') - if (bin === undefined) { + if (applyAnswer(config, 'bin', await askForBin(config.bin || '', 'mvn'))) { return canceledByUser() - } else if (bin) { - config.bin = bin - } else if (priorBin !== undefined) { - config.bin = null - } else { - delete config.bin } - const priorJavaHome = config.javaHome - const javaHome = await askForJavaHome(config.javaHome || '') - if (javaHome === undefined) { + if ( + applyAnswer(config, 'javaHome', await askForJavaHome(config.javaHome || '')) + ) { return canceledByUser() - } else if (javaHome) { - config.javaHome = javaHome - } else if (priorJavaHome !== undefined) { - config.javaHome = null - } else { - delete config.javaHome } - const priorMavenOpts = config.mavenOpts - const opts = await input({ - message: '(--maven-opts) Enter maven options to pass through', - default: config.mavenOpts || '', - required: false, - }) - if (opts === undefined) { + if ( + applyAnswer( + config, + 'mavenOpts', + await input({ + message: '(--maven-opts) Enter maven options to pass through', + default: config.mavenOpts || '', + required: false, + }), + ) + ) { return canceledByUser() - } else if (opts) { - config.mavenOpts = opts - } else if (priorMavenOpts !== undefined) { - config.mavenOpts = null - } else { - delete config.mavenOpts } // Maven only generates Socket facts (no pom path), so always ask the @@ -466,74 +436,46 @@ export async function setupMaven( return factsOptions } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { return canceledByUser() - } else if (verbose === 'yes' || verbose === 'no') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose } return notCanceled() } export async function setupSbt( - config: NonNullable< - NonNullable['manifest']>['sbt'] - >, + config: ManifestDefaults<'sbt'>, // See setupGradle's matching parameter for why. { factsOnly = false }: { factsOnly?: boolean } = {}, ): Promise> { - const priorBin = config.bin - const bin = await askForBin(config.bin || '', 'sbt') - if (bin === undefined) { + if (applyAnswer(config, 'bin', await askForBin(config.bin || '', 'sbt'))) { return canceledByUser() - } else if (bin) { - config.bin = bin - } else if (priorBin !== undefined) { - config.bin = null - } else { - delete config.bin } - const priorJavaHome = config.javaHome - const javaHome = await askForJavaHome(config.javaHome || '') - if (javaHome === undefined) { + if ( + applyAnswer(config, 'javaHome', await askForJavaHome(config.javaHome || '')) + ) { return canceledByUser() - } else if (javaHome) { - config.javaHome = javaHome - } else if (priorJavaHome !== undefined) { - config.javaHome = null - } else { - delete config.javaHome } - const priorSbtOpts = config.sbtOpts - const opts = await input({ - message: '(--sbt-opts) Enter sbt options to pass through', - default: config.sbtOpts || '', - required: false, - // validate: async string => bool - }) - if (opts === undefined) { + if ( + applyAnswer( + config, + 'sbtOpts', + await input({ + message: '(--sbt-opts) Enter sbt options to pass through', + default: config.sbtOpts || '', + required: false, + // validate: async string => bool + }), + ) + ) { return canceledByUser() - } else if (opts) { - config.sbtOpts = opts - } else if (priorSbtOpts !== undefined) { - config.sbtOpts = null - } else { - delete config.sbtOpts } if (!factsOnly) { - const facts = await askForFactsFlag(config.facts) - if (facts === undefined) { + if (applyYesNo(config, 'facts', await askForFactsFlag(config.facts))) { return canceledByUser() - } else if (facts === 'yes' || facts === 'no') { - config.facts = facts === 'yes' - } else { - delete config.facts } } @@ -574,13 +516,8 @@ export async function setupSbt( } } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { return canceledByUser() - } else if (verbose === 'yes' || verbose === 'no') { - config.verbose = verbose === 'yes' - } else { - delete config.verbose } return notCanceled() @@ -789,49 +726,42 @@ async function setupFactsOptions( }, configNoun = 'config-name', ): Promise> { - const priorIncludeConfigs = config.includeConfigs - const includeConfigs = await input({ - message: `(--include-configs) Comma-separated ${configNoun} globs to resolve (blank = all)`, - default: config.includeConfigs || '', - required: false, - }) - if (includeConfigs === undefined) { + if ( + applyAnswer( + config, + 'includeConfigs', + await input({ + message: `(--include-configs) Comma-separated ${configNoun} globs to resolve (blank = all)`, + default: config.includeConfigs || '', + required: false, + }), + ) + ) { return canceledByUser() - } else if (includeConfigs) { - config.includeConfigs = includeConfigs - } else if (priorIncludeConfigs !== undefined) { - // Was previously set; clear it explicitly instead of just deleting the - // key, so it doesn't silently start inheriting an ancestor's value again. - config.includeConfigs = null - } else { - delete config.includeConfigs } - const priorExcludeConfigs = config.excludeConfigs - const excludeConfigs = await input({ - message: `(--exclude-configs) Comma-separated ${configNoun} globs to skip (blank = none)`, - default: config.excludeConfigs || '', - required: false, - }) - if (excludeConfigs === undefined) { + if ( + applyAnswer( + config, + 'excludeConfigs', + await input({ + message: `(--exclude-configs) Comma-separated ${configNoun} globs to skip (blank = none)`, + default: config.excludeConfigs || '', + required: false, + }), + ) + ) { return canceledByUser() - } else if (excludeConfigs) { - config.excludeConfigs = excludeConfigs - } else if (priorExcludeConfigs !== undefined) { - config.excludeConfigs = null - } else { - delete config.excludeConfigs } - const ignoreUnresolved = await askForIgnoreUnresolvedFlag( - config.ignoreUnresolved, - ) - if (ignoreUnresolved === undefined) { + if ( + applyYesNo( + config, + 'ignoreUnresolved', + await askForIgnoreUnresolvedFlag(config.ignoreUnresolved), + ) + ) { return canceledByUser() - } else if (ignoreUnresolved === 'yes' || ignoreUnresolved === 'no') { - config.ignoreUnresolved = ignoreUnresolved === 'yes' - } else { - delete config.ignoreUnresolved } return notCanceled() diff --git a/src/commands/manifest/setup-recursive-manifest-config.mts b/src/commands/manifest/setup-recursive-manifest-config.mts index 9fe84f305..e2a0e1dc8 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -27,6 +27,7 @@ import { } from './setup-manifest-config.mts' import { SOCKET_JSON } from '../../constants.mts' import { loadSocketConfig } from '../../utils/config.mts' +import { getOrInsert } from '../../utils/get-or-insert.mts' import { readOrDefaultSocketJson, readSocketJsonCascade, @@ -38,7 +39,7 @@ import { excludePathToScanIgnores } from '../scan/exclude-paths.mts' import type { BuildRoot } from './discover-manifest-roots.mts' import type { BuildTool } from './scripts/build-tool.mts' import type { CResult } from '../../types.mts' -import type { SocketJson } from '../../utils/socket-json.mts' +import type { ManifestDefaults, SocketJson } from '../../utils/socket-json.mts' // One prompt per directory, as settings are per directory; Maven may have // several POMs, so several roots, there. @@ -343,33 +344,17 @@ async function runEcosystemWizard( config: Record, ): Promise> { if (ecosystem === 'dotnet') { - return await setupDotnet( - config as NonNullable< - NonNullable['manifest']>['dotnet'] - >, - ) + return await setupDotnet(config as ManifestDefaults<'dotnet'>) } if (ecosystem === 'gradle') { - return await setupGradle( - config as NonNullable< - NonNullable['manifest']>['gradle'] - >, - { factsOnly: true }, - ) + return await setupGradle(config as ManifestDefaults<'gradle'>, { + factsOnly: true, + }) } if (ecosystem === 'maven') { - return await setupMaven( - config as NonNullable< - NonNullable['manifest']>['maven'] - >, - ) + return await setupMaven(config as ManifestDefaults<'maven'>) } - return await setupSbt( - config as NonNullable< - NonNullable['manifest']>['sbt'] - >, - { factsOnly: true }, - ) + return await setupSbt(config as ManifestDefaults<'sbt'>, { factsOnly: true }) } // Seeds the wizard with this candidate's effective (cascaded) value for any @@ -868,14 +853,17 @@ export async function setupRecursiveManifestConfig( logger.log('re-run with --exclude-paths instead.') logger.log('') - const covered = new Set() + // Per tool: a solution counts as covered only by .NET projects. + const coveredByTool = new Map>() + const coveredFor = (ecosystem: BuildTool) => + getOrInsert(coveredByTool, ecosystem, () => new Set()) // cwd never appears in `included`, but a reactor root often sits exactly // at cwd - seed coverage from it first so its declared members still get // recognized. for (const ecosystem of ROOT_ECOSYSTEMS) { // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ - covered, + covered: coveredFor(ecosystem), cwd: realCwd, dir: realCwd, ecosystem, @@ -904,7 +892,7 @@ export async function setupRecursiveManifestConfig( const { ecosystem } = candidate if ( candidate.roots.every(root => - isBuildRootCovered(covered, ecosystem, root), + isBuildRootCovered(coveredFor(ecosystem), ecosystem, root), ) ) { counts.covered += 1 @@ -937,7 +925,7 @@ export async function setupRecursiveManifestConfig( // build-tool invocation only runs for a project already prompted about. // eslint-disable-next-line no-await-in-loop const coverageResult = await markWorkspaceCoverage({ - covered, + covered: coveredFor(ecosystem), cwd: realCwd, dir: candidate.dir, ecosystem, diff --git a/src/utils/get-or-insert.mts b/src/utils/get-or-insert.mts new file mode 100644 index 000000000..839a0c726 --- /dev/null +++ b/src/utils/get-or-insert.mts @@ -0,0 +1,8 @@ +export function getOrInsert(map: Map, key: K, make: () => V): V { + if (map.has(key)) { + return map.get(key) as V + } + const value = make() + map.set(key, value) + return value +} diff --git a/src/utils/socket-json.mts b/src/utils/socket-json.mts index 59a2888b7..3ed36ad06 100644 --- a/src/utils/socket-json.mts +++ b/src/utils/socket-json.mts @@ -147,6 +147,10 @@ export interface SocketJson { } } +export type ManifestDefaults< + K extends keyof NonNullable['manifest']>, +> = NonNullable['manifest']>[K]> + export function readOrDefaultSocketJson(cwd: string): SocketJson { const jsonCResult = readSocketJsonSync(cwd, true) return jsonCResult.ok