From ba664e6349bff55a0ef5d9948c161da54cf53793 Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Thu, 23 Jul 2026 13:44:26 +0530 Subject: [PATCH 1/2] XRAY-135041 - slnx support --- cli/docs/flags.go | 2 +- go.mod | 2 +- go.sum | 4 +- sca/bom/buildinfo/technologies/nuget/nuget.go | 29 +++++-- .../technologies/nuget/nuget_test.go | 78 +++++++++++++++---- .../ClassLibrary1/ClassLibrary1.csproj | 11 +++ .../multi-solutions/TestApp1/TestApp1.csproj | 16 ++++ .../nuget/multi-solutions/TestAppOnly.sln | 25 ++++++ .../nuget/multi-solutions/TestSolution.sln | 31 ++++++++ .../ClassLibrary1/ClassLibrary1.csproj | 9 +++ .../nuget/slnx-single/TestSolution.slnx | 3 + utils/techutils/techutils.go | 8 +- utils/techutils/techutils_test.go | 11 +++ 13 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln create mode 100644 tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln create mode 100644 tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 5d358adc2..64b84a705 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -346,7 +346,7 @@ var flagsMap = map[string]components.Flag{ StaticSca: components.NewBoolFlag(StaticSca, "Set to true to use the new SCA engine which is based on lock files.", components.SetHiddenBoolFlag()), UploadRtRepoPath: components.NewStringFlag(UploadRtRepoPath, fmt.Sprintf("Artifactory repository name or path to upload the scan results to. If no name or path are provided, a local generic repository will be created which will automatically be indexed by Xray. only relevant when using --%s", StaticSca), components.WithStrDefaultValue("cli-scan-results"), components.SetHiddenStrFlag()), CurationOutput: components.NewStringFlag(OutputFormat, "Defines the output format of the command. Acceptable values are: table, json.", components.WithStrDefaultValue("table")), - SolutionPath: components.NewStringFlag(SolutionPath, "Path to the .NET solution file (.sln) to use when multiple solution files are present in the directory."), + SolutionPath: components.NewStringFlag(SolutionPath, "Path to the .NET solution file (.sln or .slnx) to use when multiple solution files are present in the directory."), IncludeCachedPackages: components.NewBoolFlag(IncludeCachedPackages, "When set to true, the system will audit cached packages. This configuration is mandatory for Curation on-demand workflows, which rely on package caching."), LegacyPeerDeps: components.NewBoolFlag(LegacyPeerDeps, "[npm] Pass --legacy-peer-deps to npm install to bypass peer-dependency version conflicts."), RunNative: components.NewBoolFlag(RunNative, "[npm] Use the native npm client for dependency resolution. Reads Artifactory URL and repository from the project's .npmrc registry — no 'jf npm-config' required. Respects .npmrc and Volta configuration."), diff --git a/go.mod b/go.mod index 15db7b962..8c9070a94 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/gookit/color v1.6.1 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.6.3 - github.com/jfrog/build-info-go v1.13.1-0.20260521104402-1e35b9b5b0c6 + github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214 github.com/jfrog/froggit-go v1.22.0 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-apps-config v1.0.1 diff --git a/go.sum b/go.sum index a770b12ed..0aaf05f20 100644 --- a/go.sum +++ b/go.sum @@ -161,8 +161,8 @@ github.com/jedib0t/go-pretty/v6 v6.7.10 h1:B/2qW2Bkv2L6n14PP8o1kx75kWzHOQ3YTluWz github.com/jedib0t/go-pretty/v6 v6.7.10/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260521104402-1e35b9b5b0c6 h1:x9UKlP7rEzCYS/Z8IP0I+Oy8PTPpOxFy8qpMeRMbrwo= -github.com/jfrog/build-info-go v1.13.1-0.20260521104402-1e35b9b5b0c6/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214 h1:A5KHxN5VKuvvL7aIIn0GwDPGHuMclkLuBov+ZWbEcyw= +github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.22.0 h1:eeN5F8sOUo+h2cXkzArAu4nvSdjkDTAZtgqwrct70qg= github.com/jfrog/froggit-go v1.22.0/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= diff --git a/sca/bom/buildinfo/technologies/nuget/nuget.go b/sca/bom/buildinfo/technologies/nuget/nuget.go index 1b7f2d70c..57161d769 100644 --- a/sca/bom/buildinfo/technologies/nuget/nuget.go +++ b/sca/bom/buildinfo/technologies/nuget/nuget.go @@ -46,7 +46,14 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } - sol, err := solution.Load(wd, "", params.ExclusionPattern, log.Logger) + if p := params.SolutionFilePath; p != "" { + if _, statErr := os.Stat(filepath.Join(wd, filepath.Base(p))); statErr != nil { + err = fmt.Errorf("--solution-path %q: no solution file named %q in %s "+ + "(the flag is resolved relative to the scanned directory)", p, filepath.Base(p), wd) + return + } + } + sol, err := solution.Load(wd, solutionFileName(params.SolutionFilePath), params.ExclusionPattern, log.Logger) if err != nil && !strings.Contains(err.Error(), globalPackagesNotFoundErrorMessage) { // In older NuGet projects that utilize NuGet Cli and package.config, if the project is not installed, the solution.Load function raises an error because it cannot find global package paths. // This issue is resolved by executing the 'nuget restore' command followed by running solution.Load again. Therefore, in this scenario, we need to proceed with this process. @@ -158,10 +165,21 @@ func runDotnetRestoreAndLoadSolution(params technologies.BuildInfoBomGeneratorPa if err != nil { return } - sol, err = solution.Load(tmpWd, "", exclusionPattern, log.Logger) + sol, err = solution.Load(tmpWd, solutionFileName(params.SolutionFilePath), exclusionPattern, log.Logger) return } +// solutionFileName returns the base file name of the solution file explicitly provided via the +// SolutionFilePath param (e.g. the '--solution-path' flag), or an empty string if none was +// provided. An empty string tells solution.Load to auto-discover '.sln'/'.slnx' files under the +// given directory, which is ambiguous when more than one solution file is present. +func solutionFileName(solutionFilePath string) string { + if solutionFilePath == "" { + return "" + } + return filepath.Base(solutionFilePath) +} + // Detects if the project is utilizing either .NET CLI or NuGet CLI, prioritizing .NET CLI. // Note: For multi-module projects, only one of these tools can be identified and will be uniformly applied across all modules. func getProjectToolName(wd string) (toolName string, err error) { @@ -268,10 +286,9 @@ func runDotnetRestore(wd string, params technologies.BuildInfoBomGeneratorParams } // Check for solution file path from JF CA arguments for specific solution when we have more than one solution file - if params.SolutionFilePath != "" { - solutionFileName := filepath.Base(params.SolutionFilePath) - completeCommandArgs = append(completeCommandArgs, solutionFileName) - log.Info(fmt.Sprintf("Using solution file: %s", solutionFileName)) + if slnFile := solutionFileName(params.SolutionFilePath); slnFile != "" { + completeCommandArgs = append(completeCommandArgs, slnFile) + log.Info(fmt.Sprintf("Using solution file: %s", slnFile)) } // We include the flag that allows resolution from an Artifactory server, if it exists. diff --git a/sca/bom/buildinfo/technologies/nuget/nuget_test.go b/sca/bom/buildinfo/technologies/nuget/nuget_test.go index b39ce2493..9f43844a5 100644 --- a/sca/bom/buildinfo/technologies/nuget/nuget_test.go +++ b/sca/bom/buildinfo/technologies/nuget/nuget_test.go @@ -17,6 +17,7 @@ import ( "github.com/jfrog/build-info-go/entities" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" securityTestUtils "github.com/jfrog/jfrog-cli-security/tests/utils" ) @@ -242,6 +243,11 @@ func TestSolutionFilePathParameter(t *testing.T) { solutionFilePath: "/path/to/my-solution.sln", expectedFileName: "my-solution.sln", }, + { + name: "slnx solution file path from params", + solutionFilePath: "/path/to/my-solution.slnx", + expectedFileName: "my-solution.slnx", + }, { name: "no solution file path", solutionFilePath: "", @@ -251,21 +257,7 @@ func TestSolutionFilePathParameter(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { - params := technologies.BuildInfoBomGeneratorParams{ - SolutionFilePath: test.solutionFilePath, - } - - // Get the solution file path using the same logic as runDotnetRestore - var solutionFilePath string - if params.SolutionFilePath != "" { - solutionFilePath = params.SolutionFilePath - } - var solutionFileName string - if solutionFilePath != "" { - solutionFileName = filepath.Base(solutionFilePath) - } - - assert.Equal(t, test.expectedFileName, solutionFileName) + assert.Equal(t, test.expectedFileName, solutionFileName(test.solutionFilePath)) }) } } @@ -286,3 +278,59 @@ func TestRunDotnetRestoreWithRealSolutionFile(t *testing.T) { assert.NotContains(t, err.Error(), "this folder contains more than one project or solution file") } } + +// TestBuildDependencyTreeWithSlnxSolution proves a '.slnx'-only project (no '.sln' fallback) +// resolves real dependencies via BuildDependencyTree, the same entry point 'jf ca' uses. +func TestBuildDependencyTreeWithSlnxSolution(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "slnx-single")) + defer cleanUp() + + params := technologies.BuildInfoBomGeneratorParams{} + dependencyTrees, uniqueDeps, err := BuildDependencyTree(params) + require.NoError(t, err) + assert.NotEmpty(t, dependencyTrees, "expected at least one dependency tree to be built from the '.slnx' solution") + assert.NotEmpty(t, uniqueDeps, "expected at least one dependency to be resolved from the '.slnx' solution's ClassLibrary1 project") +} + +// TestBuildDependencyTreeWithExplicitSlnxSolutionPath proves passing a '.slnx' file explicitly +// via '--solution-path' resolves real dependencies via BuildDependencyTree, not just auto-discovery. +func TestBuildDependencyTreeWithExplicitSlnxSolutionPath(t *testing.T) { + workDir, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "slnx-single")) + defer cleanUp() + + params := technologies.BuildInfoBomGeneratorParams{ + SolutionFilePath: filepath.Join(workDir, "TestSolution.slnx"), + } + dependencyTrees, uniqueDeps, err := BuildDependencyTree(params) + require.NoError(t, err) + assert.NotEmpty(t, dependencyTrees, "expected at least one dependency tree to be built from the '.slnx' solution via --solution-path") + assert.NotEmpty(t, uniqueDeps, "expected at least one dependency to be resolved from the '.slnx' solution's ClassLibrary1 project via --solution-path") +} + +// TestSolutionFilePathDisambiguatesMultipleSlnFiles verifies --solution-path correctly picks +// one '.sln' out of several in the same directory, instead of hitting dotnet's "more than one +// project or solution file" ambiguity error. +// +// Uses its own "multi-solutions" fixture (not "multi") because TestSkipBuildDepTreeWhenInstallForbidden +// relies on "multi" having exactly one '.sln' for its auto-discovery/no-solution-path case. +func TestSolutionFilePathDisambiguatesMultipleSlnFiles(t *testing.T) { + workDir, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "multi-solutions")) + defer cleanUp() + + // Sanity check: the workspace must actually contain more than one '.sln' file, otherwise + // this test wouldn't be exercising disambiguation at all. + slnFiles, err := filepath.Glob(filepath.Join(workDir, "*.sln")) + require.NoError(t, err) + require.Len(t, slnFiles, 2, "expected exactly 2 '.sln' files in the fixture to test disambiguation") + + toolType := bidotnet.ConvertNameToToolType("dotnet") + for _, slnName := range []string{"TestSolution.sln", "TestAppOnly.sln"} { + t.Run(slnName, func(t *testing.T) { + params := technologies.BuildInfoBomGeneratorParams{ + SolutionFilePath: filepath.Join(workDir, slnName), + } + err := runDotnetRestore(workDir, params, toolType, []string{}) + assert.NoError(t, err) + }) + } +} diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj b/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj new file mode 100644 index 000000000..6e86919b4 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj new file mode 100644 index 000000000..d5d1bfea9 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj @@ -0,0 +1,16 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln new file mode 100644 index 000000000..30b792e66 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.32106.194 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp1", "TestApp1\TestApp1.csproj", "{757C1D5A-BAA9-453F-B612-A0571390976B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E1A2B3C4-D5E6-4F70-8A9B-1C2D3E4F5A6B} + EndGlobalSection +EndGlobal diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln new file mode 100644 index 000000000..1f834792b --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.32106.194 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp1", "TestApp1\TestApp1.csproj", "{757C1D5A-BAA9-453F-B612-A0571390976B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassLibrary1", "ClassLibrary1\ClassLibrary1.csproj", "{D9C89660-69F8-46E2-B9C4-78E9B505E8B1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.Build.0 = Release|Any CPU + {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D8547989-C4F7-4FCC-95BB-F7478F7D2484} + EndGlobalSection +EndGlobal diff --git a/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj b/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj new file mode 100644 index 000000000..7e0ce58ae --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj @@ -0,0 +1,9 @@ + + + + netstandard2.0 + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx b/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx new file mode 100644 index 000000000..83a293b76 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx @@ -0,0 +1,3 @@ + + + diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index f49024673..bfaf6be63 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -246,8 +246,8 @@ var technologiesData = map[Technology]TechData{ }, Nuget: { formal: "NuGet", - indicators: []string{".sln", ".csproj"}, - packageDescriptors: []string{".sln", ".csproj"}, + indicators: []string{".sln", ".slnx", ".csproj"}, + packageDescriptors: []string{".sln", ".slnx", ".csproj"}, // .NET CLI is used for NuGet projects execCommand: "dotnet", packageInstallationCommand: "add", @@ -258,8 +258,8 @@ var technologiesData = map[Technology]TechData{ }, Dotnet: { formal: ".NET", - indicators: []string{".sln", ".csproj"}, - packageDescriptors: []string{".sln", ".csproj"}, + indicators: []string{".sln", ".slnx", ".csproj"}, + packageDescriptors: []string{".sln", ".slnx", ".csproj"}, projectType: project.Dotnet, language: CSharp, }, diff --git a/utils/techutils/techutils_test.go b/utils/techutils/techutils_test.go index 87f9d3c0a..a89a6b468 100644 --- a/utils/techutils/techutils_test.go +++ b/utils/techutils/techutils_test.go @@ -119,6 +119,17 @@ func TestMapFilesToRelevantWorkingDirectories(t *testing.T) { }, expectedExcluded: noExclude, }, + { + // A directory with only a '.slnx' file (no '.sln', no '.csproj') must still be + // detected as a NuGet/.NET working directory. + name: "nugetSlnxOnlyTest", + paths: []string{filepath.Join("dir", "project.slnx"), filepath.Join("dir", "file")}, + requestedDescriptors: noRequest, + expectedWorkingDir: map[string][]string{ + "dir": {filepath.Join("dir", "project.slnx")}, + }, + expectedExcluded: noExclude, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { From 77012f311e893f9989fdf882c7add615d3a8fa65 Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Thu, 30 Jul 2026 13:24:06 +0530 Subject: [PATCH 2/2] Review fix --- sca/bom/buildinfo/technologies/nuget/nuget.go | 2 +- .../technologies/nuget/nuget_test.go | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/sca/bom/buildinfo/technologies/nuget/nuget.go b/sca/bom/buildinfo/technologies/nuget/nuget.go index 57161d769..749f8d375 100644 --- a/sca/bom/buildinfo/technologies/nuget/nuget.go +++ b/sca/bom/buildinfo/technologies/nuget/nuget.go @@ -47,7 +47,7 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen return } if p := params.SolutionFilePath; p != "" { - if _, statErr := os.Stat(filepath.Join(wd, filepath.Base(p))); statErr != nil { + if info, statErr := os.Stat(filepath.Join(wd, filepath.Base(p))); statErr != nil || info.IsDir() { err = fmt.Errorf("--solution-path %q: no solution file named %q in %s "+ "(the flag is resolved relative to the scanned directory)", p, filepath.Base(p), wd) return diff --git a/sca/bom/buildinfo/technologies/nuget/nuget_test.go b/sca/bom/buildinfo/technologies/nuget/nuget_test.go index 9f43844a5..3b926b9d0 100644 --- a/sca/bom/buildinfo/technologies/nuget/nuget_test.go +++ b/sca/bom/buildinfo/technologies/nuget/nuget_test.go @@ -334,3 +334,43 @@ func TestSolutionFilePathDisambiguatesMultipleSlnFiles(t *testing.T) { }) } } + +// TestSolutionFilePathValidation covers the '--solution-path' pre-flight check in +// BuildDependencyTree. The check runs before any solution load or 'restore', so these +// cases never invoke the dotnet CLI. +func TestSolutionFilePathValidation(t *testing.T) { + testCases := []struct { + name string + solutionFilePath string + setup func(t *testing.T, dir string) + }{ + { + name: "base name not present in scanned directory", + solutionFilePath: filepath.Join("sub", "Missing.sln"), + }, + { + name: "path resolves to a directory, not a solution file", + solutionFilePath: filepath.Join("sub", "NotASolution"), + setup: func(t *testing.T, dir string) { + require.NoError(t, os.Mkdir(filepath.Join(dir, "NotASolution"), 0o750)) + }, + }, + } + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + if test.setup != nil { + test.setup(t, dir) + } + t.Chdir(dir) + dependencyTrees, uniqueDeps, err := BuildDependencyTree(technologies.BuildInfoBomGeneratorParams{ + SolutionFilePath: test.solutionFilePath, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--solution-path", "the error must name the flag the user passed") + assert.Contains(t, err.Error(), filepath.Base(test.solutionFilePath)) + assert.Nil(t, dependencyTrees) + assert.Nil(t, uniqueDeps) + }) + } +}