From b750cb06a9253faa7c6d907dd9e18768088d6c60 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Wed, 5 Aug 2026 14:30:38 -0400 Subject: [PATCH 1/4] feat(emitters): add the dotnet facts emitter and centralize config globs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring .NET/NuGet to the facts pipeline at parity with the JVM emitters. Emitter: - Bundled C# tool (socket-facts-dotnet) running one MSBuild session — evaluate, in-process restore, read project.assets.json through NuGet.ProjectModel — emitting the shared records TSV. Ships no NuGet or MSBuild runtime assemblies: it compiles low and runs high against the locator-selected SDK (net6 floor, RollForward LatestMajor), so it works across installed SDK versions and avoids the ref/def assembly clash that breaks multi-SDK hosts. Restore forces TreatWarningsAsErrors=false so NU19xx security advisories do not abort the run. packages.config is supported: flat pinned closure, developmentDependency to dev, HintPath DLLs, and pinned-artifact download through the project's configured feeds. Fail-closed: it records failures instead of throwing, and every emitted artifact path is guaranteed to exist. - runFactsGeneration accepts tool: 'dotnet'; assets.mts resolves the published tool and throws when a packaging defect leaves it out. - NuGet resolution dialect, including the noun a .NET user expects: the report says "target framework" and points at --exclude-target-frameworks rather than --exclude-configs. Contract: - Components and projects carry the purl type their tool produces, from an exhaustive per-tool map, and a groupless nuget coordinate omits the namespace key instead of serializing an empty one. - Sidecar entries carry an ecosystem tag, so a nuget coordinate and a maven coordinate that share a name stay distinct. The producer always writes it; a payload without it is still valid and means maven. Shipping this needs the reachability sidecar's nuget schema change released first, because that consumer parses strictly. See docs/agents.md/repo/contract.md. - Resolution reports carry configsByProject, which attributes each resolved configuration to the project that resolved it. The flat union loses that as soon as two projects resolve different sets, which is routine for .NET. Config-name globs: - Compile include/exclude globs to anchored regex sources once, in config-glob.mts, and hand every emitter the compiled patterns. Removes the per-language globToRegex from the Gradle, sbt, and Maven emitters and adds a cross-language vector suite. Fail-closed fixes over the original branch: - The records writer forces LF. On Windows the default would glue a stray carriage return to each record's last field, flipping prod/direct flags, orphaning every edge, and failing every artifact path's exists-check, with the scan still reporting success. The records parser now also tolerates CRLF, so one emitter regressing cannot corrupt a scan. - A crash after partial output records a failure, so a truncated SBOM can no longer be published as a success. - The restore fallback record is gated on what was reported rather than on what a logger captured, so a restore whose every error was filtered as noise can no longer report a stale project.assets.json as a fresh scan. - PackageReference is checked before packages.config, matching NuGet's own precedence, so a leftover packages.config from a migration no longer wins over the project's real dependency graph. A project carrying both gets a warning. --- .gitignore | 9 + CHANGELOG.md | 16 +- README.md | 7 +- docs/agents.md/repo/contract.md | 16 + emitters/dotnet-tool/FactsRunner.cs | 979 ++++++++++++++++++ emitters/dotnet-tool/Program.cs | 20 + emitters/dotnet-tool/RecordsWriter.cs | 75 ++ emitters/dotnet-tool/ToolOptions.cs | 102 ++ .../dotnet-tool/socket-facts-dotnet.csproj | 49 + .../java/dev/socket/facts/SocketSupport.java | 52 +- emitters/socket-facts.init.gradle | 49 +- emitters/socket-facts.plugin.scala | 45 +- package.json | 1 + scripts/repo/build-dotnet-tool.mts | 101 ++ .../check/emitter-assets-are-publishable.mts | 90 +- scripts/repo/paths.mts | 28 + src/assets.mts | 38 +- src/contract/sbom.mts | 2 +- src/contract/sidecar.mts | 29 +- src/contract/validate-sidecar.mts | 10 + src/index.mts | 28 +- src/pipeline/artifact-paths.mts | 121 +++ src/pipeline/assemble.mts | 203 ++-- src/pipeline/records.mts | 7 +- src/pipeline/sidecar.mts | 29 +- src/report/nuget.mts | 88 ++ src/report/render.mts | 34 +- src/report/report-types.mts | 4 + src/run/build-tool.mts | 13 +- src/run/config-glob.mts | 123 +++ src/run/run-facts-generation.mts | 54 +- test/repo/unit/assemble.test.mts | 76 ++ test/repo/unit/config-glob.test.mts | 181 ++++ test/repo/unit/render.test.mts | 39 + test/repo/unit/sidecar.test.mts | 53 + test/repo/unit/validate-sidecar.test.mts | 19 + 36 files changed, 2501 insertions(+), 289 deletions(-) create mode 100644 emitters/dotnet-tool/FactsRunner.cs create mode 100644 emitters/dotnet-tool/Program.cs create mode 100644 emitters/dotnet-tool/RecordsWriter.cs create mode 100644 emitters/dotnet-tool/ToolOptions.cs create mode 100644 emitters/dotnet-tool/socket-facts-dotnet.csproj create mode 100644 scripts/repo/build-dotnet-tool.mts create mode 100644 src/pipeline/artifact-paths.mts create mode 100644 src/report/nuget.mts create mode 100644 src/run/config-glob.mts create mode 100644 test/repo/unit/config-glob.test.mts diff --git a/.gitignore b/.gitignore index aafdebb..976f752 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,12 @@ pnpm-debug.log # output, never tracked. /emitters/maven-extension/target/ /emitters/maven-extension/socket-facts-maven-extension.jar + +# NuGet emitter build output. `pnpm run build:dotnet-tool` publishes the C# +# sources under emitters/dotnet-tool into publish/, which the published package +# ships; the SDK's intermediate bin/ and obj/ dirs and the published output are +# all build output, never tracked. The fleet keeps every ignore in this root +# file, so these live here rather than in a nested .gitignore. +/emitters/dotnet-tool/bin/ +/emitters/dotnet-tool/obj/ +/emitters/dotnet-tool/publish/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f00ee52..ed09a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,15 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `runFactsGeneration` — runs a JVM build tool's Socket facts emitter against an +- `.NET`/NuGet facts generation — `runFactsGeneration` accepts `tool: 'dotnet'` + and runs a bundled C# emitter that evaluates, restores, and reads + `project.assets.json` in one MSBuild session, producing NuGet-typed components + at parity with the JVM emitters. +- Resolved-paths sidecar entries carry an `ecosystem` tag, so a NuGet coordinate + and a Maven coordinate that share a name stay distinct. +- Resolution reports carry `configsByProject`, which attributes each resolved + configuration (for .NET, each target framework) to the project that resolved + it. +- `runFactsGeneration` — runs a build tool's Socket facts emitter against an already-resolved, already-validated invocation and returns the assembled SBOM, resolution report, and resolved artifact paths. - `@socketsecurity/facts/contract` — the `.socket.facts.json` SBOM and resolved-paths sidecar types with runtime validators, so a producer and a consumer can share one definition instead of two hand-maintained copies. - `@socketsecurity/facts/assets` — resolvers for the bundled Gradle init script, - sbt plugin, and Maven extension jar, with a fail-closed check that a published - install carrying no jar throws instead of emitting an empty SBOM. + sbt plugin, Maven extension jar, and dotnet tool, with a fail-closed check that + a published install carrying no built emitter throws instead of emitting an + empty SBOM. - `@socketsecurity/facts/conformance` — parsers for a build tool's own dependency report plus a diff that fails on any component, version, or edge where the emitted facts and the build disagree. diff --git a/README.md b/README.md index e613916..1bed591 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Resolve the emitter assets rather than guessing where they live: ```js import { assertMavenExtensionBuilt, + dotnetToolDllPath, gradleInitScriptPath, sbtPluginSourcePath, } from '@socketsecurity/facts/assets' @@ -119,11 +120,13 @@ pnpm run check pnpm test ``` -Build the Maven core extension, which needs a JDK and is not part of -`pnpm run build`: +Two emitters compile from source and are not part of `pnpm run build`, because +a plain checkout cannot assume either toolchain. The Maven core extension needs +a JDK; the dotnet tool needs a .NET 8+ SDK: ```sh pnpm run build:maven-extension +pnpm run build:dotnet-tool ``` The dynamic-version conformance suite needs Gradle, Maven, and a JDK. It skips diff --git a/docs/agents.md/repo/contract.md b/docs/agents.md/repo/contract.md index 51ab4e9..01dddf3 100644 --- a/docs/agents.md/repo/contract.md +++ b/docs/agents.md/repo/contract.md @@ -65,6 +65,22 @@ consumer pinned to a version released before the addition. key is a violation here, so a producer cannot emit a payload the consumer will reject. +### `ecosystem` is the one field added under that rule + +`ResolvedComponent.ecosystem` carries the artifact's purl type, because a +groupless NuGet id and a Maven artifactId can produce the same coordinate key +and there is no other way to tell them apart. Adding it follows the rule above +rather than escaping it: **every** reachability scan, single-ecosystem JVM ones +included, fails at the sidecar handoff until the consumer's schema accepts the +key, because the producer stamps the tag on every entry and a `.strict()` parse +rejects the whole payload rather than the one field. Releasing the consumer's +schema change first is the gate on shipping a version of this package that +emits it. + +The validator is asymmetric here on purpose: it accepts a payload with no +`ecosystem` key, because that is exactly what a sidecar written before the tag +existed looks like, and it means `maven`. Strict producer, liberal consumer. + ### Proposed versioning approach — not adopted Recorded here so the next person does not have to rederive it. **Do not diff --git a/emitters/dotnet-tool/FactsRunner.cs b/emitters/dotnet-tool/FactsRunner.cs new file mode 100644 index 0000000..dee5759 --- /dev/null +++ b/emitters/dotnet-tool/FactsRunner.cs @@ -0,0 +1,979 @@ +using System.Text.RegularExpressions; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Graph; +using NuGet.Common; +using NuGet.LibraryModel; +using NuGet.Packaging; +using NuGet.ProjectModel; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; + +namespace Socket.Facts.Dotnet; + +// Single-session facts producer: evaluate -> restore -> read, all under ONE +// global-property bag (the user's -p: opts), so restore and the emitted graph +// can never describe different builds. Fail-closed by contract with this +// package: the tool records failures instead of throwing, and the TypeScript +// side renders them and raises the exit code. +internal static class FactsRunner { + private const string TestHostPackage = "microsoft.net.test.sdk"; + + public static int Run(ToolOptions opts, string sdkVersion) { + using var records = new RecordsWriter(opts.RecordsPath); + try { + records.Meta(sdkVersion); + new Session(opts, records).Execute(); + return 0; + } catch (Exception e) { + // Catastrophic only: per-project problems become failure records inside + // Execute. A hard crash leaves the partial records in place, so it MUST + // also leave a failure record: a consumer that only treats a run as + // crashed when the records file is completely empty would otherwise + // publish a truncated SBOM as a success. + Console.Error.WriteLine($"socket-facts-dotnet: {e}"); + records.Failure( + ".", + $"socket-facts-dotnet stopped before it finished; the records below are incomplete: {FirstLine(e.Message)}", + "" + ); + return 1; + } + } + + private sealed class Session(ToolOptions opts, RecordsWriter records) { + private readonly List _includes = ParsePatterns(opts.IncludeConfigs); + private readonly List _excludes = ParsePatterns(opts.ExcludeConfigs); + private readonly HashSet _scanned = new(StringComparer.Ordinal); + + // Restore eligibility per evaluated project, keyed by full path. Decided + // the way NuGet's own restore decides — from the project model, not file + // paths — so legacy no-dependency projects and packages.config projects + // are never sent to (or blamed by) a Restore build. + private readonly Dictionary _restoreSupported = + new(StringComparer.Ordinal); + + public void Execute() { + var entries = Discover(); + if (entries.Count == 0) { + Log("no solution or project files found at the top level"); + return; + } + + var graphs = EvaluateGraphs(entries); + var projectPaths = graphs.ProjectPaths; + if (!opts.NoRestore) { + // A standalone project restores only if it supports restore; a + // solution restores if ANY member does (NuGet skips the rest). + var restorable = graphs.Entries + .Where(e => e.AnyRestoreSupported) + .Select(e => e.Path) + .ToList(); + if (restorable.Count > 0) { + Restore(restorable); + } + } + foreach (var projectPath in projectPaths) { + ReadProject(projectPath); + } + } + + // NuGet's documented packages.config lookup: `packages..config` + // (spaces replaced with underscores) takes precedence over `packages.config` + // in the project directory. + private static string? FindPackagesConfig(string projectPath) { + var dir = Path.GetDirectoryName(projectPath)!; + var projectName = Path.GetFileNameWithoutExtension(projectPath).Replace(' ', '_'); + var perProject = Path.Combine(dir, $"packages.{projectName}.config"); + if (File.Exists(perProject)) return perProject; + var shared = Path.Combine(dir, "packages.config"); + return File.Exists(shared) ? shared : null; + } + + // Mirrors NuGet's own eligibility model, IN NUGET'S OWN ORDER: + // PackageReference items (also valid in legacy-format projects), an + // explicit RestoreProjectStyle, or an SDK-style project all mean restore. + // Only a project with NONE of those falls back to packages.config, whose + // manifest is a self-contained pinned closure the reader path handles + // without a restore. + // + // The order matters. A project that migrated to PackageReference commonly + // leaves a stale packages.config behind, and real `dotnet restore` + // restores the PackageReference graph for it. Checking the file first + // would report the stale pinned list — flat, all-direct, no edges — as the + // project's dependency closure, with nothing failing. + private bool IsRestoreSupported(ProjectInstance instance) { + var fullPath = instance.FullPath; + var packagesConfig = string.IsNullOrEmpty(fullPath) ? null : FindPackagesConfig(fullPath); + if (!HasPackageReferenceStyle(instance)) { + return false; + } + if (packagesConfig != null) { + // Both present: PackageReference wins, but the leftover file is worth + // surfacing either way. A warning, not a failure record — this is a + // common migration remnant, not a broken project. + Warn( + $"{Rel(fullPath)} declares PackageReference and also has {Path.GetFileName(packagesConfig)}; " + + "scanning the PackageReference graph (NuGet's own precedence) and ignoring the packages.config file" + ); + } + return true; + } + + // The three signals NuGet reads to decide a project restores in + // PackageReference style. Read from an evaluated ProjectInstance (graph + // pass) or a Project (read pass) — same three, one definition each. + private static bool HasPackageReferenceStyle(ProjectInstance instance) { + return instance.GetItems("PackageReference").Any() + || string.Equals( + instance.GetPropertyValue("RestoreProjectStyle"), "PackageReference", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + instance.GetPropertyValue("UsingMicrosoftNETSdk"), "true", + StringComparison.OrdinalIgnoreCase); + } + + private static bool HasPackageReferenceStyle(Project project) { + return project.GetItems("PackageReference").Count > 0 + || string.Equals( + project.GetPropertyValue("RestoreProjectStyle"), "PackageReference", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + project.GetPropertyValue("UsingMicrosoftNETSdk"), "true", + StringComparison.OrdinalIgnoreCase); + } + + // Case-insensitive so Linux agrees with a caller's own project detection + // (App.SLN is a valid solution file there too). + private static readonly EnumerationOptions TopLevelIgnoreCase = new() { + MatchCasing = MatchCasing.CaseInsensitive, + RecurseSubdirectories = false, + }; + + // Top-level only, matching every other emitter in this package: the tool + // runs where the build runs; it does not walk the filesystem. + private List Discover() { + var slns = Directory.EnumerateFiles(opts.RootDir, "*.sln", TopLevelIgnoreCase) + .Concat(Directory.EnumerateFiles(opts.RootDir, "*.slnx", TopLevelIgnoreCase)) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + if (slns.Count > 0) return slns; + return Directory.EnumerateFiles(opts.RootDir, "*.*proj", TopLevelIgnoreCase) + .Where(IsProjectFile) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + } + + private sealed record GraphSummary( + List<(string Path, bool AnyRestoreSupported)> Entries, + List ProjectPaths + ); + + // Walks project graphs (solutions expand to their member projects, and + // project references pull in projects outside the top-level dir) to find + // every project this build covers, recording restore eligibility from the + // evaluated instances along the way. + private GraphSummary EvaluateGraphs(List entries) { + var entrySummaries = new List<(string Path, bool AnyRestoreSupported)>(); + var projectPaths = new SortedSet(StringComparer.Ordinal); + foreach (var entry in entries) { + var anySupported = false; + try { + var collection = new ProjectCollection(opts.GlobalProperties); + var graph = new ProjectGraph( + new[] { new ProjectGraphEntryPoint(entry, opts.GlobalProperties) }, + collection, + CreateInstance + ); + foreach (var node in graph.ProjectNodes) { + var fullPath = node.ProjectInstance.FullPath; + if (string.IsNullOrEmpty(fullPath) || !IsProjectFile(fullPath)) { + continue; + } + fullPath = Path.GetFullPath(fullPath); + projectPaths.Add(fullPath); + var supported = IsRestoreSupported(node.ProjectInstance); + _restoreSupported[fullPath] = supported; + anySupported |= supported; + } + } catch (Exception e) { + records.Failure(Rel(entry), $"could not load the project graph: {FirstLine(e.Message)}", ""); + } + entrySummaries.Add((entry, anySupported)); + } + return new GraphSummary(entrySummaries, projectPaths.ToList()); + } + + private ProjectInstance CreateInstance( + string fullPath, Dictionary globalProperties, ProjectCollection collection + ) { + try { + // Pre-restore, the generated nuget.g.props imports may not exist yet. + var project = new Project( + fullPath, globalProperties, toolsVersion: null, collection, + ProjectLoadSettings.IgnoreMissingImports + ); + return project.CreateProjectInstance(); + } catch (Exception e) { + records.Failure(Rel(fullPath), $"could not evaluate the project: {FirstLine(e.Message)}", ""); + return new Project(collection).CreateProjectInstance(); + } + } + + // Restore-only global properties: a metadata scan wants the resolved graph, + // not the project's build-warning policy. `TreatWarningsAsErrors=false` + // stops a project from promoting a NuGet warning to a fatal error and + // aborting the whole run — most notably NU1902/NU1903/NU1904 security + // advisories, which a Socket scan should surface as findings, never choke + // on. Ours wins over any user `-p:` value: warning-as-error is never what + // an SBOM run wants. Restore-scoped (not folded into opts.GlobalProperties) + // so the post-restore re-evaluation still sees the project's real settings. + private Dictionary RestoreProperties() { + return new Dictionary(opts.GlobalProperties, StringComparer.OrdinalIgnoreCase) { + ["TreatWarningsAsErrors"] = "false", + ["MSBuildTreatWarningsAsErrors"] = "false", + }; + } + + private void Restore(List entries) { + var restoreProps = RestoreProperties(); + var errorCapture = new ErrorCaptureLogger(); + var bm = BuildManager.DefaultBuildManager; + bm.BeginBuild(new BuildParameters(new ProjectCollection(restoreProps)) { + Loggers = new Microsoft.Build.Framework.ILogger[] { errorCapture }, + EnableNodeReuse = false, + }); + var submissions = new List<(string Entry, BuildSubmission Submission)>(); + try { + foreach (var entry in entries) { + var request = new BuildRequestData( + entry, restoreProps, targetsToBuild: new[] { "Restore" }, + toolsVersion: null, hostServices: null + ); + var submission = bm.PendBuildRequest(request); + submission.ExecuteAsync(callback: null, context: null); + submissions.Add((entry, submission)); + } + var deadline = DateTime.UtcNow.AddSeconds(opts.RestoreTimeoutSec); + var timedOut = false; + var cancelled = new HashSet(); + foreach (var (entry, submission) in submissions) { + // A submission that already finished is never a timeout, even when + // an earlier one exhausted the budget. + if (submission.IsCompleted) continue; + var remaining = deadline - DateTime.UtcNow; + if (timedOut || remaining <= TimeSpan.Zero || !submission.WaitHandle.WaitOne(remaining)) { + if (!timedOut) { + timedOut = true; + bm.CancelAllSubmissions(); + } + cancelled.Add(submission); + records.Failure( + Rel(entry), + $"restore did not finish within {opts.RestoreTimeoutSec}s and was cancelled", + "" + ); + } + } + if (timedOut) { + // Give cancelled submissions a moment to unwind before EndBuild. + foreach (var (_, submission) in submissions) { + submission.WaitHandle.WaitOne(TimeSpan.FromSeconds(30)); + } + } + _cancelledSubmissions = cancelled; + } finally { + bm.EndBuild(); + } + // Count what gets REPORTED, not what the logger caught. Errors filtered + // as noise below stay in errorCapture.Errors, so gating the fallback on + // that collection lets a restore fail while emitting no failure record + // at all — and a leftover project.assets.json from an earlier run then + // reports a stale dependency closure as a fresh, successful scan. + var reported = 0; + foreach (var error in errorCapture.Errors) { + // Restore errors attributed to projects that don't support restore + // (packages.config, legacy no-dependency) are noise: the reader path + // handles those without restore, while a solution restore still + // visits them and can fail on VS-only imports like + // Microsoft.WebApplication.targets. + if (Path.IsPathRooted(error.Coord) + && _restoreSupported.TryGetValue(Path.GetFullPath(error.Coord), out var supported) + && !supported) { + continue; + } + records.Failure(error.Coord, error.Detail, ""); + reported += 1; + } + foreach (var (entry, submission) in submissions) { + if (submission.IsCompleted + && !_cancelledSubmissions.Contains(submission) + && submission.BuildResult?.OverallResult == BuildResultCode.Failure + && reported == 0) { + records.Failure(Rel(entry), "restore failed (run with --verbose for the build output)", ""); + } + } + } + + private HashSet _cancelledSubmissions = new(); + + private void ReadProject(string projectPath) { + Log($"reading {Rel(projectPath)}"); + Project project; + // Fresh evaluation post-restore so the generated nuget.g.props imports + // are picked up; a fresh collection per project keeps evaluations + // independent of graph-time state. + var collection = new ProjectCollection(opts.GlobalProperties); + try { + project = new Project( + projectPath, opts.GlobalProperties, toolsVersion: null, collection, + ProjectLoadSettings.IgnoreMissingImports + ); + } catch (Exception e) { + records.Failure(Rel(projectPath), $"could not evaluate the project: {FirstLine(e.Message)}", ""); + return; + } + + // Same precedence as IsRestoreSupported: a leftover packages.config only + // decides the read path when the project declares no PackageReference + // style of its own. + var pkgConfigPath = FindPackagesConfig(projectPath); + if (pkgConfigPath != null && !HasPackageReferenceStyle(project)) { + ReadPackagesConfigProject(project, projectPath, pkgConfigPath); + return; + } + + var assetsPath = project.GetPropertyValue("ProjectAssetsFile"); + if (string.IsNullOrEmpty(assetsPath)) { + var objDir = project.GetPropertyValue("MSBuildProjectExtensionsPath"); + assetsPath = string.IsNullOrEmpty(objDir) + ? Path.Combine(Path.GetDirectoryName(projectPath)!, "obj", "project.assets.json") + : Path.Combine(objDir, "project.assets.json"); + } + assetsPath = Path.GetFullPath(assetsPath, Path.GetDirectoryName(projectPath)!); + if (!File.Exists(assetsPath)) { + if (project.GetItems("PackageReference").Count == 0 + && !string.Equals(project.GetPropertyValue("UsingMicrosoftNETSdk"), "true", StringComparison.OrdinalIgnoreCase)) { + // No NuGet dependencies at all (e.g. GAC-only legacy project): still + // a first-party module whose sources matter, just nothing to resolve. + EmitBareProject(project, projectPath); + } else { + records.Failure(Rel(projectPath), "restore produced no project.assets.json for this project", ""); + } + return; + } + + var lockFile = LockFileUtilities.GetLockFile(assetsPath, NullLogger.Instance); + if (lockFile?.PackageSpec == null) { + records.Failure(Rel(projectPath), $"could not parse {Rel(assetsPath)}", ""); + return; + } + + var projectKey = projectPath; + var projectName = lockFile.PackageSpec.Name ?? Path.GetFileNameWithoutExtension(projectPath); + var projectVersion = lockFile.PackageSpec.Version?.ToNormalizedString() ?? ""; + records.Project(projectKey, projectName, projectVersion, Rel(Path.GetDirectoryName(projectPath)!)); + + foreach (var message in lockFile.LogMessages ?? Enumerable.Empty()) { + if (message.Level == LogLevel.Error) { + records.Failure( + string.IsNullOrEmpty(message.LibraryId) ? projectName : message.LibraryId, + $"{message.Code}: {message.Message}", + "" + ); + } + } + + var isTestProject = + string.Equals(project.GetPropertyValue("IsTestProject"), "true", StringComparison.OrdinalIgnoreCase) + || lockFile.PackageSpec.TargetFrameworks.Any(f => + DependenciesOf(f).Any(d => string.Equals(d.Name, TestHostPackage, StringComparison.OrdinalIgnoreCase))); + + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + EmitProjectTargets(projectKey, projectPath, project, collection, lockFile); + } + + foreach (var target in lockFile.Targets) { + EmitTarget(projectKey, projectName, lockFile, target, isTestProject); + } + } + + // The project dir is the base source root; evaluated Compile items catch + // linked sources living OUTSIDE it (``), + // which the dir alone would miss. Exclude-globs within the dir are not + // modeled (the dir over-approximates); refine with per-file paths if the + // sidecar consumer ever wants exact sets. + private void EmitProjectSources(string projectKey, string projectPath, Project project) { + var projectDir = Path.GetDirectoryName(projectPath)!; + records.ProjectSrc(projectKey, projectDir); + var external = new SortedSet(StringComparer.Ordinal); + foreach (var item in project.GetItems("Compile")) { + string full; + try { + full = item.GetMetadataValue("FullPath"); + } catch { + continue; + } + if (string.IsNullOrEmpty(full)) continue; + var dir = Path.GetDirectoryName(Path.GetFullPath(full)); + if (dir != null && !IsUnder(dir, projectDir)) { + external.Add(dir); + } + } + foreach (var dir in external) { + records.ProjectSrc(projectKey, dir); + } + } + + private static bool IsUnder(string path, string root) { + var rel = Path.GetRelativePath(root, path); + return rel == "." || (!rel.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(rel)); + } + + private void EmitBareProject(Project project, string projectPath) { + var projectKey = projectPath; + records.Project( + projectKey, + Path.GetFileNameWithoutExtension(projectPath), + "", + Rel(Path.GetDirectoryName(projectPath)!) + ); + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + var targetPath = NormalizeSlashes(project.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) { + records.ProjectTgt(projectKey, Path.GetFullPath(targetPath, Path.GetDirectoryName(projectPath)!)); + } + } + } + + // Legacy packages.config: the manifest itself is the full pinned closure + // (NuGet resolved it at install time), so no restore is needed for + // completeness — the graph is flat (no edge data in the manifest) and + // every package is emitted as direct. `developmentDependency="true"` + // feeds the dev split; installed DLLs come from evaluated Reference + // HintPaths when the packages folder is present. + private void ReadPackagesConfigProject(Project project, string projectPath, string pkgConfigPath) { + var projectKey = projectPath; + records.Project( + projectKey, + Path.GetFileNameWithoutExtension(projectPath), + "", + Rel(Path.GetDirectoryName(projectPath)!) + ); + if (opts.WithFiles) { + EmitProjectSources(projectKey, projectPath, project); + var targetPath = NormalizeSlashes(project.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) { + records.ProjectTgt(projectKey, Path.GetFullPath(targetPath, Path.GetDirectoryName(projectPath)!)); + } + } + + List packages; + try { + using var stream = File.OpenRead(pkgConfigPath); + packages = new NuGet.Packaging.PackagesConfigReader(stream) + .GetPackages(allowDuplicatePackageIds: true) + .ToList(); + } catch (Exception e) { + records.Failure(Rel(pkgConfigPath), $"could not parse packages.config: {FirstLine(e.Message)}", ""); + return; + } + if (packages.Count == 0) return; + + var config = LegacyFrameworkConfigName(project); + if (!ConfigMatches(new[] { config })) return; + if (_scanned.Add(config)) records.Scanned(config); + + // DLL paths per package folder segment (`.`), from the + // evaluated References' HintPaths. + var dllsBySegment = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (opts.WithFiles) { + foreach (var item in project.GetItems("Reference")) { + // Normalize explicitly: MSBuild's unix slash-adjustment for metadata + // is existence-gated, so HintPaths keep raw backslashes precisely + // when the packages folder is missing — the download case. + var hint = NormalizeSlashes(item.GetMetadataValue("HintPath")); + if (string.IsNullOrEmpty(hint)) continue; + var full = Path.GetFullPath(hint, Path.GetDirectoryName(projectPath)!); + foreach (var segment in full.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { + if (!dllsBySegment.TryGetValue(segment, out var list)) dllsBySegment[segment] = list = new List(); + list.Add(full); + } + } + // Packages whose HintPath'd assemblies aren't on disk get downloaded + // into the same packages folder the HintPaths reference — exactly what + // `nuget restore` would populate. Versions are pinned by the manifest, + // so there is no resolution to redo, and a failed download becomes a + // blocking failure record (matching the Maven/Gradle scripts). + if (!opts.NoRestore) { + var missing = new List(); + string? packagesRoot = null; + foreach (var pkg in packages) { + var segment = FolderSegment(pkg); + if (segment == null || !dllsBySegment.TryGetValue(segment, out var dlls)) continue; + var absent = dlls.FirstOrDefault(d => !File.Exists(d)); + if (absent == null) continue; + missing.Add(pkg); + packagesRoot ??= DerivePackagesRoot(absent, segment); + } + if (missing.Count > 0) { + if (packagesRoot == null) { + records.Failure( + Rel(projectPath), + "could not locate the packages folder from the project's HintPaths; run a NuGet restore", + config + ); + } else { + DownloadPackagesConfigArtifacts( + Path.GetDirectoryName(projectPath)!, packagesRoot, missing, config + ); + } + } + } + } + + var emitted = new[] { + (Kind: "prod", Packages: packages.Where(p => !p.IsDevelopmentDependency).ToList()), + (Kind: "dev", Packages: packages.Where(p => p.IsDevelopmentDependency).ToList()), + }; + foreach (var (kind, kindPackages) in emitted) { + if (kindPackages.Count == 0) continue; + var rootId = $"{projectKey}|{config}|{kind}"; + records.Root(rootId, projectKey, config, kind == "prod"); + foreach (var pkg in kindPackages.OrderBy(p => p.PackageIdentity.Id, StringComparer.OrdinalIgnoreCase)) { + var id = pkg.PackageIdentity.Id; + var version = pkg.PackageIdentity.Version?.ToNormalizedString() ?? ""; + var coord = CoordIdOf(id, version); + // The flat closure carries no edges, so direct-vs-transitive is + // unknowable from the manifest alone; every package is direct, the + // same over-approximation cdxgen makes. + records.Node(rootId, coord, id, version, direct: true); + var segment = FolderSegment(pkg); + if (opts.WithFiles && segment != null && dllsBySegment.TryGetValue(segment, out var dlls)) { + foreach (var dll in dlls) { + if (File.Exists(dll)) { + records.File(rootId, coord, dll); + } else { + // Never silently claim an artifact: a referenced assembly that + // is still absent (download failed or was skipped) blocks. + records.Failure( + coord, + $"referenced assembly is not installed ({Path.GetFileName(dll)}); run a NuGet restore or re-run without --no-restore", + config + ); + } + } + } + } + } + } + + private static string NormalizeSlashes(string path) { + return Path.DirectorySeparatorChar == '/' ? path.Replace('\\', '/') : path; + } + + // The `.` folder segment a packages.config package occupies + // in the packages folder (and in HintPaths). + private static string? FolderSegment(NuGet.Packaging.PackageReference pkg) { + var version = pkg.PackageIdentity.Version?.ToNormalizedString(); + return string.IsNullOrEmpty(version) ? null : $"{pkg.PackageIdentity.Id}.{version}"; + } + + // The packages root is whatever the HintPaths point at: everything before + // the `.` segment. + private static string? DerivePackagesRoot(string dllPath, string segment) { + var parts = dllPath.Split(Path.DirectorySeparatorChar); + var idx = Array.FindIndex(parts, p => string.Equals(p, segment, StringComparison.OrdinalIgnoreCase)); + if (idx <= 0) return null; + return string.Join(Path.DirectorySeparatorChar, parts.Take(idx)); + } + + // Pinned-version downloads through the user's configured NuGet sources + // (nuget.config hierarchy, credential providers included) — the same feeds + // the user's own restore would use. Extraction uses the side-by-side + // `.` layout `nuget restore` produces for packages.config. + private void DownloadPackagesConfigArtifacts( + string projectDir, string packagesRoot, + List missing, string config + ) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(opts.RestoreTimeoutSec)); + var logger = NullLogger.Instance; + NuGet.Configuration.ISettings settings; + List repos; + try { + settings = NuGet.Configuration.Settings.LoadDefaultSettings(projectDir); + repos = new NuGet.Configuration.PackageSourceProvider(settings) + .LoadPackageSources() + .Where(s => s.IsEnabled) + .Select(s => Repository.Factory.GetCoreV3(s)) + .ToList(); + NuGet.Credentials.DefaultCredentialServiceUtility.SetupDefaultCredentialService( + logger, nonInteractive: true + ); + } catch (Exception e) { + foreach (var pkg in missing) { + records.Failure( + CoordIdOf(pkg.PackageIdentity.Id, pkg.PackageIdentity.Version?.ToNormalizedString() ?? ""), + $"could not load NuGet sources for download: {FirstLine(e.Message)}", + config + ); + } + return; + } + var extraction = new PackageExtractionContext( + PackageSaveMode.Defaultv2, + XmlDocFileSaveMode.None, + NuGet.Packaging.Signing.ClientPolicyContext.GetClientPolicy(settings, logger), + logger + ); + var resolver = new PackagePathResolver(packagesRoot); + using var cache = new SourceCacheContext(); + foreach (var pkg in missing) { + var identity = pkg.PackageIdentity; + var coord = CoordIdOf(identity.Id, identity.Version?.ToNormalizedString() ?? ""); + string? lastError = repos.Count == 0 ? "no enabled NuGet sources" : null; + var ok = false; + foreach (var repo in repos) { + try { + var resource = repo + .GetResourceAsync(cts.Token) + .GetAwaiter().GetResult(); + using var stream = new MemoryStream(); + var copied = resource + .CopyNupkgToStreamAsync(identity.Id, identity.Version, stream, cache, logger, cts.Token) + .GetAwaiter().GetResult(); + if (!copied) continue; + stream.Position = 0; + PackageExtractor + .ExtractPackageAsync(repo.PackageSource.Source, stream, resolver, extraction, cts.Token) + .GetAwaiter().GetResult(); + Log($"downloaded {identity} from {repo.PackageSource.Name}"); + ok = true; + break; + } catch (Exception e) { + lastError = FirstLine(e.Message); + } + } + if (!ok) { + records.Failure( + coord, + $"could not download from any configured NuGet source{(lastError == null ? "" : $": {lastError}")}", + config + ); + } + } + } + + // Short folder name of the legacy project's single framework, e.g. net472. + private static string LegacyFrameworkConfigName(Project project) { + var moniker = project.GetPropertyValue("TargetFrameworkMoniker"); + if (!string.IsNullOrEmpty(moniker)) { + try { + return NuGet.Frameworks.NuGetFramework.Parse(moniker).GetShortFolderName(); + } catch { + // Fall through to the raw version below. + } + } + var version = project.GetPropertyValue("TargetFrameworkVersion"); + return string.IsNullOrEmpty(version) ? "unknown" : $"net{version.TrimStart('v').Replace(".", "")}"; + } + + // The compiled output path per target framework; multi-targeting needs an + // inner-build evaluation per alias because the outer build has no + // TargetPath. Missing files are fine: assembleFacts drops non-existent paths. + private void EmitProjectTargets( + string projectKey, string projectPath, Project outerProject, + ProjectCollection collection, LockFile lockFile + ) { + var frameworks = lockFile.PackageSpec.TargetFrameworks; + if (frameworks.Count <= 1) { + var targetPath = NormalizeSlashes(outerProject.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) records.ProjectTgt(projectKey, Path.GetFullPath(targetPath)); + return; + } + foreach (var framework in frameworks) { + var alias = framework.TargetAlias; + if (string.IsNullOrEmpty(alias)) continue; + try { + var props = new Dictionary(opts.GlobalProperties, StringComparer.OrdinalIgnoreCase) { + ["TargetFramework"] = alias, + }; + var inner = new Project(projectPath, props, toolsVersion: null, collection, ProjectLoadSettings.IgnoreMissingImports); + var targetPath = NormalizeSlashes(inner.GetPropertyValue("TargetPath")); + if (!string.IsNullOrEmpty(targetPath)) records.ProjectTgt(projectKey, Path.GetFullPath(targetPath)); + } catch { + // TargetPath is best-effort metadata; resolution stays authoritative. + } + } + } + + private void EmitTarget( + string projectKey, string projectName, LockFile lockFile, LockFileTarget target, bool isTestProject + ) { + var frameworkInfo = lockFile.PackageSpec.TargetFrameworks + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + var shortName = frameworkInfo?.TargetAlias; + if (string.IsNullOrEmpty(shortName)) { + try { + shortName = target.TargetFramework.GetShortFolderName(); + } catch { + records.Unscannable(target.Name, $"unrecognized target framework in {Rel(projectKey)}"); + return; + } + } + var config = string.IsNullOrEmpty(target.RuntimeIdentifier) + ? shortName + : $"{shortName}/{target.RuntimeIdentifier}"; + // RID-specific targets match on the composite name AND on the base + // framework, so the documented `--include-configs net8.0` usage also + // covers `net8.0/win-x64` (and an exclude of either form drops it). + var configNames = string.IsNullOrEmpty(target.RuntimeIdentifier) + ? new[] { config } + : new[] { config, shortName }; + if (!ConfigMatches(configNames)) return; + if (_scanned.Add(config)) records.Scanned(config); + + // NuGet package ids are case-insensitive; dependency edges resolve + // through a lowercased name index. + var byLowerName = new Dictionary(StringComparer.Ordinal); + var nodes = new Dictionary(StringComparer.Ordinal); + var children = new Dictionary>(StringComparer.Ordinal); + foreach (var lib in target.Libraries) { + if (string.IsNullOrEmpty(lib.Name)) continue; + var coordId = CoordId(lib); + byLowerName[lib.Name.ToLowerInvariant()] = coordId; + nodes[coordId] = lib; + } + foreach (var lib in target.Libraries) { + if (string.IsNullOrEmpty(lib.Name)) continue; + var parent = CoordId(lib); + foreach (var dep in lib.Dependencies) { + if (byLowerName.TryGetValue(dep.Id.ToLowerInvariant(), out var child) && child != parent) { + if (!children.TryGetValue(parent, out var list)) children[parent] = list = new List(); + list.Add(child); + } + } + } + + var directProd = new HashSet(StringComparer.Ordinal); + var directDev = new HashSet(StringComparer.Ordinal); + foreach (var dep in frameworkInfo != null ? DependenciesOf(frameworkInfo) : Enumerable.Empty()) { + if (string.IsNullOrEmpty(dep.Name)) continue; + if (!byLowerName.TryGetValue(dep.Name.ToLowerInvariant(), out var coordId)) continue; + // PrivateAssets=all (analyzers, build tooling) doesn't flow to + // consumers: dev, mirroring the JVM scripts' non-prod configs. + if (dep.SuppressParent == LibraryIncludeFlags.All) directDev.Add(coordId); + else directProd.Add(coordId); + } + // Match project references by their MSBuild project path (the lock-file + // library name is the PackageSpec/PackageId name, which can differ from + // the csproj file name); the file-name lookup stays as a fallback. + var projectLibByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + var projectDir = Path.GetDirectoryName(projectKey)!; + foreach (var library in lockFile.Libraries) { + if (!string.Equals(library.Type, "project", StringComparison.OrdinalIgnoreCase)) continue; + if (string.IsNullOrEmpty(library.MSBuildProject) || string.IsNullOrEmpty(library.Name)) continue; + projectLibByPath[Path.GetFullPath(library.MSBuildProject, projectDir)] = + library.Name.ToLowerInvariant(); + } + var restoreFrameworkInfo = lockFile.PackageSpec.RestoreMetadata?.TargetFrameworks + .FirstOrDefault(f => f.FrameworkName.Equals(target.TargetFramework)); + foreach (var projectRef in restoreFrameworkInfo?.ProjectReferences ?? Enumerable.Empty()) { + var refPath = projectRef.ProjectPath; + if (string.IsNullOrEmpty(refPath)) continue; + if (!projectLibByPath.TryGetValue(Path.GetFullPath(refPath, projectDir), out var lowerName)) { + lowerName = Path.GetFileNameWithoutExtension(refPath).ToLowerInvariant(); + } + if (byLowerName.TryGetValue(lowerName, out var coordId)) { + directProd.Add(coordId); + } + } + + var prodReach = Reach(directProd, children); + var devReach = Reach(directDev, children); + foreach (var key in nodes.Keys) { + if (!prodReach.Contains(key) && !devReach.Contains(key)) prodReach.Add(key); + } + + EmitRoot(projectKey, config, "prod", !isTestProject, prodReach, directProd, nodes, children, lockFile); + EmitRoot(projectKey, config, "dev", prod: false, devReach, directDev, nodes, children, lockFile); + } + + private void EmitRoot( + string projectKey, string config, string kind, bool prod, + HashSet keys, HashSet direct, + Dictionary nodes, + Dictionary> children, LockFile lockFile + ) { + if (keys.Count == 0) return; + var rootId = $"{projectKey}|{config}|{kind}"; + records.Root(rootId, projectKey, config, prod); + foreach (var key in keys.OrderBy(k => k, StringComparer.Ordinal)) { + var lib = nodes[key]; + records.Node(rootId, key, lib.Name!, lib.Version?.ToNormalizedString() ?? "", direct.Contains(key)); + if (opts.WithFiles && string.Equals(lib.Type, "package", StringComparison.OrdinalIgnoreCase)) { + var (found, missing) = ResolveRuntimeAssemblies(lockFile, lib); + foreach (var file in found) { + records.File(rootId, key, file); + } + if (missing.Count > 0) { + // Never silently claim an artifact: the lock file lists runtime + // assemblies this cache doesn't hold (pruned cache, stale assets + // with --no-restore). A package with NO runtime assemblies at all + // (analyzer/content-only) is fine and takes neither branch. + records.Failure( + key, + $"runtime assemblies listed in project.assets.json are missing from the package cache ({missing[0]}{(missing.Count > 1 ? $" +{missing.Count - 1} more" : "")}); re-run restore", + config + ); + } + } + if (children.TryGetValue(key, out var kids)) { + foreach (var child in kids) { + if (keys.Contains(child)) records.Edge(rootId, key, child); + } + } + } + } + + // Runtime (lib/) assemblies, not compile (ref/) assemblies: reference + // assemblies have no method bodies, which breaks reachability analysis. + // First package folder holding the file wins (NuGet's own probe order); + // an item found in NO folder is reported so it can fail the run. + private static (List Found, List Missing) ResolveRuntimeAssemblies( + LockFile lockFile, LockFileTargetLibrary lib + ) { + var found = new List(); + var missing = new List(); + var library = lockFile.GetLibrary(lib.Name, lib.Version); + if (library?.Path == null) return (found, missing); + var items = (lib.RuntimeAssemblies ?? new List()) + .Select(item => item.Path) + .Where(p => !string.IsNullOrEmpty(p) + && p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && Path.GetFileName(p) != "_._") + .ToList(); + foreach (var item in items) { + string? hit = null; + foreach (var folder in lockFile.PackageFolders) { + if (string.IsNullOrEmpty(folder.Path)) continue; + var full = Path.GetFullPath(Path.Combine(folder.Path, library.Path, item)); + if (File.Exists(full)) { + hit = full; + break; + } + } + if (hit != null) { + found.Add(hit); + } else { + missing.Add(item!); + } + } + return (found, missing); + } + + private static HashSet Reach(HashSet seeds, Dictionary> children) { + var seen = new HashSet(seeds, StringComparer.Ordinal); + var stack = new Stack(seen); + while (stack.Count > 0) { + var key = stack.Pop(); + if (!children.TryGetValue(key, out var kids)) continue; + foreach (var child in kids) { + if (seen.Add(child)) stack.Push(child); + } + } + return seen; + } + + private bool ConfigMatches(IReadOnlyList names) { + if (_excludes.Any(p => names.Any(n => p.IsMatch(n)))) return false; + return _includes.Count == 0 || _includes.Any(p => names.Any(n => p.IsMatch(n))); + } + + private string Rel(string path) { + var rel = Path.GetRelativePath(opts.RootDir, path).Replace('\\', '/'); + return string.IsNullOrEmpty(rel) || rel == "." ? "." : rel; + } + + private void Log(string message) { + if (opts.Verbose) Console.Error.WriteLine($"socket-facts-dotnet: {message}"); + } + + // Always printed, unlike Log: a warning describes project state the + // operator should know about even when they did not ask for progress. + private void Warn(string message) { + Console.Error.WriteLine($"socket-facts-dotnet: warning: {message}"); + } + } + + // TargetFrameworkInformation.Dependencies changed shape across NuGet + // versions (IList -> ImmutableArray), + // so a compiled getter call binds on some SDKs and MissingMethodExceptions + // on others. Read it reflectively: both shapes implement + // IEnumerable, and type identity holds because the + // runtime NuGet assemblies are always the locator-selected SDK's own. + private static readonly System.Reflection.PropertyInfo? TfiDependenciesProperty = + typeof(TargetFrameworkInformation).GetProperty("Dependencies"); + + private static IEnumerable DependenciesOf(TargetFrameworkInformation framework) { + return TfiDependenciesProperty?.GetValue(framework) as IEnumerable + ?? Enumerable.Empty(); + } + + private static string CoordId(LockFileTargetLibrary lib) { + return CoordIdOf(lib.Name!, lib.Version?.ToNormalizedString() ?? ""); + } + + private static string CoordIdOf(string name, string version) { + return string.IsNullOrEmpty(version) ? name : $"{name}:{version}"; + } + + private static bool IsProjectFile(string path) => + path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase); + + private static string FirstLine(string s) { + var idx = s.IndexOfAny(new[] { '\n', '\r' }); + return idx < 0 ? s : s.Substring(0, idx); + } + + // Pre-compiled anchored pattern sources from src/run/config-glob.mts, this + // package's single glob implementation; an uncompilable pattern is dropped, + // never thrown — it only guards against a broken transport. + private static List ParsePatterns(string csv) { + var patterns = new List(); + foreach (var raw in (csv ?? "").Split(',')) { + var p = raw.Trim(); + if (p.Length == 0) continue; + try { + patterns.Add(new Regex(p)); + } catch (ArgumentException) { + // Dropped; see contract above. + } + } + return patterns; + } + + // Collects restore errors; NuGet logs NU-coded restore failures as build + // errors, which become failure records after the build session ends. + private sealed class ErrorCaptureLogger : Microsoft.Build.Framework.ILogger { + public readonly List<(string Coord, string Detail)> Errors = new(); + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Quiet; + public string? Parameters { get; set; } + + public void Initialize(IEventSource eventSource) { + eventSource.ErrorRaised += (_, e) => { + lock (Errors) { + var coord = string.IsNullOrEmpty(e.ProjectFile) ? (e.File ?? "restore") : e.ProjectFile; + Errors.Add((coord, $"{e.Code}: {e.Message}")); + } + }; + } + + public void Shutdown() { } + } +} diff --git a/emitters/dotnet-tool/Program.cs b/emitters/dotnet-tool/Program.cs new file mode 100644 index 0000000..85f8147 --- /dev/null +++ b/emitters/dotnet-tool/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Build.Locator; + +namespace Socket.Facts.Dotnet; + +internal static class Program { + private static int Main(string[] args) { + ToolOptions opts; + try { + opts = ToolOptions.Parse(args); + } catch (ArgumentException e) { + Console.Error.WriteLine(e.Message); + Console.Error.WriteLine(ToolOptions.Usage); + return 2; + } + var instance = MSBuildLocator.RegisterDefaults(); + // FactsRunner lives in a separate class: MSBuild types must not be JITed + // before the locator has registered the SDK assemblies. + return FactsRunner.Run(opts, instance.Version.ToString()); + } +} diff --git a/emitters/dotnet-tool/RecordsWriter.cs b/emitters/dotnet-tool/RecordsWriter.cs new file mode 100644 index 0000000..9ea6b68 --- /dev/null +++ b/emitters/dotnet-tool/RecordsWriter.cs @@ -0,0 +1,75 @@ +using System.Text; + +namespace Socket.Facts.Dotnet; + +// Emits the flat TSV records line protocol shared with the JVM build-tool +// scripts (grammar documented in src/pipeline/records.mts). Buffered and +// flushed on Dispose so a crash mid-run still leaves whatever was recorded. +internal sealed class RecordsWriter : IDisposable { + private readonly StreamWriter _writer; + + // How many `failure` records this writer has emitted. The restore path gates + // its fallback record on what was REPORTED, not on what a logger captured: + // a restore whose every error was filtered as noise still has to leave a + // failure behind, or a stale project.assets.json reads as a fresh success. + public int FailureCount { get; private set; } + + public RecordsWriter(string path) { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // No BOM: the TS records parser reads lines verbatim and a BOM would + // corrupt the first record's tag. + _writer = new StreamWriter(path, append: false, new UTF8Encoding(false)); + // LF on every platform. The parser splits on '\n' only, so a Windows + // default of "\r\n" would leave a stray '\r' glued to each record's LAST + // field — silently flipping prod/direct flags, orphaning every edge, and + // failing every artifact path's exists-check. The Gradle and sbt emitters + // already join with an explicit '\n'; this keeps all three identical. + _writer.NewLine = "\n"; + } + + public void Rec(params string[] fields) { + var sb = new StringBuilder(); + for (var i = 0; i < fields.Length; i += 1) { + if (i > 0) sb.Append('\t'); + sb.Append(Escape(fields[i])); + } + _writer.WriteLine(sb.ToString()); + } + + public void Meta(string toolVersion) => Rec("meta", "dotnet", toolVersion, ""); + + public void Project(string projectKey, string name, string version, string dir) => + Rec("project", projectKey, "", name, version, dir); + + public void ProjectSrc(string projectKey, string path) => Rec("projectSrc", projectKey, path); + + public void ProjectTgt(string projectKey, string path) => Rec("projectTgt", projectKey, path); + + public void Root(string rootId, string projectKey, string config, bool prod) => + Rec("root", rootId, projectKey, config, prod ? "1" : "0"); + + public void Node(string rootId, string coordId, string name, string version, bool direct) => + Rec("node", rootId, coordId, "", name, version, "", "", direct ? "1" : "0"); + + public void Edge(string rootId, string parentCoordId, string childCoordId) => + Rec("edge", rootId, parentCoordId, childCoordId); + + public void File(string rootId, string coordId, string path) => Rec("file", rootId, coordId, path); + + public void Scanned(string config) => Rec("scanned", config); + + public void Failure(string coord, string detail, string config) { + FailureCount += 1; + Rec("failure", coord, detail, config); + } + + public void Unscannable(string config, string detail) => Rec("unscannable", config, detail); + + public void Dispose() => _writer.Dispose(); + + private static string Escape(string? v) { + if (string.IsNullOrEmpty(v)) return ""; + return v.Replace("\\", "\\\\").Replace("\t", "\\t").Replace("\n", "\\n").Replace("\r", "\\r"); + } +} diff --git a/emitters/dotnet-tool/ToolOptions.cs b/emitters/dotnet-tool/ToolOptions.cs new file mode 100644 index 0000000..a2b6201 --- /dev/null +++ b/emitters/dotnet-tool/ToolOptions.cs @@ -0,0 +1,102 @@ +namespace Socket.Facts.Dotnet; + +internal sealed class ToolOptions { + public const string Usage = """ + Usage: socket-facts-dotnet --records --root [options] [-p:Key=Value ...] + + Options: + --records Records output file (TSV line protocol). Required. + --root Project root to scan (top-level *.sln/*.slnx, else *proj). Required. + --with-files Also emit resolved artifact/source paths. + --include-configs Comma-separated anchored regex patterns for target framework names. + --exclude-configs Comma-separated anchored regex patterns; applied after includes. + --no-restore Skip the in-process restore (use existing restore output). + --restore-timeout-sec Cancel restore after n seconds (default 900). + --verbose Log progress to stderr. + -p:Key=Value MSBuild global property, applied to the WHOLE session + (evaluation, restore, and reading). Also accepts --property:. + """; + + public string RecordsPath = ""; + public string RootDir = ""; + public bool WithFiles; + public string IncludeConfigs = ""; + public string ExcludeConfigs = ""; + public bool NoRestore; + public int RestoreTimeoutSec = 900; + public bool Verbose; + public Dictionary GlobalProperties = new(StringComparer.OrdinalIgnoreCase); + + public static ToolOptions Parse(string[] args) { + var opts = new ToolOptions(); + for (var i = 0; i < args.Length; i += 1) { + var arg = args[i]; + switch (arg) { + case "--records": + opts.RecordsPath = Next(args, ref i, arg); + break; + case "--root": + opts.RootDir = Next(args, ref i, arg); + break; + case "--with-files": + opts.WithFiles = true; + break; + case "--include-configs": + opts.IncludeConfigs = Next(args, ref i, arg); + break; + case "--exclude-configs": + opts.ExcludeConfigs = Next(args, ref i, arg); + break; + case "--no-restore": + opts.NoRestore = true; + break; + case "--restore-timeout-sec": + if (!int.TryParse(Next(args, ref i, arg), out opts.RestoreTimeoutSec) || opts.RestoreTimeoutSec <= 0) { + throw new ArgumentException("--restore-timeout-sec expects a positive integer"); + } + break; + case "--verbose": + opts.Verbose = true; + break; + default: + if (TryParseProperty(arg, out var key, out var value)) { + opts.GlobalProperties[key] = value; + } else { + throw new ArgumentException( + $"Unknown argument `{arg}`. --dotnet-opts accepts MSBuild property tokens only (-p:Key=Value or --property:Key=Value)." + ); + } + break; + } + } + if (string.IsNullOrEmpty(opts.RecordsPath) || string.IsNullOrEmpty(opts.RootDir)) { + throw new ArgumentException("--records and --root are required"); + } + opts.RootDir = Path.GetFullPath(opts.RootDir); + return opts; + } + + private static string Next(string[] args, ref int i, string arg) { + i += 1; + if (i >= args.Length) throw new ArgumentException($"{arg} expects a value"); + return args[i]; + } + + private static bool TryParseProperty(string arg, out string key, out string value) { + key = ""; + value = ""; + string? rest = null; + foreach (var prefix in new[] { "-p:", "/p:", "--property:", "-property:" }) { + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { + rest = arg.Substring(prefix.Length); + break; + } + } + if (rest == null) return false; + var eq = rest.IndexOf('='); + if (eq <= 0) return false; + key = rest.Substring(0, eq); + value = rest.Substring(eq + 1); + return true; + } +} diff --git a/emitters/dotnet-tool/socket-facts-dotnet.csproj b/emitters/dotnet-tool/socket-facts-dotnet.csproj new file mode 100644 index 0000000..c7e1d1f --- /dev/null +++ b/emitters/dotnet-tool/socket-facts-dotnet.csproj @@ -0,0 +1,49 @@ + + + + Exe + + net6.0 + LatestMajor + 12 + enable + enable + socket-facts-dotnet + Socket.Facts.Dotnet + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java index b38378a..cf91915 100644 --- a/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java +++ b/emitters/maven-extension/src/main/java/dev/socket/facts/SocketSupport.java @@ -46,43 +46,6 @@ public static String bareId(String groupId, String artifactId, String version) { return coordId(groupId, artifactId, null, null, version); } - /** - * Translate a config-name glob to a case-sensitive regex. Supports {@code *}, {@code ?}, and - * {@code [...]} character classes: enumerations ({@code [cC]}), ranges ({@code [a-z]}), and - * {@code [!..]}/{@code [^..]} negation. A malformed glob falls back to a literal match, never throws. - */ - public static Pattern globToRegex(String glob) { - StringBuilder sb = new StringBuilder(); - int i = 0; - int n = glob.length(); - while (i < n) { - char c = glob.charAt(i); - if (c == '*') { sb.append(".*"); i++; } - else if (c == '?') { sb.append('.'); i++; } - else if (c == '[') { - int j = glob.indexOf(']', i + 1); - // Treat as a class only with a non-empty body; else a literal '['. - if (j <= i + 1) { sb.append("\\["); i++; } - else { - String body = glob.substring(i + 1, j); - boolean neg = body.startsWith("!"); - if (neg) body = body.substring(1); - // Only literal chars and '-' ranges are meaningful; neutralize regex-class tricks. - body = body.replace("\\", "\\\\").replace("[", "\\[").replace("&", "\\&"); - sb.append('[').append(neg ? "^" : "").append(body).append(']'); - i = j + 1; - } - } else if ("\\.^$|+(){}]".indexOf(c) >= 0) { - sb.append('\\').append(c); i++; - } else { sb.append(c); i++; } - } - try { - return Pattern.compile(sb.toString()); - } catch (java.util.regex.PatternSyntaxException e) { - return Pattern.compile(Pattern.quote(glob)); - } - } - /** * Compile a comma-separated list of {@code --exclude-paths} into glob {@link PathMatcher}s, used * only to skip whole excluded reactor modules. Each entry variant yields the entry itself and @@ -151,13 +114,24 @@ public static boolean isExcludedPath(String rel, List matchers) { return false; } - /** Parse a comma-separated list of globs into case-sensitive patterns. */ + /** + * Parse a comma-separated list of PRE-COMPILED anchored regex pattern sources. + * {@code src/run/config-glob.mts} is the single glob implementation and compiles the + * caller-facing globs; this extension only {@link Pattern#compile(String)}s what it receives. A + * pattern that doesn't compile is dropped, never thrown: the caller emits a dialect-portable + * subset, so this only guards against a broken transport. + */ public static List parsePatterns(String csv) { List out = new ArrayList<>(); if (csv == null || csv.trim().isEmpty()) return out; for (String raw : csv.split(",")) { String p = raw.trim(); - if (!p.isEmpty()) out.add(globToRegex(p)); + if (p.isEmpty()) continue; + try { + out.add(Pattern.compile(p)); + } catch (java.util.regex.PatternSyntaxException ignored) { + // Dropped; see the contract above. + } } return out; } diff --git a/emitters/socket-facts.init.gradle b/emitters/socket-facts.init.gradle index 4362d99..6525741 100644 --- a/emitters/socket-facts.init.gradle +++ b/emitters/socket-facts.init.gradle @@ -365,49 +365,22 @@ allprojects { project -> n.contains('classpath') || n == 'compile' || n == 'runtime' } - // Config-name glob to a case-SENSITIVE regex (matching is documented as case-sensitive). - // Supports `*`, `?`, and `[...]` character classes: enumerations (`[cC]`), ranges (`[a-z]`), - // and `[!..]`/`[^..]` negation. A malformed glob falls back to a literal match, never throws. - def globToRegex = { String glob -> - def sb = new StringBuilder() - int i = 0 - int n = glob.length() - while (i < n) { - def ch = glob[i] - if (ch == '*') { sb << '.*'; i++ } - else if (ch == '?') { sb << '.'; i++ } - else if (ch == '[') { - int j = glob.indexOf(']', i + 1) - // Treat as a class only with a non-empty body; else a literal `[`. - if (j <= i + 1) { sb << '\\['; i++ } - else { - def body = glob.substring(i + 1, j) - boolean neg = body.startsWith('!') - if (neg) { body = body.substring(1) } - // Only literal chars and `-` ranges are meaningful; neutralize regex-class tricks. - body = body.replace('\\', '\\\\').replace('[', '\\[').replace('&', '\\&') - sb << '[' << (neg ? '^' : '') << body << ']' - i = j + 1 - } - } - else if ('.\\^$|+(){}]'.contains(ch)) { sb << '\\' << ch; i++ } - else { sb << ch; i++ } - } - try { - java.util.regex.Pattern.compile(sb.toString()) - } catch (java.util.regex.PatternSyntaxException e) { - java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(glob)) - } - } - - // `-Psocket.includeConfigs`/`-Psocket.excludeConfigs`: comma-separated config-name globs. A - // config is walked when it matches some include (or there are none) AND matches no exclude. + // `-Psocket.includeConfigs`/`-Psocket.excludeConfigs`: comma-separated, PRE-COMPILED + // anchored regex pattern sources. src/run/config-glob.mts is the single glob + // implementation and compiles the caller-facing globs; this script only compiles what it + // receives. A config is walked when it matches some include (or there are none) AND matches + // no exclude. def parsePatterns = { String s -> def out = [] if (s != null && !s.trim().isEmpty()) { s.split(',').each { raw -> def p = raw.trim() - if (!p.isEmpty()) out << globToRegex(p) + if (!p.isEmpty()) { + // A pattern that doesn't compile is dropped, never thrown: the caller emits a + // dialect-portable subset, so this only guards against a broken transport. + try { out << java.util.regex.Pattern.compile(p) } + catch (java.util.regex.PatternSyntaxException ignored) { } + } } } out diff --git a/emitters/socket-facts.plugin.scala b/emitters/socket-facts.plugin.scala index 49b8f11..a9d7881 100644 --- a/emitters/socket-facts.plugin.scala +++ b/emitters/socket-facts.plugin.scala @@ -335,11 +335,19 @@ object SocketFactsPlugin extends AutoPlugin { // ---- config selection --------------------------------------------------- // With no includes the default is ALL configurations (captures build/tooling deps). + // `-Dsocket.includeConfigs`/`-Dsocket.excludeConfigs`: comma-separated, PRE-COMPILED anchored + // regex pattern sources. src/run/config-glob.mts is the single glob implementation and compiles + // the caller-facing globs; this plugin only compiles what it receives. A pattern that doesn't + // compile is dropped, never thrown: the caller emits a dialect-portable subset, so this only + // guards against a broken transport. private def buildConfigMatcher(): String => Boolean = { def parse(prop: String): List[java.util.regex.Pattern] = sys.props.get(prop) match { case Some(s) if s.trim.nonEmpty => - s.split(",").map(_.trim).filter(_.nonEmpty).toList.map(globToRegex) + s.split(",").map(_.trim).filter(_.nonEmpty).toList.flatMap { p => + try List(java.util.regex.Pattern.compile(p)) + catch { case _: java.util.regex.PatternSyntaxException => Nil } + } case _ => Nil } val includes = parse("socket.includeConfigs") @@ -352,41 +360,6 @@ object SocketFactsPlugin extends AutoPlugin { } } - // Case-SENSITIVE (matching is documented as case-sensitive). Supports `*`, `?`, and `[...]` - // character classes: enumerations (`[cC]`), ranges (`[a-z]`), and `[!..]`/`[^..]` negation. A - // malformed glob falls back to a literal match, never throws. - private def globToRegex(glob: String): java.util.regex.Pattern = { - val sb = new StringBuilder - var i = 0 - val n = glob.length - while (i < n) { - val c = glob.charAt(i) - if (c == '*') { sb.append(".*"); i += 1 } - else if (c == '?') { sb.append('.'); i += 1 } - else if (c == '[') { - val j = glob.indexOf(']', i + 1) - // Treat as a class only with a non-empty body; else a literal `[`. - if (j <= i + 1) { sb.append("\\["); i += 1 } - else { - var body = glob.substring(i + 1, j) - val neg = body.startsWith("!") - if (neg) body = body.substring(1) - // Only literal chars and `-` ranges are meaningful; neutralize regex-class tricks. - body = body.replace("\\", "\\\\").replace("[", "\\[").replace("&", "\\&") - sb.append('[').append(if (neg) "^" else "").append(body).append(']') - i = j + 1 - } - } else if ("\\.^$|+(){}]".indexOf(c.toInt) >= 0) { - sb.append('\\').append(c); i += 1 - } else { sb.append(c); i += 1 } - } - try java.util.regex.Pattern.compile(sb.toString) - catch { - case _: java.util.regex.PatternSyntaxException => - java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(glob)) - } - } - // ConfigurationReport.configuration is a String on sbt 0.13, a ConfigRef on 1.x: read `.name` reflectively. private def confName(cr: ConfigurationReport): String = { val c: Any = cr.configuration diff --git a/package.json b/package.json index e588916..bcd8b3d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "scripts": { "build": "node scripts/repo/build.mts", + "build:dotnet-tool": "node scripts/repo/build-dotnet-tool.mts", "build:maven-extension": "node scripts/repo/build-maven-extension.mts", "check": "node scripts/fleet/check.mts", "check:paths": "node scripts/fleet/check/paths-are-canonical.mts", diff --git a/scripts/repo/build-dotnet-tool.mts b/scripts/repo/build-dotnet-tool.mts new file mode 100644 index 0000000..ba2b3b0 --- /dev/null +++ b/scripts/repo/build-dotnet-tool.mts @@ -0,0 +1,101 @@ +/* + * @file Publish the dotnet facts emitter into the directory `src/assets.mts` + * resolves it from. Needs a .NET 8+ SDK; the tool itself targets net6.0 with + * RollForward, so it runs on every SDK from 6 up. + * + * Publishing goes to a fresh staging dir which then replaces the old one, so + * a stale assembly can never linger next to a fresh one. The displaced + * directory parks in the system temp dir, where the OS reclaims it — no + * recursive delete of a path this script assembled. + * + * Usage: pnpm run build:dotnet-tool + */ + +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib-stable/errors/message' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' + +import { isMainModule } from '../fleet/_shared/is-main-module.mts' +import { + DOTNET_TOOL_DIR, + DOTNET_TOOL_DLL, + DOTNET_TOOL_PROJECT, + DOTNET_TOOL_PUBLISH_DIR, +} from './paths.mts' +import { safeDelete } from '@socketsecurity/lib-stable/fs/safe' + +const logger = getDefaultLogger() + +export async function publishTool(stagingDir: string): Promise { + await spawn( + 'dotnet', + [ + 'publish', + DOTNET_TOOL_PROJECT, + '-c', + 'Release', + '-o', + stagingDir, + '--nologo', + '-v', + 'quiet', + ], + { cwd: DOTNET_TOOL_DIR, stdio: 'inherit' }, + ) +} + +export async function placePublishOutput(stagingDir: string): Promise { + const stagedDll = path.join(stagingDir, path.basename(DOTNET_TOOL_DLL)) + if (!existsSync(stagedDll)) { + throw new Error( + `dotnet publish finished but produced no tool assembly. ` + + `Where: ${stagedDll}. ` + + `Saw no file, wanted the published socket-facts-dotnet assembly. ` + + `Fix: re-run with \`-v quiet\` removed from publishTool to see the SDK's own output.`, + ) + } + // Debug symbols are not runtime assets and roughly double the shipped size. + const entries = await fs.readdir(stagingDir) + for (const entry of entries) { + if (entry.endsWith('.pdb')) { + await safeDelete(path.join(stagingDir, entry)) + } + } + if (existsSync(DOTNET_TOOL_PUBLISH_DIR)) { + // A path that does not exist yet: renaming onto an existing directory is + // an error on Windows and only legal for an empty one on POSIX. + await fs.rename( + DOTNET_TOOL_PUBLISH_DIR, + path.join( + os.tmpdir(), + `socket-facts-dotnet-old-${process.pid}-${Date.now()}`, + ), + ) + } + await fs.rename(stagingDir, DOTNET_TOOL_PUBLISH_DIR) +} + +export async function main(): Promise { + logger.info('build:dotnet-tool: publishing the facts emitter…') + const stagingDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-facts-dotnet-'), + ) + await publishTool(stagingDir) + await placePublishOutput(stagingDir) + logger.success(`build:dotnet-tool: ${DOTNET_TOOL_DLL}`) +} + +if (isMainModule(import.meta.url)) { + main().then( + () => process.exit(0), + (error: unknown) => { + logger.error(errorMessage(error)) + process.exit(1) + }, + ) +} diff --git a/scripts/repo/check/emitter-assets-are-publishable.mts b/scripts/repo/check/emitter-assets-are-publishable.mts index dd1a2d1..0668e19 100644 --- a/scripts/repo/check/emitter-assets-are-publishable.mts +++ b/scripts/repo/check/emitter-assets-are-publishable.mts @@ -12,11 +12,12 @@ * 1. An emitter source is missing. Always a failure. * 2. `package.json` `files` does not cover `emitters/`, so npm would drop the * assets from the tarball. Always a failure. - * 3. The Maven extension jar is absent. A failure under `--require-jar` (the - * packaging gate) and a warning otherwise, because a plain checkout has no - * JDK obligation. + * 3. A built emitter artifact is absent — the Maven extension jar or the + * published dotnet tool. A failure under `--require-built` (the packaging + * gate) and a warning otherwise, because a plain checkout has no JDK or + * .NET SDK obligation. * - * Usage: node scripts/repo/check/emitter-assets-are-publishable.mts [--require-jar] + * Usage: node scripts/repo/check/emitter-assets-are-publishable.mts [--require-built] * Exit 0 when clean, 1 on any finding. */ @@ -26,7 +27,10 @@ import process from 'node:process' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { isMainModule } from '../../fleet/_shared/is-main-module.mts' import { + DOTNET_TOOL_DIR, + DOTNET_TOOL_DLL, EMITTERS_DIR, MAVEN_EXTENSION_DIR, MAVEN_EXTENSION_JAR, @@ -40,9 +44,36 @@ export const REQUIRED_EMITTER_FILES: readonly string[] = [ path.join(EMITTERS_DIR, 'socket-facts.init.gradle'), path.join(EMITTERS_DIR, 'socket-facts.plugin.scala'), path.join(MAVEN_EXTENSION_DIR, 'pom.xml'), + path.join(DOTNET_TOOL_DIR, 'socket-facts-dotnet.csproj'), + path.join(DOTNET_TOOL_DIR, 'FactsRunner.cs'), + path.join(DOTNET_TOOL_DIR, 'Program.cs'), + path.join(DOTNET_TOOL_DIR, 'RecordsWriter.cs'), + path.join(DOTNET_TOOL_DIR, 'ToolOptions.cs'), MAVEN_WRAPPER, ] +// Emitter artifacts that a build step produces rather than a checkout carrying. +// Both are fail-closed at packaging time and a warning otherwise, because a +// plain checkout has no JDK or .NET SDK obligation. +export const BUILT_EMITTER_ARTIFACTS: ReadonlyArray<{ + path: string + wanted: string + fix: string +}> = [ + { + path: MAVEN_EXTENSION_JAR, + wanted: + 'the shaded core-extension jar, without which Maven emits an empty SBOM instead of failing', + fix: 'run `pnpm run build:maven-extension` (needs a JDK) before packaging', + }, + { + path: DOTNET_TOOL_DLL, + wanted: + 'the published socket-facts-dotnet assembly, without which every dotnet facts run fails to launch', + fix: 'run `pnpm run build:dotnet-tool` (needs a .NET 8+ SDK) before packaging', + }, +] + export const EMITTERS_FILES_ENTRY = 'emitters/**/*' export interface EmitterFinding { @@ -68,7 +99,7 @@ export function readPackageFiles(): string[] { : [] } -export function checkEmitterAssets(requireJar: boolean): EmitterFinding[] { +export function checkEmitterAssets(requireBuilt: boolean): EmitterFinding[] { const findings: EmitterFinding[] = [] for (let i = 0, { length } = REQUIRED_EMITTER_FILES; i < length; i += 1) { const file = REQUIRED_EMITTER_FILES[i]! @@ -89,28 +120,41 @@ export function checkEmitterAssets(requireJar: boolean): EmitterFinding[] { where: 'package.json', }) } - if (!existsSync(MAVEN_EXTENSION_JAR) && requireJar) { - findings.push({ - fix: 'run `pnpm run build:maven-extension` (needs a JDK) before packaging', - saw: 'no built jar', - wanted: - 'the shaded core-extension jar, without which Maven emits an empty SBOM instead of failing', - where: path.relative(REPO_ROOT, MAVEN_EXTENSION_JAR), - }) + if (requireBuilt) { + for (let i = 0, { length } = BUILT_EMITTER_ARTIFACTS; i < length; i += 1) { + const artifact = BUILT_EMITTER_ARTIFACTS[i]! + if (!existsSync(artifact.path)) { + findings.push({ + fix: artifact.fix, + saw: 'no built artifact', + wanted: artifact.wanted, + where: path.relative(REPO_ROOT, artifact.path), + }) + } + } } return findings } export function main(): void { - const requireJar = process.argv.includes('--require-jar') - const findings = checkEmitterAssets(requireJar) + const requireBuilt = process.argv.includes('--require-built') + const findings = checkEmitterAssets(requireBuilt) if (findings.length === 0) { - if (!requireJar && !existsSync(MAVEN_EXTENSION_JAR)) { - logger.warn( - 'emitter-assets-are-publishable: the Maven extension jar is not built. ' + - 'Maven facts generation throws until you run `pnpm run build:maven-extension`; ' + - 'packaging runs this gate with --require-jar and fails instead.', - ) + if (!requireBuilt) { + for ( + let i = 0, { length } = BUILT_EMITTER_ARTIFACTS; + i < length; + i += 1 + ) { + const artifact = BUILT_EMITTER_ARTIFACTS[i]! + if (!existsSync(artifact.path)) { + logger.warn( + `emitter-assets-are-publishable: ${path.relative(REPO_ROOT, artifact.path)} is not built. ` + + `That emitter throws until you build it; packaging runs this gate with ` + + `--require-built and fails instead. Fix: ${artifact.fix}.`, + ) + } + } } logger.info( `emitter-assets-are-publishable: ${REQUIRED_EMITTER_FILES.length} emitter asset(s) present and shipped.`, @@ -130,4 +174,6 @@ export function main(): void { process.exitCode = 1 } -main() +if (isMainModule(import.meta.url)) { + main() +} diff --git a/scripts/repo/paths.mts b/scripts/repo/paths.mts index 3bec9d8..e49b03e 100644 --- a/scripts/repo/paths.mts +++ b/scripts/repo/paths.mts @@ -35,6 +35,34 @@ export const MAVEN_EXTENSION_JAR = path.join( 'socket-facts-maven-extension.jar', ) +/** + * NuGet emitter sources: a self-contained C# tool run through `dotnet`. + */ +export const DOTNET_TOOL_DIR = path.join(EMITTERS_DIR, 'dotnet-tool') + +/** + * The C# project file `pnpm run build:dotnet-tool` publishes. + */ +export const DOTNET_TOOL_PROJECT = path.join( + DOTNET_TOOL_DIR, + 'socket-facts-dotnet.csproj', +) + +/** + * Published tool directory, at the path `src/assets.mts` resolves at runtime. + * `dotnet publish` emits an assembly plus its runtime config, so the unit that + * ships is a directory rather than a single file. + */ +export const DOTNET_TOOL_PUBLISH_DIR = path.join(DOTNET_TOOL_DIR, 'publish') + +/** + * Entry assembly inside the published tool directory. + */ +export const DOTNET_TOOL_DLL = path.join( + DOTNET_TOOL_PUBLISH_DIR, + 'socket-facts-dotnet.dll', +) + /** * The generation API and the wire contracts. */ diff --git a/src/assets.mts b/src/assets.mts index eb32580..c857345 100644 --- a/src/assets.mts +++ b/src/assets.mts @@ -24,6 +24,36 @@ export const MAVEN_EXTENSION_DIR: string = path.join( 'maven-extension', ) +export const DOTNET_TOOL_DIR: string = path.join(EMITTERS_DIR, 'dotnet-tool') + +export const DOTNET_TOOL_DLL_FILENAME = 'socket-facts-dotnet.dll' + +// `dotnet publish` emits a directory, not a single file: the tool assembly +// plus its deps.json/runtimeconfig.json and the compile-time reference +// assemblies. The whole directory ships; this is the entry point inside it. +export const DOTNET_TOOL_PUBLISH_DIR: string = path.join( + DOTNET_TOOL_DIR, + 'publish', +) + +// Fail closed, for the same reason as the Maven jar: a `dotnet` invocation +// pointed at a missing tool assembly is a launch error whose wording ("could +// not execute because the specified command or file was not found") reads like +// a missing SDK, which sends the operator down the wrong path entirely. +export function assertDotnetToolBuilt(): string { + const dllPath = dotnetToolDllPath() + if (existsSync(dllPath)) { + return dllPath + } + throw new Error( + `Socket facts dotnet tool is missing. ` + + `Where: ${dllPath}. ` + + `Saw no file, wanted the published tool assembly that ships with this package. ` + + `Fix: in a published install this is a packaging defect — reinstall @socketsecurity/facts; ` + + `in a local checkout run \`pnpm run build:dotnet-tool\` (needs a .NET 8+ SDK).`, + ) +} + // Fail closed. Maven with no extension on `-Dmaven.ext.class.path` runs to // completion and emits nothing, which downstream reads as "this project has no // dependencies" rather than as a failure — so an absent jar has to throw here, @@ -42,8 +72,14 @@ export function assertMavenExtensionBuilt(): string { ) } +export function dotnetToolDllPath(): string { + return path.join(DOTNET_TOOL_PUBLISH_DIR, DOTNET_TOOL_DLL_FILENAME) +} + export function emitterAssetPath(tool: BuildTool): string { switch (tool) { + case 'dotnet': + return dotnetToolDllPath() case 'gradle': return gradleInitScriptPath() case 'maven': @@ -52,7 +88,7 @@ export function emitterAssetPath(tool: BuildTool): string { return sbtPluginSourcePath() default: throw new Error( - `Unsupported build tool. Where: emitterAssetPath. Saw ${String(tool)}, wanted gradle, maven, or sbt. Fix: pass one of the supported BuildTool values.`, + `Unsupported build tool. Where: emitterAssetPath. Saw ${String(tool)}, wanted dotnet, gradle, maven, or sbt. Fix: pass one of the supported BuildTool values.`, ) } } diff --git a/src/contract/sbom.mts b/src/contract/sbom.mts index df690b7..e9ba974 100644 --- a/src/contract/sbom.mts +++ b/src/contract/sbom.mts @@ -12,7 +12,7 @@ export type AnyPURL = { export const SOCKET_FACTS_SBOM_FORMAT = 'socket-facts-sbom' -export type SocketFactsTool = 'gradle' | 'maven' | 'sbt' +export type SocketFactsTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' // No sources/targets here: those are local absolute paths, returned in-memory // as ResolvedArtifactPaths, never serialized into the SBOM. diff --git a/src/contract/sidecar.mts b/src/contract/sidecar.mts index c0f26f6..3e587cb 100644 --- a/src/contract/sidecar.mts +++ b/src/contract/sidecar.mts @@ -1,7 +1,10 @@ // The `--compute-artifacts-sidecar` wire format: one entry per coordinate the // build resolved. Per coordinate: `targets`/`sources` present → resolved, and -// the consumer uses the paths; both empty → a pom/BOM that resolved with no -// artifact, which is not a failure; the coordinate absent → the consumer +// the consumer uses the paths; both empty → a pom/BOM (or a NuGet package with +// no runtime assemblies) that resolved with no artifact, which is not a +// failure, because the emitters are fail-closed and record a failure instead +// of an empty entry when an artifact is genuinely missing; the coordinate +// absent → the consumer // resolves that coordinate itself, with a best-effort probe of local caches, // then `mvn -Dtransitive=false dependency:get`, then HTTP. // @@ -10,6 +13,15 @@ // resolution this format exists to avoid, at that path's cost and // reliability. Treat a coverage gap as a correctness concern to surface, not // as a silent fallback: see docs/agents.md/repo/contract.md. +// One coordinate can exist in two ecosystems at once — a groupless NuGet id +// and a Maven artifactId collide on the same key — so the ecosystem tag is +// part of the identity, not decoration. The producer ALWAYS writes it; a +// consumer that does not know the key reads a missing tag as 'maven', which +// is what every sidecar written before the tag existed meant. +// +// Emitting this field is gated on the consumer's schema accepting it: see +// "An additive field is a coordinated release" in +// docs/agents.md/repo/contract.md. export type ResolvedComponent = { group: string name: string @@ -19,7 +31,18 @@ export type ResolvedComponent = { // omitting the key would change the wire shape a `.strict()` consumer parses. // socket-lint: allow prefer-undefined-over-null classifier: string | null - // Classpath entries (jars / first-party output dirs). + // The artifact's purl `type`, carried verbatim as the ecosystem + // discriminator each consumer filters on: 'maven' for gradle/sbt/maven, + // 'nuget' for dotnet. It is exactly the facts component's `type`, so there + // is no narrowing and no re-derivation. + // + // Optional in the TYPE, always written by the PRODUCER. Every sidecar this + // package emits carries the tag; the optionality exists so reading a + // sidecar written before the tag existed is still valid, and such a payload + // means 'maven'. Read it as `entry.ecosystem ?? 'maven'`. + ecosystem?: string | undefined + // Classpath entries (jars / first-party output dirs). For NuGet, runtime + // (lib/) assemblies and first-party build outputs. targets: string[] // First-party source roots; [] for external deps. sources: string[] diff --git a/src/contract/validate-sidecar.mts b/src/contract/validate-sidecar.mts index 91eb5f3..5f34234 100644 --- a/src/contract/validate-sidecar.mts +++ b/src/contract/validate-sidecar.mts @@ -13,6 +13,7 @@ import type { ContractValidation, ContractViolation } from './violations.mts' // against the consumer's field list at a glance. export const RESOLVED_COMPONENT_FIELDS: readonly string[] = [ 'classifier', + 'ecosystem', 'ext', 'group', 'name', @@ -81,6 +82,15 @@ export function checkComponent( message: `saw ${describeType(value['classifier'])}, wanted a string or an explicit null`, }) } + // Strict producer, liberal consumer: this package always writes `ecosystem`, + // and a payload without it is still valid — that is what every sidecar + // written before the tag existed looks like, and it means 'maven'. + if ('ecosystem' in value && typeof value['ecosystem'] !== 'string') { + violations.push({ + path: `${path}.ecosystem`, + message: `saw ${describeType(value['ecosystem'])}, wanted a purl type string such as "maven" or "nuget"`, + }) + } checkStringArray(value['sources'], `${path}.sources`, violations) checkStringArray(value['targets'], `${path}.targets`, violations) checkNoUnknownKeys(value, RESOLVED_COMPONENT_FIELDS, path, violations) diff --git a/src/index.mts b/src/index.mts index b3291ca..f4a9f85 100644 --- a/src/index.mts +++ b/src/index.mts @@ -1,5 +1,10 @@ export { + assertDotnetToolBuilt, assertMavenExtensionBuilt, + DOTNET_TOOL_DIR, + DOTNET_TOOL_DLL_FILENAME, + DOTNET_TOOL_PUBLISH_DIR, + dotnetToolDllPath, EMITTERS_DIR, emitterAssetPath, GRADLE_INIT_SCRIPT_FILENAME, @@ -63,7 +68,17 @@ export { parseMavenDependencyTreeJson, } from './conformance/maven-tree.mts' export type { MavenTreeNode } from './conformance/maven-tree.mts' -export { assembleFacts } from './pipeline/assemble.mts' +export { + buildArtifactPaths, + gav, + unionInto, +} from './pipeline/artifact-paths.mts' +export { + assembleFacts, + buildConfigsByProject, + namespaceEntry, + purlTypeForTool, +} from './pipeline/assemble.mts' export type { AssembleOptions, AssembleResult } from './pipeline/assemble.mts' export { parseRecords, unescapeField } from './pipeline/records.mts' export type { @@ -82,7 +97,10 @@ export type { SidecarAccumulator } from './pipeline/sidecar.mts' export { classifyGradleFailure, GRADLE_DIALECT } from './report/gradle.mts' export { classifyIvyFailure, SBT_DIALECT } from './report/ivy.mts' export { classifyMavenFailure, MAVEN_DIALECT } from './report/maven.mts' +export { classifyNugetFailure, NUGET_DIALECT } from './report/nuget.mts' export { + DEFAULT_CONFIG_NOUN, + DEFAULT_EXCLUDE_CONFIGS_OPTION, renderResolutionErrorReport, renderResolutionReport, } from './report/render.mts' @@ -105,6 +123,14 @@ export { isBuildTool, } from './run/build-tool.mts' export type { BuildTool } from './run/build-tool.mts' +export { + compileConfigPatterns, + createConfigGlobFilter, + globToRegexSource, + literalRegexSource, + serializeConfigPatterns, +} from './run/config-glob.mts' +export type { ConfigGlobFilter } from './run/config-glob.mts' export { applyBuildEnvPolicy, BUILD_TOOL_ARGUMENT_ENV_VARS, diff --git a/src/pipeline/artifact-paths.mts b/src/pipeline/artifact-paths.mts new file mode 100644 index 0000000..bcbc7a8 --- /dev/null +++ b/src/pipeline/artifact-paths.mts @@ -0,0 +1,121 @@ +import { mavenCoordinateKey } from '../contract/coordinate.mts' + +import type { ResolvedArtifactPaths } from '../contract/sidecar.mts' +import type { MergedNode } from './assemble.mts' +import type { RawProject } from './records.mts' + +// The `withFiles` half of assembly: which on-disk artifacts each resolved +// coordinate maps to. Kept apart from the SBOM assembly because it answers a +// different question — the SBOM says what resolved, this says where it landed — +// and only a reachability run asks the second one. + +export function buildArtifactPaths( + finalNodes: Map, + projects: RawProject[], + fileExists: (path: string) => boolean, +): ResolvedArtifactPaths { + const projectsByGav = new Map< + string, + { sources: string[]; targets: string[] } + >() + for (let i = 0, { length } = projects; i < length; i += 1) { + const p = projects[i]! + projectsByGav.set(gav(p.group, p.name, p.version), { + sources: p.sources, + targets: p.targets, + }) + } + const targetsByCoord = new Map() + const targetsByGav = new Map() + const sourcesByCoord = new Map() + const coords = new Set() + for (const fn of finalNodes.values()) { + const c = fn.coord + const coordKey = mavenCoordinateKey({ + groupId: c.group, + artifactId: c.name, + type: c.ext, + classifier: c.classifier, + version: c.version, + }) + if (!coordKey) { + continue + } + coords.add(coordKey) + const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) + const sources = (pi?.sources ?? []).filter(fileExists) + const targets = [...new Set([...fn.targets, ...(pi?.targets ?? [])])] + .filter(fileExists) + .toSorted() + if (sources.length) { + sourcesByCoord.set(coordKey, sources) + } + if (!targets.length) { + continue + } + targetsByCoord.set(coordKey, targets) + const gavKey = mavenCoordinateKey({ + groupId: c.group, + artifactId: c.name, + version: c.version, + }) + if (gavKey) { + const acc = targetsByGav.get(gavKey) + if (acc) { + for (let i = 0, { length } = targets; i < length; i += 1) { + const f = targets[i]! + if (!acc.includes(f)) { + acc.push(f) + } + } + } else { + targetsByGav.set(gavKey, [...targets]) + } + } + } + // A top-level module is a `project` but usually not a dependency node, so its + // source roots (where reachability starts) are missed by the node loop above; + // emit first-party module paths here. + for (let i = 0, { length } = projects; i < length; i += 1) { + const p = projects[i]! + const coordKey = mavenCoordinateKey({ + groupId: p.group, + artifactId: p.name, + version: p.version, + }) + if (!coordKey) { + continue + } + coords.add(coordKey) + unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) + const targets = p.targets.filter(fileExists) + unionInto(targetsByCoord, coordKey, targets) + unionInto(targetsByGav, coordKey, targets) + } + return { targetsByCoord, targetsByGav, sourcesByCoord, coords } +} + +export function gav(group: string, name: string, version: string): string { + return `${group}:${name}:${version}` +} + +export function unionInto( + map: Map, + key: string, + add: string[], +): void { + if (!add.length) { + return + } + const acc = map.get(key) + if (acc) { + for (let i = 0, { length } = add; i < length; i += 1) { + const f = add[i]! + if (!acc.includes(f)) { + acc.push(f) + } + } + } else { + map.set(key, [...add]) + } +} diff --git a/src/pipeline/assemble.mts b/src/pipeline/assemble.mts index e9d6220..c2ae1a8 100644 --- a/src/pipeline/assemble.mts +++ b/src/pipeline/assemble.mts @@ -1,14 +1,15 @@ import crypto from 'node:crypto' import { existsSync } from 'node:fs' -import { mavenCoordinateKey } from '../contract/coordinate.mts' import { isBuildTool } from '../run/build-tool.mts' +import { buildArtifactPaths, gav } from './artifact-paths.mts' import type { SocketFactsSbom, SocketFactsSbomComponent, SocketFactsSbomMetadata, SocketFactsSbomProject, + SocketFactsTool, } from '../contract/sbom.mts' import type { ResolvedArtifactPaths } from '../contract/sidecar.mts' import type { ResolutionReport } from '../report/report-types.mts' @@ -16,6 +17,19 @@ import type { ParsedRecords, RawCoord, RawProject } from './records.mts' const PURL_TYPE_MAVEN = 'maven' +const PURL_TYPE_NUGET = 'nuget' + +// Exhaustive, not a "dotnet or else maven" ternary: adding a fifth tool to +// SocketFactsTool then fails to type-check here until someone names its purl +// type, instead of silently assembling maven-typed components for it. +const PURL_TYPE_BY_TOOL: Readonly> = + Object.freeze({ + dotnet: PURL_TYPE_NUGET, + gradle: PURL_TYPE_MAVEN, + maven: PURL_TYPE_MAVEN, + sbt: PURL_TYPE_MAVEN, + }) + export type AssembleResult = { facts: SocketFactsSbom report: ResolutionReport @@ -55,11 +69,12 @@ export function assembleFacts( const { directByRoot, finalNodes } = mergePathSensitive(perRoot) const tool = isBuildTool(parsed.tool) ? parsed.tool : 'gradle' - const components = buildComponents(finalNodes) + const purlType = purlTypeForTool(tool) + const components = buildComponents(finalNodes, purlType) const projects = opts.emitProjects === false ? [] - : buildProjects(parsed, finalNodes, directByRoot, perRoot) + : buildProjects(parsed, finalNodes, directByRoot, perRoot, purlType) const metadata: SocketFactsSbomMetadata = { format: 'socket-facts-sbom', @@ -83,94 +98,9 @@ export function assembleFacts( } } -export function buildArtifactPaths( - finalNodes: Map, - projects: RawProject[], - fileExists: (path: string) => boolean, -): ResolvedArtifactPaths { - const projectsByGav = new Map< - string, - { sources: string[]; targets: string[] } - >() - for (let i = 0, { length } = projects; i < length; i += 1) { - const p = projects[i]! - projectsByGav.set(gav(p.group, p.name, p.version), { - sources: p.sources, - targets: p.targets, - }) - } - const targetsByCoord = new Map() - const targetsByGav = new Map() - const sourcesByCoord = new Map() - const coords = new Set() - for (const fn of finalNodes.values()) { - const c = fn.coord - const coordKey = mavenCoordinateKey({ - groupId: c.group, - artifactId: c.name, - type: c.ext, - classifier: c.classifier, - version: c.version, - }) - if (!coordKey) { - continue - } - coords.add(coordKey) - const pi = projectsByGav.get(gav(c.group, c.name, c.version ?? '')) - const sources = (pi?.sources ?? []).filter(fileExists) - const targets = [...new Set([...fn.targets, ...(pi?.targets ?? [])])] - .filter(fileExists) - .toSorted() - if (sources.length) { - sourcesByCoord.set(coordKey, sources) - } - if (!targets.length) { - continue - } - targetsByCoord.set(coordKey, targets) - const gavKey = mavenCoordinateKey({ - groupId: c.group, - artifactId: c.name, - version: c.version, - }) - if (gavKey) { - const acc = targetsByGav.get(gavKey) - if (acc) { - for (let i = 0, { length } = targets; i < length; i += 1) { - const f = targets[i]! - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - targetsByGav.set(gavKey, [...targets]) - } - } - } - // A top-level module is a `project` but usually not a dependency node, so its - // source roots (where reachability starts) are missed by the node loop above; - // emit first-party module paths here. - for (let i = 0, { length } = projects; i < length; i += 1) { - const p = projects[i]! - const coordKey = mavenCoordinateKey({ - groupId: p.group, - artifactId: p.name, - version: p.version, - }) - if (!coordKey) { - continue - } - coords.add(coordKey) - unionInto(sourcesByCoord, coordKey, p.sources.filter(fileExists)) - const targets = p.targets.filter(fileExists) - unionInto(targetsByCoord, coordKey, targets) - unionInto(targetsByGav, coordKey, targets) - } - return { targetsByCoord, targetsByGav, sourcesByCoord, coords } -} - export function buildComponents( finalNodes: Map, + purlType: string, ): SocketFactsSbomComponent[] { return [...finalNodes.keys()].toSorted().map(id => { const fn = finalNodes.get(id)! @@ -183,8 +113,8 @@ export function buildComponents( qualifiers['ext'] = c.ext } const comp: SocketFactsSbomComponent = { - type: PURL_TYPE_MAVEN, - namespace: c.group, + type: purlType, + ...namespaceEntry(purlType, c.group), name: c.name, ...(c.version ? { version: c.version } : {}), ...(Object.keys(qualifiers).length ? { qualifiers } : {}), @@ -203,6 +133,39 @@ export function buildComponents( }) } +// Roots carry the (project, config) pairs. Label each project by its relative +// dir, which is unique and human-readable, and fall back to its name, then its +// key. The flat scannedConfigs union is not enough on its own once projects in +// one build resolve different configs — routine for dotnet, where every +// project picks its own target frameworks. +export function buildConfigsByProject( + parsed: ParsedRecords, +): Array<{ project: string; configs: string[] }> { + const configsByProjectKey = new Map>() + for (const root of parsed.roots.values()) { + if (!root.config) { + continue + } + let set = configsByProjectKey.get(root.projectKey) + if (!set) { + set = new Set() + configsByProjectKey.set(root.projectKey, set) + } + set.add(root.config) + } + return [...configsByProjectKey] + .map(({ 0: projectKey, 1: configs }) => { + const p = parsed.projects.get(projectKey) + return { + project: p?.dir || p?.name || projectKey, + configs: [...configs].toSorted(), + } + }) + .toSorted((a, b) => + a.project < b.project ? -1 : a.project > b.project ? 1 : 0, + ) +} + export function buildPerRoot(parsed: ParsedRecords): Map { const out = new Map() for (const [rootId, r] of parsed.roots) { @@ -245,6 +208,7 @@ export function buildProjects( finalNodes: Map, directByRoot: Map>, perRoot: Map, + purlType: string, ): SocketFactsSbomProject[] { const idsByGav = new Map>() for (const [id, fn] of finalNodes) { @@ -271,8 +235,8 @@ export function buildProjects( const projects = [...parsed.projects.values()].map(p => { const entry: SocketFactsSbomProject = { - type: PURL_TYPE_MAVEN, - namespace: p.group, + type: purlType, + ...namespaceEntry(purlType, p.group), name: p.name, ...(p.version ? { version: p.version } : {}), subprojectDir: p.dir, @@ -284,8 +248,8 @@ export function buildProjects( return entry }) projects.sort((a, b) => { - const ka = `${a.subprojectDir} ${a.namespace}:${a.name}` - const kb = `${b.subprojectDir} ${b.namespace}:${b.name}` + const ka = `${a.subprojectDir} ${a.namespace ?? ''}:${a.name}` + const kb = `${b.subprojectDir} ${b.namespace ?? ''}:${b.name}` return ka < kb ? -1 : ka > kb ? 1 : 0 }) return projects @@ -310,11 +274,12 @@ export function buildReport(parsed: ParsedRecords): ResolutionReport { seenUnscannable.add(key) return true }) - return { failures, scannedConfigs: parsed.scannedConfigs, unscannable } -} - -export function gav(group: string, name: string, version: string): string { - return `${group}:${name}:${version}` + return { + configsByProject: buildConfigsByProject(parsed), + failures, + scannedConfigs: parsed.scannedConfigs, + unscannable, + } } // A coordinate with identical subtrees everywhere collapses to one node (id = @@ -438,6 +403,23 @@ export function mergePathSensitive(perRoot: Map): { return { finalNodes, directByRoot } } +// Maven-type entries always carry the `namespace` key, even when it is empty: +// that is the shape every pre-dotnet consumer matches identity on. Groupless +// ecosystems (NuGet) omit the key entirely. +export function namespaceEntry( + purlType: string, + group: string, +): { namespace?: string | undefined } { + if (purlType === PURL_TYPE_NUGET && !group) { + return {} + } + return { namespace: group } +} + +export function purlTypeForTool(tool: SocketFactsTool): string { + return PURL_TYPE_BY_TOOL[tool] +} + export function shortHash(s: string): string { return crypto .createHash('sha256') @@ -445,24 +427,3 @@ export function shortHash(s: string): string { .digest('hex') .slice(0, 12) } - -export function unionInto( - map: Map, - key: string, - add: string[], -): void { - if (!add.length) { - return - } - const acc = map.get(key) - if (acc) { - for (let i = 0, { length } = add; i < length; i += 1) { - const f = add[i]! - if (!acc.includes(f)) { - acc.push(f) - } - } - } else { - map.set(key, [...add]) - } -} diff --git a/src/pipeline/records.mts b/src/pipeline/records.mts index 058d0e0..0ba0da2 100644 --- a/src/pipeline/records.mts +++ b/src/pipeline/records.mts @@ -76,7 +76,12 @@ export function parseRecords(text: string): ParsedRecords { const lines = text.split('\n') for (let i = 0, { length } = lines; i < length; i += 1) { - const rawLine = lines[i]! + // Tolerate CRLF. Splitting on '\n' alone would leave a '\r' glued to each + // record's LAST field, which is where the grammar puts prod/direct flags, + // edge targets, and artifact paths — every one of which fails silently + // rather than loudly. The emitters all write LF; this is the parser half + // of that guarantee, so one emitter regressing cannot corrupt a scan. + const rawLine = lines[i]!.replace(/\r$/, '') if (!rawLine) { continue } diff --git a/src/pipeline/sidecar.mts b/src/pipeline/sidecar.mts index 511ccae..bff13d6 100644 --- a/src/pipeline/sidecar.mts +++ b/src/pipeline/sidecar.mts @@ -9,7 +9,8 @@ import type { // Emit an entry for every SBOM component AND every first-party project: a // top-level module is a project, not a dependency component, yet its source -// roots are where reachability starts, so the sidecar must carry them. +// roots are where reachability starts, so the sidecar must carry them. The +// ecosystem is each artifact's own purl `type`, passed through verbatim. export function accumulateSidecar( acc: SidecarAccumulator, facts: SocketFactsSbom, @@ -25,6 +26,7 @@ export function accumulateSidecar( comp.qualifiers?.['ext'] ?? '', // oxlint-disable-next-line socket/prefer-undefined-over-null -- frozen sidecar contract serializes an explicit JSON null comp.qualifiers?.['classifier'] ?? null, + comp.type, ) } // First-party modules have no ext/classifier. @@ -38,6 +40,7 @@ export function accumulateSidecar( '', // oxlint-disable-next-line socket/prefer-undefined-over-null -- frozen sidecar contract serializes an explicit JSON null null, + proj.type, ) } } @@ -50,6 +53,7 @@ export function addEntry( version: string, ext: string, classifier: string | null, + ecosystem: string, ): void { const coordKey = mavenCoordinateKey({ groupId: group, @@ -61,10 +65,23 @@ export function addEntry( if (!coordKey) { return } - let entry = acc.get(coordKey) + // Namespaced by ecosystem so a groupless NuGet coordinate can never merge + // with a Maven one. This key is accumulator-internal; the wire format + // carries the ecosystem tag on the entry itself. + const accKey = `${ecosystem}|${coordKey}` + let entry = acc.get(accKey) if (!entry) { - entry = { group, name, version, ext, classifier, targets: [], sources: [] } - acc.set(coordKey, entry) + entry = { + group, + name, + version, + ext, + classifier, + ecosystem, + targets: [], + sources: [], + } + acc.set(accKey, entry) } pushUnique(entry.targets, artifactPaths.targetsByCoord.get(coordKey) ?? []) pushUnique(entry.sources, artifactPaths.sourcesByCoord.get(coordKey) ?? []) @@ -96,8 +113,8 @@ export function serializeSidecar( entry.sources.sort() } resolved.sort((a, b) => { - const ka = `${a.group}:${a.name}:${a.ext}:${a.classifier ?? ''}:${a.version}` - const kb = `${b.group}:${b.name}:${b.ext}:${b.classifier ?? ''}:${b.version}` + const ka = `${a.ecosystem ?? ''}:${a.group}:${a.name}:${a.ext}:${a.classifier ?? ''}:${a.version}` + const kb = `${b.ecosystem ?? ''}:${b.group}:${b.name}:${b.ext}:${b.classifier ?? ''}:${b.version}` return ka < kb ? -1 : ka > kb ? 1 : 0 }) return resolved diff --git a/src/report/nuget.mts b/src/report/nuget.mts new file mode 100644 index 0000000..12a76a2 --- /dev/null +++ b/src/report/nuget.mts @@ -0,0 +1,88 @@ +import type { FailureCategory, ResolutionDialect } from './render.mts' + +// NuGet restore: failures come from the assets file's `logs` section, whose +// messages carry NU-prefixed codes, plus the emitter's own synthetic +// missing-assets-file failure. No variant ambiguity, so every kind blocks. +export function classifyNugetFailure(detail: string): FailureCategory { + const t = (detail || '').toLowerCase() + // Assembly-load/runtime failures inside the tool are environment problems, + // not feed problems — check FIRST: NuGet wraps them in NU1301-style messages + // whose wording would otherwise classify as repository-or-network and send + // people chasing connectivity. `showReason` on config-problem surfaces the + // real loader message in the summary. + if ( + t.includes('could not load file or assembly') || + t.includes('missingmethodexception') || + t.includes('0x80131040') + ) { + return 'config-problem' + } + if ( + t.includes('nu1301') || + t.includes('nu1302') || + t.includes('nu1303') || + t.includes('nu1304') || + t.includes('unable to load the service index') || + t.includes('401') || + t.includes('403') || + t.includes('unauthorized') || + t.includes('forbidden') || + t.includes('connection refused') || + t.includes('timed out') + ) { + return 'repository-or-network' + } + if ( + t.includes('nu1101') || + t.includes('nu1102') || + t.includes('nu1103') || + t.includes('unable to find package') + ) { + return 'not-found' + } + // Project/framework incompatibilities and a restore that never produced an + // assets file are project-configuration problems, not missing packages. + if ( + t.includes('nu1105') || + t.includes('nu1201') || + t.includes('nu1202') || + t.includes('is not compatible with') || + t.includes('produced no project.assets.json') || + t.includes('stopped before it finished') + ) { + return 'config-problem' + } + return 'other' +} + +export const NUGET_DIALECT: ResolutionDialect = { + categories: [ + { + key: 'not-found', + header: () => ` Not found on any feed:`, + blocking: true, + }, + { + key: 'repository-or-network', + header: n => + ` Feed or network error — ${n} could not reach or authenticate to a package feed:`, + blocking: true, + }, + { + key: 'config-problem', + header: n => ` Project/restore problem (reason from ${n}):`, + showReason: true, + blocking: true, + }, + { + key: 'other', + header: n => ` Other restore failures (reason from ${n}):`, + showReason: true, + blocking: true, + }, + ], + classify: classifyNugetFailure, + configNoun: 'target framework', + excludeConfigsOption: '--exclude-target-frameworks', + label: 'NuGet', +} diff --git a/src/report/render.mts b/src/report/render.mts index 8a6255b..298cff9 100644 --- a/src/report/render.mts +++ b/src/report/render.mts @@ -1,6 +1,7 @@ import { GRADLE_DIALECT } from './gradle.mts' import { SBT_DIALECT } from './ivy.mts' import { MAVEN_DIALECT } from './maven.mts' +import { NUGET_DIALECT } from './nuget.mts' import type { BuildTool } from '../run/build-tool.mts' import type { ResolutionFailure, UnscannableConfig } from './report-types.mts' @@ -34,8 +35,17 @@ export type ResolutionDialect = { label: string classify: (detail: string) => FailureCategory categories: FailureCategorySpec[] + // What this ecosystem calls a "config", and the caller-facing option name + // that narrows the set. NuGet resolves per target framework, so the JVM + // wording would tell a .NET user to pass an option that does not exist. + configNoun?: string | undefined + excludeConfigsOption?: string | undefined } +export const DEFAULT_CONFIG_NOUN = 'configuration' + +export const DEFAULT_EXCLUDE_CONFIGS_OPTION = '--exclude-configs' + export type RenderedResolutionReport = { // Failure report for blocking kinds; empty when nothing blocks. summary: string @@ -68,6 +78,8 @@ export function dedupCoords(coords: Iterable): string[] { export function dialectFor(tool: BuildTool): ResolutionDialect { switch (tool) { + case 'dotnet': + return NUGET_DIALECT case 'sbt': return SBT_DIALECT case 'maven': @@ -122,6 +134,9 @@ export function renderResolutionReport( } = {}, ): RenderedResolutionReport { const name = dialect.label + const noun = dialect.configNoun ?? DEFAULT_CONFIG_NOUN + const excludeOption = + dialect.excludeConfigsOption ?? DEFAULT_EXCLUDE_CONFIGS_OPTION const unscannable = opts.unscannable ?? [] const unscannableConfigs = new Set(unscannable.map(u => u.config)) const specOf = new Map(dialect.categories.map(c => [c.key, c])) @@ -200,10 +215,15 @@ export function renderResolutionReport( const out: string[] = [] if (hasBlockingFailures) { if (blockingCount > 0) { + // A failure with no config attribution — a restore-level error, say — + // would render as "in 0 …(s)". Drop the clause instead. + const inConfigs = perDepBlockingConfigs.size + ? ` in ${perDepBlockingConfigs.size} ${noun}(s)` + : '' out.push( opts.ignoreUnresolved - ? `Ignored ${blockingCount} unresolved dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):` - : `Could not resolve ${blockingCount} dependency(ies) in ${perDepBlockingConfigs.size} configuration(s):`, + ? `Ignored ${blockingCount} unresolved dependency(ies)${inConfigs}:` + : `Could not resolve ${blockingCount} dependency(ies)${inConfigs}:`, ) for (const { infos, spec } of blockingGroups) { out.push('') @@ -230,8 +250,8 @@ export function renderResolutionReport( } out.push( opts.ignoreUnresolved - ? `Ignored ${blockingUnscannable.length} configuration(s) that could not be scanned:` - : `Could not scan ${blockingUnscannable.length} configuration(s) (reason from ${name}):`, + ? `Ignored ${blockingUnscannable.length} ${noun}(s) that could not be scanned:` + : `Could not scan ${blockingUnscannable.length} ${noun}(s) (reason from ${name}):`, ) const shownUnscannable = blockingUnscannable.slice( 0, @@ -264,7 +284,7 @@ export function renderResolutionReport( out.push(`To proceed, re-run with either:`) out.push(` --ignore-unresolved`) if (blockingFailed.length) { - out.push(` --exclude-configs '${blockingFailed.join(',')}'`) + out.push(` ${excludeOption} '${blockingFailed.join(',')}'`) } } out.push('') @@ -284,7 +304,7 @@ export function renderResolutionReport( if (nonBlockingUnscannable.length) { const n = new Set(nonBlockingUnscannable.map(u => u.config)).size notices.push( - `Could not scan ${n} configuration(s) — re-run with --verbose for ${name}'s messages.`, + `Could not scan ${n} ${noun}(s) — re-run with --verbose for ${name}'s messages.`, ) } @@ -300,7 +320,7 @@ export function renderResolutionReport( } if (unscannable.length) { detailLines.push('') - detailLines.push(`${name} configurations that could not be scanned:`) + detailLines.push(`${name} ${noun}s that could not be scanned:`) for (const u of unscannable) { detailLines.push('') detailLines.push(` ${u.config}:`) diff --git a/src/report/report-types.mts b/src/report/report-types.mts index eb6f645..da871b2 100644 --- a/src/report/report-types.mts +++ b/src/report/report-types.mts @@ -13,6 +13,10 @@ export type UnscannableConfig = { } export type ResolutionReport = { + // Which configs each first-party project resolved. `scannedConfigs` is a + // flat union across the whole build, which loses attribution as soon as two + // projects resolve different configs. + configsByProject: Array<{ project: string; configs: string[] }> failures: ResolutionFailure[] scannedConfigs: string[] unscannable: UnscannableConfig[] diff --git a/src/run/build-tool.mts b/src/run/build-tool.mts index 6adbd5d..fafeac3 100644 --- a/src/run/build-tool.mts +++ b/src/run/build-tool.mts @@ -1,14 +1,20 @@ import path from 'node:path' -export type BuildTool = 'gradle' | 'maven' | 'sbt' +export type BuildTool = 'dotnet' | 'gradle' | 'maven' | 'sbt' -export const BUILD_TOOLS: readonly BuildTool[] = ['gradle', 'maven', 'sbt'] +export const BUILD_TOOLS: readonly BuildTool[] = [ + 'dotnet', + 'gradle', + 'maven', + 'sbt', +] // The binary name each tool conventionally installs on PATH. Exported as // KNOWLEDGE, not as a fallback: nothing in this package looks a binary up on // PATH. A consumer that wants PATH resolution does the lookup itself, applies // its own trust policy to the result, and passes the absolute path back in. const CONVENTIONAL_BIN: Readonly> = Object.freeze({ + dotnet: 'dotnet', gradle: 'gradle', maven: 'mvn', sbt: 'sbt', @@ -16,7 +22,8 @@ const CONVENTIONAL_BIN: Readonly> = Object.freeze({ // Project-local wrapper filename, where the tool has that convention. A wrapper // pins the build-tool version the project expects, which is why a consumer -// usually prefers it. sbt has no wrapper convention. POSIX names only. +// usually prefers it. sbt has no wrapper convention, and dotnet pins its SDK +// through global.json instead. POSIX names only. const WRAPPER_FILENAME: Readonly>> = Object.freeze({ gradle: 'gradlew', diff --git a/src/run/config-glob.mts b/src/run/config-glob.mts new file mode 100644 index 0000000..a82c4bf --- /dev/null +++ b/src/run/config-glob.mts @@ -0,0 +1,123 @@ +// Single source of truth for `includeConfigs` / `excludeConfigs` glob +// semantics: case-sensitive; `*`, `?`, and `[...]` character classes with +// `[!..]`/`[^..]` negation; a malformed glob falls back to a literal match, +// never throws. Globs are compiled to regex pattern source strings HERE and +// handed to every emitter (the Gradle init script, the sbt plugin, the Maven +// extension, and the dotnet tool) pre-compiled, so there is exactly one +// implementation and one test suite. +// +// Portability contract: the emitted subset (`.*`, `.`, `[...]`, `[^...]`, and +// backslash-escaped metacharacters) behaves identically in JS RegExp, Java +// java.util.regex (via `.matcher(name).matches()`), and .NET Regex (via +// `IsMatch`). Patterns are anchored with `^(?:...)$` so unanchored matchers +// still test the full name. Class bodies escape `&` because Java classes +// support `&&` intersection (JS/.NET treat it literally). Patterns transport +// comma-joined: an input glob can never contain a comma because the +// comma-split happens before glob parsing. +// +// One deliberate direction change from the per-language implementations this +// replaces: an emitter that receives a pattern it cannot compile DROPS it +// rather than degrading to a literal match. Since an empty include list means +// include-everything, a dropped include widens the scan instead of narrowing +// it. That is safe here because every pattern this module emits is validated +// before it is sent; it only matters to an out-of-band caller driving a +// shipped emitter with hand-written patterns. + +export type ConfigGlobFilter = (name: string) => boolean + +// Comma-separated globs → anchored regex pattern sources. +export function compileConfigPatterns(csv: string | undefined): string[] { + return (csv ?? '') + .split(',') + .map(p => p.trim()) + .filter(Boolean) + .map(globToRegexSource) +} + +// A config is scanned when it matches some include (or there are none) AND +// matches no exclude — the contract documented on the includeConfigs / +// excludeConfigs options. +export function createConfigGlobFilter( + includeConfigs: string | undefined, + excludeConfigs: string | undefined, +): ConfigGlobFilter { + const includes = compileConfigPatterns(includeConfigs).map(s => new RegExp(s)) + const excludes = compileConfigPatterns(excludeConfigs).map(s => new RegExp(s)) + return name => { + if (excludes.some(p => p.test(name))) { + return false + } + return !includes.length || includes.some(p => p.test(name)) + } +} + +export function globToRegexSource(glob: string): string { + let sb = '' + let i = 0 + const n = glob.length + while (i < n) { + const ch = glob.charAt(i) + if (ch === '*') { + sb += '.*' + i += 1 + } else if (ch === '?') { + sb += '.' + i += 1 + } else if (ch === '[') { + const j = glob.indexOf(']', i + 1) + // Treat as a class only with a non-empty body; else a literal `[`. + if (j <= i + 1) { + sb += '\\[' + i += 1 + } else { + let body = glob.slice(i + 1, j) + const neg = body.startsWith('!') || body.startsWith('^') + if (neg) { + body = body.slice(1) + } + if (!body) { + // `[!]`/`[^]` would emit `[^]`, which JS accepts but Java/.NET + // reject; the JS validity gate below can't catch that, so replicate + // the old per-language fallback: the WHOLE glob matches literally. + return literalRegexSource(glob) + } + // Only literal chars and `-` ranges are meaningful; neutralize + // regex-class tricks (`&` guards Java's `&&` class intersection). + // oxlint-disable-next-line socket/prefer-normalize-path -- regex escaping, not a path separator: a backslash inside a glob's class body is doubled so the emitted pattern reads it as a literal + body = body + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/&/g, '\\&') + sb += `[${neg ? '^' : ''}${body}]` + i = j + 1 + } + } else if ('.\\^$|+(){}]'.includes(ch)) { + sb += `\\${ch}` + i += 1 + } else { + sb += ch + i += 1 + } + } + const source = `^(?:${sb})$` + try { + // Validity gate: an unbalanced range or a stray quantifier compiled from a + // hand-written glob degrades to a literal match instead of throwing. + void new RegExp(source) + return source + } catch { + return literalRegexSource(glob) + } +} + +export function literalRegexSource(glob: string): string { + return `^(?:${glob.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})$` +} + +// Transport form handed to the emitters: comma-joined pattern sources (safe, +// because globs — and therefore emitted patterns — cannot contain a comma). +// Empty string when there are no patterns. +export function serializeConfigPatterns(csv: string | undefined): string { + return compileConfigPatterns(csv).join(',') +} diff --git a/src/run/run-facts-generation.mts b/src/run/run-facts-generation.mts index 3495426..a33f2a6 100644 --- a/src/run/run-facts-generation.mts +++ b/src/run/run-facts-generation.mts @@ -2,11 +2,13 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import { + assertDotnetToolBuilt, assertMavenExtensionBuilt, gradleInitScriptPath, SBT_PLUGIN_FILENAME, sbtPluginSourcePath, } from '../assets.mts' +import { serializeConfigPatterns } from './config-glob.mts' import { applyBuildEnvPolicy } from './env.mts' import { assertFactsInvocation } from './invocation.mts' import { factsGenerationTimeoutMs } from './timeouts.mts' @@ -55,11 +57,16 @@ export function emitterProps( if (cfg.populateFilesFor) { props.push(`${prefix}socket.populateFilesFor=${cfg.populateFilesFor}`) } - if (cfg.includeConfigs) { - props.push(`${prefix}socket.includeConfigs=${cfg.includeConfigs}`) + // Globs compile to anchored regex pattern sources HERE, because + // config-glob.mts is the single glob implementation; each emitter only + // compiles the patterns it is handed. + const includePatterns = serializeConfigPatterns(cfg.includeConfigs) + if (includePatterns) { + props.push(`${prefix}socket.includeConfigs=${includePatterns}`) } - if (cfg.excludeConfigs) { - props.push(`${prefix}socket.excludeConfigs=${cfg.excludeConfigs}`) + const excludePatterns = serializeConfigPatterns(cfg.excludeConfigs) + if (excludePatterns) { + props.push(`${prefix}socket.excludeConfigs=${excludePatterns}`) } if (cfg.excludePaths?.length) { // CSV: an entry can never contain a comma, because the CLI flag these come @@ -69,6 +76,41 @@ export function emitterProps( return props } +// The bundled C# tool runs one MSBuild session — evaluate, then an in-process +// restore, then read each project.assets.json through NuGet's own APIs — under +// a single global-property bag, so the caller's `-p:` options apply to +// resolution and to the emitted graph alike. It writes the same records +// protocol as the JVM emitters. +export async function runDotnet( + config: FactsGenerationOptions, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const toolDll = assertDotnetToolBuilt() + return await withTmpDir('socket-dotnet-facts-', async tmp => { + const recordsFile = path.join(tmp, 'records.tsv') + const includePatterns = serializeConfigPatterns(cfg.includeConfigs) + const excludePatterns = serializeConfigPatterns(cfg.excludeConfigs) + const args = [ + toolDll, + '--records', + recordsFile, + '--root', + cfg.cwd, + ...(cfg.withFiles ? ['--with-files'] : []), + ...(includePatterns ? ['--include-configs', includePatterns] : []), + ...(excludePatterns ? ['--exclude-configs', excludePatterns] : []), + ...(cfg.stdio === 'inherit' ? ['--verbose'] : []), + ...cfg.opts, + ] + const out = await runBuildToolNeverThrow( + cfg.bin, + args, + spawnConfigFor(config), + ) + return await assembleFromRecords(out, recordsFile) + }) +} + // Runs one build tool's Socket facts emitter against an already-resolved, // already-vetted invocation and assembles the records it emits. Writes no // files: the caller persists the SBOM and consumes the artifact paths. @@ -78,6 +120,8 @@ export async function runFactsGeneration( const cfg = { __proto__: null, ...config } as typeof config assertFactsInvocation(config) switch (cfg.tool) { + case 'dotnet': + return await runDotnet(config) case 'gradle': return await runGradle(config) case 'maven': @@ -86,7 +130,7 @@ export async function runFactsGeneration( return await runSbt(config) default: throw new Error( - `Unsupported build tool. Where: runFactsGeneration. Saw ${String(cfg.tool)}, wanted gradle, maven, or sbt. Fix: pass one of the supported BuildTool values.`, + `Unsupported build tool. Where: runFactsGeneration. Saw ${String(cfg.tool)}, wanted dotnet, gradle, maven, or sbt. Fix: pass one of the supported BuildTool values.`, ) } } diff --git a/test/repo/unit/assemble.test.mts b/test/repo/unit/assemble.test.mts index bd935e5..b82bc24 100644 --- a/test/repo/unit/assemble.test.mts +++ b/test/repo/unit/assemble.test.mts @@ -51,6 +51,7 @@ describe('records → assemble → sidecar', () => { version: '1.0', ext: '', classifier: null, + ecosystem: 'maven', targets: ['/abs/app/build/classes'], sources: ['/abs/app/src/main/java'], }) @@ -63,3 +64,78 @@ describe('records → assemble → sidecar', () => { expect(byName.get('bom')).toMatchObject({ targets: [], sources: [] }) }) }) + +// The dotnet emitter's records for one project that resolved two target +// frameworks. NuGet coordinates are groupless, so the `group` field is empty +// throughout — that is what makes the namespace and accumulator-key handling +// load-bearing rather than cosmetic. +const DOTNET_RECORDS = [ + 'meta\tdotnet\t8.0.404\t', + 'project\t/repo/App/App.csproj\t\tApp\t1.0.0\tApp', + 'projectSrc\t/repo/App/App.csproj\t/repo/App', + 'projectTgt\t/repo/App/App.csproj\t/repo/App/bin/App.dll', + 'root\tr-net8\t/repo/App/App.csproj\tnet8.0\t1', + 'node\tr-net8\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + 'file\tr-net8\tNewtonsoft.Json:13.0.3\t/cache/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll', + 'root\tr-net6\t/repo/App/App.csproj\tnet6.0\t1', + 'node\tr-net6\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + 'scanned\tnet8.0', + 'scanned\tnet6.0', +].join('\n') + +describe('dotnet records → assemble', () => { + it('types components as nuget and omits the empty namespace', () => { + const { facts } = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + + expect(facts.metadata?.tool).toBe('dotnet') + const component = facts.components[0]! + expect(component.type).toBe('nuget') + expect(component.name).toBe('Newtonsoft.Json') + // A groupless ecosystem drops the key rather than serializing "". + expect(component).not.toHaveProperty('namespace') + expect(facts.projects?.[0]).not.toHaveProperty('namespace') + }) + + it('keeps a maven build emitting an explicit empty namespace', () => { + const records = [ + 'meta\tgradle\t8.0\t17', + 'root\tr1\t:app\truntimeClasspath\t1', + 'node\tr1\tlib:jar:1.0\t\tlib\t1.0\tjar\t\t1', + ].join('\n') + const { facts } = assembleFacts(parseRecords(records), { + fileExists: () => true, + }) + + expect(facts.components[0]).toHaveProperty('namespace', '') + }) + + it('attributes target frameworks to the project that resolved them', () => { + const { report } = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + + expect(report.configsByProject).toStrictEqual([ + { project: 'App', configs: ['net6.0', 'net8.0'] }, + ]) + }) + + // A Windows emitter that forgets to force LF would otherwise glue a '\r' to + // every record's LAST field: prod/direct flags stop parsing as booleans, + // edge targets match no node, and artifact paths fail their exists-check — + // all silently, with the scan still reporting success. + it('parses a CRLF records stream identically to an LF one', () => { + const lf = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + const crlf = assembleFacts( + parseRecords(DOTNET_RECORDS.replaceAll('\n', '\r\n')), + { fileExists: () => true }, + ) + + expect(crlf.facts).toStrictEqual(lf.facts) + expect(crlf.report).toStrictEqual(lf.report) + expect(crlf.facts.components[0]?.direct).toBe(true) + }) +}) diff --git a/test/repo/unit/config-glob.test.mts b/test/repo/unit/config-glob.test.mts new file mode 100644 index 0000000..f49ed31 --- /dev/null +++ b/test/repo/unit/config-glob.test.mts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' + +import { + createConfigGlobFilter, + globToRegexSource, + serializeConfigPatterns, +} from '../../../src/run/config-glob.mts' + +// Vector table for the cross-language config-glob contract. The globs are +// compiled to regex pattern sources here (the ONLY implementation) and handed +// pre-compiled to the Gradle init script, the sbt plugin, the Maven extension, +// and the dotnet tool — so these vectors define the semantics every emitter +// sees. The emitted subset must behave identically in JS RegExp, Java +// java.util.regex, and .NET Regex. +const MATCH_VECTORS: Array<{ + glob: string + matches: string[] + rejects: string[] +}> = [ + // Literals are exact, case-SENSITIVE matches. + { + glob: 'compileClasspath', + matches: ['compileClasspath'], + rejects: ['CompileClasspath', 'compileClasspathX', 'xcompileClasspath'], + }, + // `*` spans any run of characters, including none. + { + glob: '*CompileClasspath', + matches: ['CompileClasspath', 'testCompileClasspath'], + rejects: ['compileClasspath', 'CompileClasspathTest'], + }, + { + glob: 'net*', + matches: ['net8.0', 'netstandard2.0', 'net'], + rejects: ['dotnet8.0'], + }, + // `?` matches exactly one character. + { + glob: 'net?.0', + matches: ['net8.0', 'net9.0'], + rejects: ['net10.0', 'net.0'], + }, + // Character classes: enumerations, ranges, and `[!..]`/`[^..]` negation. + { + glob: '[cC]ompile', + matches: ['compile', 'Compile'], + rejects: ['dompile'], + }, + { + glob: 'net[6-8].0', + matches: ['net6.0', 'net7.0', 'net8.0'], + rejects: ['net9.0'], + }, + { + glob: '[!t]est', + matches: ['best', 'rest'], + rejects: ['test'], + }, + { + glob: '[^t]est', + matches: ['best', 'rest'], + rejects: ['test'], + }, + // Regex metacharacters in globs are literals. + { + glob: 'net8.0', + matches: ['net8.0'], + rejects: ['net8x0'], + }, + { + glob: 'a+b(c)|d', + matches: ['a+b(c)|d'], + rejects: ['aab(c)|d'], + }, + // An unterminated `[` is a literal bracket. + { + glob: 'a[bc', + matches: ['a[bc'], + rejects: ['ab', 'ac'], + }, + // An empty (possibly negated) class would emit `[^]` — valid in JS but + // rejected by Java/.NET — so the whole glob falls back to a literal match, + // the same behavior the per-language implementations had. + { + glob: '[!]est', + matches: ['[!]est'], + rejects: ['best', 'test', 'est'], + }, + { + glob: '[^]est', + matches: ['[^]est'], + rejects: ['best', 'est'], + }, + // `&` inside a class is a literal (Java classes support `&&` intersection; + // the emitted pattern escapes it so all three engines agree). + { + glob: '[a&]x', + matches: ['ax', '&x'], + rejects: ['bx'], + }, +] + +describe('config-glob vectors (cross-language contract)', () => { + for (const { glob, matches, rejects } of MATCH_VECTORS) { + it(`\`${glob}\``, () => { + const filter = createConfigGlobFilter(glob, '') + for (const name of matches) { + expect(filter(name), `${glob} should match ${name}`).toBe(true) + } + for (const name of rejects) { + expect(filter(name), `${glob} should reject ${name}`).toBe(false) + } + }) + } +}) + +describe('include/exclude semantics', () => { + it('no includes means everything; excludes always win', () => { + const filter = createConfigGlobFilter('', '*test*') + expect(filter('compileClasspath')).toBe(true) + expect(filter('Test')).toBe(true) + expect(filter('testCompileClasspath')).toBe(false) + expect(filter('integrationtestRuntime')).toBe(false) + }) + + it('excludes apply after includes', () => { + const filter = createConfigGlobFilter('*Classpath', '*test*') + expect(filter('compileClasspath')).toBe(true) + expect(filter('integrationtestClasspath')).toBe(false) + expect(filter('compile')).toBe(false) + }) + + it('comma-separated patterns OR together', () => { + const filter = createConfigGlobFilter('compile, runtime', '') + expect(filter('compile')).toBe(true) + expect(filter('runtime')).toBe(true) + expect(filter('test')).toBe(false) + }) +}) + +describe('emitted pattern sources (transport format)', () => { + it('anchors patterns so unanchored matchers still test the full name', () => { + expect(globToRegexSource('net*')).toBe('^(?:net.*)$') + expect(globToRegexSource('a?b')).toBe('^(?:a.b)$') + }) + + it('escapes regex metacharacters as literals', () => { + expect(globToRegexSource('net8.0')).toBe('^(?:net8\\.0)$') + expect(globToRegexSource('a+b(c)|d')).toBe('^(?:a\\+b\\(c\\)\\|d)$') + expect(globToRegexSource('a{b}')).toBe('^(?:a\\{b\\})$') + }) + + it('escapes `&` in class bodies (Java `&&` intersection guard)', () => { + expect(globToRegexSource('[a&]x')).toBe('^(?:[a\\&]x)$') + }) + + it('normalizes `[!..]` negation to `[^..]`', () => { + expect(globToRegexSource('[!t]est')).toBe('^(?:[^t]est)$') + }) + + it('never emits `[^]` (Java/.NET-invalid); empty classes go literal', () => { + expect(globToRegexSource('[!]est')).toBe('^(?:\\[!\\]est)$') + expect(globToRegexSource('[^]')).toBe('^(?:\\[\\^\\])$') + }) + + it('emits nothing a comma-join could break on', () => { + // The transport comma-joins patterns; globs cannot contain commas (the + // comma-split precedes glob parsing), so emitted patterns cannot either. + const serialized = serializeConfigPatterns('net*, [a&]x ,a+b(c)|d') + expect(serialized).toBe('^(?:net.*)$,^(?:[a\\&]x)$,^(?:a\\+b\\(c\\)\\|d)$') + for (const pattern of serialized.split(',')) { + expect(() => new RegExp(pattern)).not.toThrow() + } + }) + + it('serializes empty/blank input to the empty string', () => { + expect(serializeConfigPatterns('')).toBe('') + expect(serializeConfigPatterns(' , ,')).toBe('') + expect(serializeConfigPatterns(undefined)).toBe('') + }) +}) diff --git a/test/repo/unit/render.test.mts b/test/repo/unit/render.test.mts index 6d8871e..282958a 100644 --- a/test/repo/unit/render.test.mts +++ b/test/repo/unit/render.test.mts @@ -184,3 +184,42 @@ describe('resolution failure classification', () => { expect(r.summary.startsWith('\n')).toBe(false) }) }) + +describe('nuget dialect wording', () => { + it('names target frameworks and the option that narrows them', () => { + const rendered = renderResolutionErrorReport( + [ + { + coord: 'Contoso.Missing:1.0.0', + detail: 'NU1101: Unable to find package Contoso.Missing', + config: 'net8.0', + }, + ], + ['net8.0'], + 'dotnet', + ) + + expect(rendered.hasBlockingFailures).toBe(true) + expect(rendered.summary).toContain('1 target framework(s)') + expect(rendered.summary).toContain("--exclude-target-frameworks 'net8.0'") + expect(rendered.summary).not.toContain('configuration(s)') + }) + + it('drops the count clause when no failure carries a target framework', () => { + const rendered = renderResolutionErrorReport( + [ + { + coord: '.', + detail: + 'socket-facts-dotnet stopped before it finished; the records below are incomplete: boom', + config: '', + }, + ], + [], + 'dotnet', + ) + + expect(rendered.hasBlockingFailures).toBe(true) + expect(rendered.summary).not.toContain('in 0 ') + }) +}) diff --git a/test/repo/unit/sidecar.test.mts b/test/repo/unit/sidecar.test.mts index 0be6cbe..182b988 100644 --- a/test/repo/unit/sidecar.test.mts +++ b/test/repo/unit/sidecar.test.mts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' +import { assembleFacts } from '../../../src/pipeline/assemble.mts' +import { parseRecords } from '../../../src/pipeline/records.mts' import { accumulateSidecar, + createSidecarAccumulator, serializeSidecar, } from '../../../src/pipeline/sidecar.mts' @@ -76,6 +79,7 @@ describe('compute-artifacts sidecar', () => { version: 'da517db', ext: 'jar', classifier: null, + ecosystem: 'maven', targets: ['/abs/lib.jar'], sources: ['/abs/lib/src/main/java'], }, @@ -158,6 +162,7 @@ describe('compute-artifacts sidecar', () => { version: '1.0', ext: '', classifier: null, + ecosystem: 'maven', targets: ['/abs/app/build/classes'], sources: ['/abs/app/src/main/java'], }, @@ -176,3 +181,51 @@ describe('compute-artifacts sidecar', () => { expect(resolved[0]!.targets).toEqual(['/root-a/a.jar', '/root-b/a.jar']) }) }) + +// The dotnet emitter's records for one project that resolved two target +// frameworks. NuGet coordinates are groupless, so the `group` field is empty +// throughout — that is what makes the namespace and accumulator-key handling +// load-bearing rather than cosmetic. +const DOTNET_RECORDS = [ + 'meta\tdotnet\t8.0.404\t', + 'project\t/repo/App/App.csproj\t\tApp\t1.0.0\tApp', + 'projectSrc\t/repo/App/App.csproj\t/repo/App', + 'projectTgt\t/repo/App/App.csproj\t/repo/App/bin/App.dll', + 'root\tr-net8\t/repo/App/App.csproj\tnet8.0\t1', + 'node\tr-net8\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + 'file\tr-net8\tNewtonsoft.Json:13.0.3\t/cache/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll', + 'root\tr-net6\t/repo/App/App.csproj\tnet6.0\t1', + 'node\tr-net6\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + 'scanned\tnet8.0', + 'scanned\tnet6.0', +].join('\n') + +describe('sidecar ecosystem tagging', () => { + it('keeps a nuget coordinate separate from a maven one of the same name', () => { + const acc = createSidecarAccumulator() + const dotnet = assembleFacts(parseRecords(DOTNET_RECORDS), { + fileExists: () => true, + }) + accumulateSidecar(acc, dotnet.facts, dotnet.artifactPaths) + + // A groupless NuGet id and a Maven artifactId can produce the same + // coordinate key; only the ecosystem tag keeps them apart. + const maven = assembleFacts( + parseRecords( + [ + 'meta\tmaven\t3.9.6\t17', + 'root\tr1\t:app\tcompile\t1', + 'node\tr1\tNewtonsoft.Json:13.0.3\t\tNewtonsoft.Json\t13.0.3\t\t\t1', + ].join('\n'), + ), + { fileExists: () => true }, + ) + accumulateSidecar(acc, maven.facts, maven.artifactPaths) + + const ecosystems = serializeSidecar(acc) + .filter(e => e.name === 'Newtonsoft.Json') + .map(e => e.ecosystem) + .toSorted() + expect(ecosystems).toStrictEqual(['maven', 'nuget']) + }) +}) diff --git a/test/repo/unit/validate-sidecar.test.mts b/test/repo/unit/validate-sidecar.test.mts index ba8f129..7fb2ed2 100644 --- a/test/repo/unit/validate-sidecar.test.mts +++ b/test/repo/unit/validate-sidecar.test.mts @@ -13,6 +13,7 @@ function component( ): Record { return { classifier: null, + ecosystem: 'maven', ext: 'jar', group: 'org.example', name: 'lib', @@ -107,3 +108,21 @@ describe('assertResolvedPathsSidecar', () => { expect(message).toContain('Fix:') }) }) + +describe('the ecosystem tag', () => { + it('still accepts a sidecar written before the tag existed', () => { + const legacy = component() + delete legacy['ecosystem'] + + expect(validateResolvedPathsSidecar([legacy]).ok).toBe(true) + }) + + it('rejects a non-string tag', () => { + const result = validateResolvedPathsSidecar([ + component({ ecosystem: 7 } as never), + ]) + + expect(result.ok).toBe(false) + expect(result.ok ? [] : result.violations[0]?.path).toBe('[0].ecosystem') + }) +}) From 7763894776244fe377900a90dd26f68e68c6213a Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 14:31:08 -0400 Subject: [PATCH 2/4] fix(scripts): guard the build entrypoints behind isMainModule Importing either build script as a library ran main() against the caller's argv. The dotnet build script lands with the guard already in place; these two are its siblings, and entry-scripts-are-fail-soft flags all three together. --- scripts/repo/build-maven-extension.mts | 17 ++++++++++------- scripts/repo/build.mts | 17 ++++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/scripts/repo/build-maven-extension.mts b/scripts/repo/build-maven-extension.mts index 8451a7d..a722f73 100644 --- a/scripts/repo/build-maven-extension.mts +++ b/scripts/repo/build-maven-extension.mts @@ -15,6 +15,7 @@ import { errorMessage } from '@socketsecurity/lib-stable/errors/message' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { isMainModule } from '../fleet/_shared/is-main-module.mts' import { MAVEN_EXTENSION_DIR, MAVEN_EXTENSION_JAR, @@ -55,10 +56,12 @@ export async function main(): Promise { logger.success(`build:maven-extension: ${MAVEN_EXTENSION_JAR}`) } -main().then( - () => process.exit(0), - (error: unknown) => { - logger.error(errorMessage(error)) - process.exit(1) - }, -) +if (isMainModule(import.meta.url)) { + main().then( + () => process.exit(0), + (error: unknown) => { + logger.error(errorMessage(error)) + process.exit(1) + }, + ) +} diff --git a/scripts/repo/build.mts b/scripts/repo/build.mts index 58b7ad5..0f14bd9 100644 --- a/scripts/repo/build.mts +++ b/scripts/repo/build.mts @@ -25,6 +25,7 @@ import { errorMessage } from '@socketsecurity/lib-stable/errors/message' import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' import { spawn } from '@socketsecurity/lib-stable/process/spawn/child' +import { isMainModule } from '../fleet/_shared/is-main-module.mts' import { REPO_ROOT } from './paths.mts' const logger = getDefaultLogger() @@ -73,10 +74,12 @@ export async function main(): Promise { logger.info('build: done') } -main().then( - () => process.exit(0), - (error: unknown) => { - logger.error(errorMessage(error)) - process.exit(1) - }, -) +if (isMainModule(import.meta.url)) { + main().then( + () => process.exit(0), + (error: unknown) => { + logger.error(errorMessage(error)) + process.exit(1) + }, + ) +} From 5c2d5e873837454da7a3c1f29eab6199d0c02665 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 15:23:04 -0400 Subject: [PATCH 3/4] fix(pipeline): drop the unused RawProject import from assemble Splitting artifact-paths out of assemble left the type import behind. The declaration build runs with noUnusedLocals, so it failed there while the type check passed. --- src/pipeline/assemble.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/assemble.mts b/src/pipeline/assemble.mts index c2ae1a8..64b619d 100644 --- a/src/pipeline/assemble.mts +++ b/src/pipeline/assemble.mts @@ -13,7 +13,7 @@ import type { } from '../contract/sbom.mts' import type { ResolvedArtifactPaths } from '../contract/sidecar.mts' import type { ResolutionReport } from '../report/report-types.mts' -import type { ParsedRecords, RawCoord, RawProject } from './records.mts' +import type { ParsedRecords, RawCoord } from './records.mts' const PURL_TYPE_MAVEN = 'maven' From 154f939e34559c184344adc00c95d3ce9943dec1 Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 5 Aug 2026 15:29:10 -0400 Subject: [PATCH 4/4] fix(emitters): pin the dotnet tool's NuGet references to the patched 6.0.6 6.0.0 carries GHSA-68w7-72jg-6qpp, a critical NuGet client security-feature bypass, plus two high-severity advisories on NuGet.Common and NuGet.Protocol. Socket's own scanner blocks the PR on it. 6.0.6 is the patched floor of the same line, so the compile-low/run-high guarantee the references exist for is unchanged: the tool still binds on every SDK from 6 up and still ships no NuGet runtime assets. --- .../dotnet-tool/socket-facts-dotnet.csproj | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/emitters/dotnet-tool/socket-facts-dotnet.csproj b/emitters/dotnet-tool/socket-facts-dotnet.csproj index c7e1d1f..c1256c1 100644 --- a/emitters/dotnet-tool/socket-facts-dotnet.csproj +++ b/emitters/dotnet-tool/socket-facts-dotnet.csproj @@ -24,7 +24,10 @@ clash (0x80131040) with ours on any SDK whose NuGet version differs from the bundled one. Versions are the OLDEST supported surface: compile-low/run-high binds on every SDK >= 6, and the SDK's own NuGet - can always read its own restore output. Microsoft.Build 17.3.2 is the + can always read its own restore output. 6.0.6 rather than 6.0.0 is + the patched floor of that line: 6.0.0 carries GHSA-68w7-72jg-6qpp, + a critical NuGet client security-feature bypass. Staying inside 6.0.x + keeps the compile-low guarantee intact. Microsoft.Build 17.3.2 is the newest with net6.0 assets. --> @@ -34,16 +37,16 @@ closure is listed explicitly to keep its runtime assets out of the publish output. Credentials enables plugin credential providers (e.g. Azure Artifacts) for the packages.config artifact downloads. --> - - - - - - - - - - + + + + + + + + + +