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..c1256c1 --- /dev/null +++ b/emitters/dotnet-tool/socket-facts-dotnet.csproj @@ -0,0 +1,52 @@ + + + + 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/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) + }, + ) +} 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..64b619d 100644 --- a/src/pipeline/assemble.mts +++ b/src/pipeline/assemble.mts @@ -1,21 +1,35 @@ 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' -import type { ParsedRecords, RawCoord, RawProject } from './records.mts' +import type { ParsedRecords, RawCoord } 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') + }) +})