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-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 8a64cf5be..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,6 +123,7 @@ describe('socket fix --dynamic-sbom-inference', () => { } const uploadManifestFiles = vi.fn() const generated = { + artifactsFlags: ['--maven-use-only-socket-facts'], paths: [FACTS], sidecarFile: '/tmp/socket-fix-facts/sidecar.json', remove: vi.fn(), diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 8df4de75b..368d118f8 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -392,7 +392,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 +412,7 @@ 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?.generated?.artifactsFlags ?? [] 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..fc49bae70 100644 --- a/src/commands/fix/generated-socket-facts.mts +++ b/src/commands/fix/generated-socket-facts.mts @@ -1,11 +1,14 @@ 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' export type GeneratedSocketFacts = { + // Coana's flags selecting what the facts files resolve. + artifactsFlags: string[] paths: string[] - // The facts files' per-project classpaths, outside the repository. + // The facts files' per-project classpaths, written outside the repository. sidecarFile: string | undefined remove: () => Promise restore: () => Promise @@ -52,6 +55,7 @@ export async function generateSocketFactsForFix({ ) await restoreAll(originals) } + let artifactsFlags: string[] let paths: string[] let sidecarFile: string | undefined try { @@ -62,6 +66,7 @@ export async function generateSocketFactsForFix({ sidecar: true, withFiles: false, }) + artifactsFlags = useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar) paths = factsPaths.map(p => path.resolve(cwd, p)) if (resolvedPathsSidecar) { sidecarFile = path.join(tmpDir, 'sidecar.json') @@ -78,6 +83,7 @@ export async function generateSocketFactsForFix({ ...(await backUp(paths, path.join(tmpDir, 'generated'))), ] return { + artifactsFlags, paths, sidecarFile, remove: () => reset(paths), diff --git a/src/commands/fix/generated-socket-facts.test.mts b/src/commands/fix/generated-socket-facts.test.mts index 2d5a4a9cd..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 { sidecarFile } = await generate() + const { artifactsFlags, sidecarFile } = await generate() expect(mockRunDynamicSbomInference).toHaveBeenCalledWith( expect.objectContaining({ sidecar: true }), ) + expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts']) expect(path.dirname(sidecarFile!)).toBe(tmpDir) expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({ - [fresh]: {}, + [fresh]: { components: [], projects: [] }, }) }) diff --git a/src/commands/manifest/cmd-manifest-dotnet.mts b/src/commands/manifest/cmd-manifest-dotnet.mts new file mode 100644 index 000000000..8ce029236 --- /dev/null +++ b/src/commands/manifest/cmd-manifest-dotnet.mts @@ -0,0 +1,214 @@ +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' +import { resolveEcosystemConfig } from './generate-recursive-manifests.mts' +import { excludePathsFlag } from './manifest-flags.mts' +import { parseBuildToolOpts } from './parse-build-tool-opts.mts' +import constants, { SOCKET_JSON } from '../../constants.mts' +import { commonFlags } from '../../flags.mts' +import { checkCommandInput } from '../../utils/check-input.mts' +import { 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 { camelToKebab } from '../../utils/strings.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] ?? '.') + const isFile = !!safeStatsSync(target)?.isFile() + 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) + + 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 + } + // An empty --bin or --dotnet-opts leaves the default in place. + const flags = { + ...given, + bin: given.bin || undefined, + dotnetOpts: given.dotnetOpts || undefined, + } + 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, + ) + } + } + 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, ':') + 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({ + config: dotnetConfig, + cwd, + entryFile, + excludePaths, + }) +} 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..79150ba4b --- /dev/null +++ b/src/commands/manifest/convert-dotnet-to-facts.mts @@ -0,0 +1,69 @@ +import { logger } from '@socketsecurity/registry/lib/logger' + +import { findDotnetEntries } from './detect-manifest-actions.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' + +// 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({ + config, + cwd, + entryFile, + excludePaths, + hints, + sidecarAcc, + withFiles, +}: { + config: EcosystemBuildConfig + cwd: string + // Builds only this file (relative to cwd) when set. + entryFile?: string | undefined + excludePaths?: string[] | undefined + hints?: BuildOptionHints | undefined + sidecarAcc?: SidecarAccumulator | undefined + 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) { + // eslint-disable-next-line no-await-in-loop + const outcome = await buildUncoveredRoot({ + config, + covered, + cwd, + ecosystem: 'dotnet', + excludePaths, + hints, + root: { dir, entryFile: entry }, + sidecarAcc, + withFiles, + }) + if (outcome === 'covered') { + if (config.verbose) { + logger.info(`Skipping \`${entry}\`: earlier builds cover its projects.`) + } + continue + } + if (outcome === null) { + return null + } + last = outcome ?? last + } + return last +} diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts index 4198e1729..5cee9f39c 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((a, b) => compareEntryFiles('dotnet', a, b)) +} + 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..3dd651bca 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)) +} + +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 @@ -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,13 +132,74 @@ 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. +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)) +} + +// 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) @@ -122,7 +209,9 @@ export function sortByDepthThenPath( return ( depthA - depthB || compareStrings(relA, relB) || - compareStrings(a.entryFile ?? '', b.entryFile ?? '') + (tool + ? compareEntryFiles(tool, a.entryFile ?? '', b.entryFile ?? '') + : compareStrings(a.entryFile ?? '', b.entryFile ?? '')) ) }) } @@ -193,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 b28159226..c73b2282a 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, + isRootCovered, 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' @@ -26,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' @@ -38,6 +44,7 @@ export type RecursiveManifestOutcomeStatus = | 'generated' | 'skippedCovered' | 'skippedDisabled' + | 'skippedNoSdk' export type RecursiveManifestOutcome = BuildRoot & { ecosystem: BuildTool @@ -167,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 @@ -200,29 +208,28 @@ 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 } - + if ( + ecosystem === 'dotnet' && + // eslint-disable-next-line no-await-in-loop + (await skipWithoutDotnetSdk(dirConfig.bin, dir)) + ) { + 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, - excludeConfigs, // Relative to the scan root: a build can reach modules outside its // own directory (Maven `../x` modules, relocated Gradle projects). excludeBase: realCwd, @@ -235,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) @@ -261,14 +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, - path.dirname(result.factsPath), - result.projects, - ) outcomes.push({ ...root, ecosystem, @@ -280,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({ @@ -372,6 +440,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..de975f7b1 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,24 @@ 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({ + config, + cwd, + excludePaths, + hints, + sidecarAcc, + 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..73520aeff 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -1,6 +1,8 @@ import { existsSync } from 'node:fs' +import { NAMESPACELESS_PURL_TYPES, PURL_TYPE_BY_TOOL } from './build-tool.mts' import { + type ProjectFramework, type ResolvedArtifactPaths, type SocketFactsSbom, type SocketFactsSbomComponent, @@ -9,11 +11,25 @@ import { type SocketFactsSbomProject, } from './facts.mts' import { moduleKey } from './resolution-report.mts' +import { getOrInsert } from '../../../utils/get-or-insert.mts' -import type { ParsedRecords, RawCoord, RawProject } from './records.mts' +import type { + ParsedRecords, + RawCoord, + RawProject, + RawTarget, +} from './records.mts' import type { ResolutionReport } from './resolution-report.mts' -const PURL_TYPE_MAVEN = 'maven' +function purlBase( + type: string, + group: string, + name: string, +): { type: string; namespace?: string; name: string } { + return NAMESPACELESS_PURL_TYPES.has(type) + ? { type, name } + : { type, namespace: group, name } +} export type AssembleResult = { facts: SocketFactsSbom @@ -45,12 +61,14 @@ type SubprojectGraph = { type PerRoot = { projectKey: string prod: boolean + target?: RawTarget | undefined nodes: Map } type RootNode = { coord: RawCoord children: string[] + compileTargets: string[] direct: boolean project: string targets: string[] @@ -61,17 +79,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 +118,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 +150,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 +161,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 +174,12 @@ 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, + target: r.target, + 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,146 @@ 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 is PerRoot & { target: RawTarget } => !!r.target, + ) + const projects = [...parsed.projects.values()] + if (!roots.length && !projects.some(p => p.builds.size)) { + return {} + } + + const projectBuilds = new Map>() + const projectFramework = ( + projectKey: string, + { 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 + >() + 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) + } + if (node.project) { + continue + } + 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(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, framework: '', runtime: '' }) + } + } + + 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( + ({ 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 } +} + 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..5db050c58 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -3,11 +3,27 @@ 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 + +// 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, + dotnet: 'dotnet', gradle: 'gradle', maven: 'mvn', sbt: 'sbt', @@ -26,12 +42,33 @@ 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 + // Within one directory, lower runs first; by name otherwise. + entryRank?: (file: string) => number + } // `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, + // A solution covers the projects it lists. + entryRank: (file: string) => (isDotnetSolution(file) ? 0 : 1), + }, gradle: { byFile: false, names: [ @@ -74,6 +111,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..c0cfbf2fa --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs @@ -0,0 +1,345 @@ +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; +} + +// 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)) { + _records.Failure(Path.GetFileName(_opts.EntryPath), "entry file does not exist", ""); + return; + } + + var entryProjects = EntryProjects(); + if (entryProjects == null) return; + if (_opts.Mode == ToolMode.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.Mode == ToolMode.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); + if (project == null) continue; + info.Style = StyleOf(project, out _); + 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, 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) && !Path.IsPathRooted(rel) + && _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) { + _records.Failure(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) { + _records.Failure(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); + } + + // 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 (!info.Excluded) { + _records.Unscannable("", $"could not evaluate the project: {Text.FirstLine(e.Message)}", info.Key); + } + return null; + } + } + + // 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) 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}: "; + _records.Failure(key.Length > 0 ? key : Rel(_opts.EntryPath), $"{code}{error.Message}", "", key); + reported += 1; + } + if (reported == 0 && _assetsReportedErrors.Count == 0) { + _records.Failure( + 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); + } + } + + 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..78b26c7f1 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Patterns.cs @@ -0,0 +1,138 @@ +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 { + // 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); + } + + 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..d7236c73d --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Reader.cs @@ -0,0 +1,789 @@ +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); + // 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); + if (project == null) return references; + 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); + } + return references; + } + references.AddRange(ProjectReferences(project, innerBuilds: false)); + switch (info.Style) { + case RestoreStyle.PackageReference: + references.AddRange(ReadAssets(info, project)); + break; + case RestoreStyle.PackagesConfig when packagesConfig != null: + ReadPackagesConfig(info, project, packagesConfig); + 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); + 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); + // Null detail: ReportRestoreErrors reports the project with the failed restore. + string[] Unscannable(string? detail) { + EmitProject(info, project); + 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) return Unscannable($"could not read {Rel(assetsPath)}"); + var owner = spec.RestoreMetadata?.ProjectPath; + if (!string.IsNullOrEmpty(owner) && !SamePath(owner, info.FullPath)) { + 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" + ); + } + + 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); + _records.Failure( + string.IsNullOrEmpty(message.LibraryId) ? info.Key : message.LibraryId, + $"{message.Code}: {message.Message}", + "", + info.Key + ); + } + } + + // 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) || File.GetLastWriteTimeUtc(cachePath) < _restore.StartedUtc - MtimeResolution) return false; + using (var stream = File.OpenRead(cachePath)) { + ReportLogErrors(info, CacheFileFormat.Read(stream, NullLogger.Instance, cachePath)); + } + 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) { + _records.Failure( + 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) { + _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, + 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, string packagesConfig) { + EmitProject(info, project); + _records.ProjectBuild(info.Key, Rel(packagesConfig)); + if (_opts.WithFiles) EmitPaths(info, project); + + List packages; + try { + 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(packagesConfig)}: {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); + _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, + 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(ProjectInstance 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. + // `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 (frameworks ?? multi).Where(f => _configs.Matches(new[] { f }))) 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); + _records.Failure(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) 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..6db32cfce --- /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..e1c8a227a --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/Restorer.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +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, + DateTime StartedUtc, + List Errors +) { + public bool TimedOut => ExitCode == null; + public bool Succeeded => 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 ? null : process.ExitCode, started, errors); + } + + 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 = Text.IsProjectFile(origin) && File.Exists(origin) + ? origin + : 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 new file mode 100644 index 000000000..1f42af6de --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs @@ -0,0 +1,101 @@ +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 path = Path.Combine(Path.GetTempPath(), $"socket-facts-{Guid.NewGuid():N}.slnf"); + var doc = new { + solution = new { + path = _solutionPath, + projects = members + .Select(m => Members[m].RelativePath) + .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 && Text.IsProjectFile(project.AbsolutePath)); + } + + // 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..744da70f8 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs @@ -0,0 +1,167 @@ +namespace Socket.Facts.Dotnet; + +internal enum ToolMode { + Facts, + Members, + Workspaces, +} + +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 ToolMode Mode = ToolMode.Facts; + 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.SetMode(ToolMode.Workspaces, arg); + break; + case "--members": + opts.SetMode(ToolMode.Members, arg); + 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 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"); + 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..e4d3badc9 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/packages.lock.json @@ -0,0 +1,281 @@ +{ + "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==" + }, + "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", + "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.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.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..376def028 --- /dev/null +++ b/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj @@ -0,0 +1,48 @@ + + + + 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..3c2bb2e46 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,18 +49,73 @@ export type RawNode = { project: string // --with-files only. targets: string[] + // dotnet, --with-files only. + compileTargets: string[] 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 - config: string prod: boolean + target?: RawTarget | undefined 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 = { + 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[] + 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 @@ -69,6 +126,7 @@ export type RawProject = { targets: string[] // The project's build files, build-root-relative. buildFiles: string[] + builds: Map } export type ParsedRecords = { @@ -81,6 +139,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 +178,7 @@ export function parseRecords(text: string): ParsedRecords { buildRoot: '', entry: '', projects: new Map(), + members: [], excludedProjects: new Map(), roots: new Map(), scannedConfigs: [], @@ -134,7 +194,6 @@ export function parseRecords(text: string): ParsedRecords { r = { rootId: id, projectKey: '', - config: '', prod: false, nodes: new Map(), edges: [], @@ -155,13 +214,34 @@ 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 = { + analyzers: [], + defineConstants: [], + frameworkReferences: [], + references: [], + sources: [], + targets: [], + 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 +258,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 +274,38 @@ 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 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': + 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': @@ -207,8 +319,10 @@ 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]) + if (f[5]) { + r.target = { alias: f[7] ?? '', framework: f[5], runtime: f[6] ?? '' } + } break } case 'node': { @@ -226,6 +340,7 @@ export function parseRecords(text: string): ParsedRecords { direct: bool(f[8]), project: f[9] ?? '', targets: [], + compileTargets: [], unfetched: false, }) break @@ -252,6 +367,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..1df27f6e3 --- /dev/null +++ b/src/commands/manifest/scripts/resolution-report-nuget.mts @@ -0,0 +1,63 @@ +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 a design-time build failure as `: : `; its +// findings without a code count as other failures. +export function classifyNugetFailure(detail: string): FailureCategory { + 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' + } + 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. + return 'config-problem' +} + +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..4b2b61b20 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' @@ -239,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 @@ -266,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 @@ -475,6 +475,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..7896ffb8c 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,7 +9,10 @@ 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 { getOrInsert } from '../../../utils/get-or-insert.mts' +import { camelToKebab } from '../../../utils/strings.mts' import type { BuildTool } from './build-tool.mts' import type { @@ -153,9 +157,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 +184,19 @@ 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, + // The package.json script that builds it, as the dist build names it too. + 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: pnpm run ${buildScript}`, ) } @@ -189,6 +207,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 +224,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, @@ -218,34 +240,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 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 props + return scriptOptions(opts).flatMap(({ 0: name, 1: value }) => { + const flag = `--${camelToKebab(name)}` + return value === true ? [flag] : [flag, value] + }) } async function invokeGradle( @@ -385,7 +426,11 @@ async function invokeMaven( 'maven-extension', 'coana-maven-extension.jar', ) - assertMavenExtensionBuilt(jarPath) + assertBundledAssetBuilt( + jarPath, + 'Maven manifest extension jar', + 'build:maven-extension', + ) return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') const bin = resolveBuildToolBin('maven', opts.projectDir, opts.bin) @@ -425,3 +470,173 @@ async function enumerateMavenWorkspaces( 'socket-maven-workspaces-', ) } + +// 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 { + // 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)}` + return getOrInsert(sdkVersionByPin, key, () => + readDotnetSdkVersion(bin, opts), + ) +} + +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', '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), + // 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 args = [ + toolDll, + '--records', + recordsFile, + '--entry', + opts.entryFile!, + '--dotnet', + bin, + ...(mode ? [mode] : []), + ...dotnetToolArgs(opts), + ...(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) +} + +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 function listDotnetEntryProjects( + opts: ManifestScriptOptions, +): Promise { + const key = JSON.stringify([ + opts.bin, + opts.projectDir, + opts.entryFile, + opts.excludePaths, + opts.excludeBase, + opts.toolOpts, + ]) + return getOrInsert(entryProjectsByRun, key, () => + readDotnetEntryProjects(opts), + ) +} + +async function readDotnetEntryProjects( + 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..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, @@ -53,6 +91,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 +189,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 = {} @@ -203,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) { @@ -257,75 +306,83 @@ async function setupConda( } } - const verbose = await askForVerboseFlag(config.verbose) - if (verbose === undefined) { + if (applyYesNo(config, 'verbose', await askForVerboseFlag(config.verbose))) { + return canceledByUser() + } + + return notCanceled() +} + +export async function setupDotnet( + config: ManifestDefaults<'dotnet'>, +): Promise> { + if (applyAnswer(config, 'bin', await askForBin(config.bin || '', 'dotnet'))) { + return canceledByUser() + } + + 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() + } + + const factsOptions = await setupFactsOptions(config, 'target framework') + if (!factsOptions.ok || factsOptions.data.canceled) { + return factsOptions + } + + 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 } } @@ -338,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 @@ -402,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 } } @@ -510,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() @@ -717,56 +718,50 @@ 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> { - const priorIncludeConfigs = config.includeConfigs - const includeConfigs = await input({ - message: - '(--include-configs) Comma-separated config-name globs to resolve (blank = all configurations)', - default: config.includeConfigs || '', - required: false, - }) - if (includeConfigs === undefined) { +async function setupFactsOptions( + config: { + excludeConfigs?: string | undefined | null + ignoreUnresolved?: boolean | undefined + includeConfigs?: string | undefined | null + }, + configNoun = 'config-name', +): Promise> { + 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 config-name 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 e5bc9c041..e2a0e1dc8 100644 --- a/src/commands/manifest/setup-recursive-manifest-config.mts +++ b/src/commands/manifest/setup-recursive-manifest-config.mts @@ -11,15 +11,23 @@ import { buildRootPath, findBuildToolCandidates, isBuildRootCovered, + isRootCovered, 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 { getOrInsert } from '../../utils/get-or-insert.mts' import { readOrDefaultSocketJson, readSocketJsonCascade, @@ -31,16 +39,17 @@ 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. 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 +241,20 @@ 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 ( + // eslint-disable-next-line no-await-in-loop + await isRootCovered(covered, ecosystem, 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,27 +343,18 @@ async function runEcosystemWizard( ecosystem: BuildTool, config: Record, ): Promise> { + if (ecosystem === '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 @@ -841,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, @@ -877,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 @@ -910,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/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/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 a20ace474..3ed36ad06 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 @@ -131,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 @@ -178,6 +198,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,