From 3c878a74f814929243e0c2eb30fbf054f155bd25 Mon Sep 17 00:00:00 2001 From: jsutherland-snyk <100410878+jsutherland-snyk@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:28:59 +0100 Subject: [PATCH] feat: support .slnx solution files on --file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--file=*.sln` selects the projects a solution holds rather than scanning the solution itself. `.slnx`, the XML solution format that replaces `.sln` from Visual Studio 17.14 / .NET 9 onwards, was not recognised: it fell through to package-manager detection and failed with "could not detect package manager". Customers converting `.sln` to `.slnx` as part of a .NET 9/10 upgrade hit this and their pipelines break. CMPA-766. Project paths are read out of the XML rather than with a new XML dependency, and comments are stripped first so a commented-out project isn't scanned. `.sln` folder resolution is untouched. The two formats disagree about a project path written with a trailing separator — `.slnx` allows it to name the project's directory, while a `.sln` ASP.NET Website entry has always resolved to the directory above — so each format keeps its own rule and a test pins the `.sln` one. Two deliberate, disclosed side-effects on `.sln`: - the extension is now matched case-insensitively, so `--file=App.SLN` is expanded where it previously fell through to manifest detection; - the unsupported-combination message names the extension in lower case, matching the message cli-extension-os-flows produces for the same rejection. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/main.ts | 4 +- src/lib/sln/index.ts | 135 +++++++++++-- test/acceptance/workspaces/emptySolution.slnx | 3 + .../sln-example-app/mySolution.slnx | 6 + .../sln-no-supported-files/mySolution.slnx | 4 + .../sln-website-project/mySolution.sln | 8 + test/acceptance/workspaces/slnxSolution.slnx | 11 ++ test/fixtures/nuget-sln/Service.slnx | 3 + test/jest/acceptance/cli-args.spec.ts | 14 ++ .../snyk-sbom/nuget-options.spec.ts | 20 ++ test/jest/unit/lib/sln.spec.ts | 185 ++++++++++++++++++ 11 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 test/acceptance/workspaces/emptySolution.slnx create mode 100644 test/acceptance/workspaces/sln-example-app/mySolution.slnx create mode 100644 test/acceptance/workspaces/sln-no-supported-files/mySolution.slnx create mode 100644 test/acceptance/workspaces/sln-website-project/mySolution.sln create mode 100644 test/acceptance/workspaces/slnxSolution.slnx create mode 100644 test/fixtures/nuget-sln/Service.slnx create mode 100644 test/jest/unit/lib/sln.spec.ts diff --git a/src/cli/main.ts b/src/cli/main.ts index dbec95ca3e..bfa33ae09b 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -325,11 +325,11 @@ export async function main(): Promise { if ( globalArgs.options.file && typeof globalArgs.options.file === 'string' && - (globalArgs.options.file as string).match(/\.sln$/) + sln.isSolutionFile(globalArgs.options.file as string) ) { if (globalArgs.options['project-name']) { throw new UnsupportedOptionCombinationError([ - 'file=*.sln', + `file=*${sln.solutionExtension(globalArgs.options.file as string)}`, 'project-name', ]); } diff --git a/src/lib/sln/index.ts b/src/lib/sln/index.ts index 54b9e92e16..6bc5f8ae8c 100644 --- a/src/lib/sln/index.ts +++ b/src/lib/sln/index.ts @@ -7,31 +7,134 @@ import { FileFlagBadInputError } from '../errors'; const debug = Debug('snyk'); +// The two .NET solution formats. `.slnx` is the XML replacement for the text +// `.sln`: the .NET SDK reads it from 9.0.200, and `dotnet new sln` creates it by +// default from .NET 10. +const SLN_EXTENSION = '.sln'; +const SLNX_EXTENSION = '.slnx'; + +// Returns the solution extension `file` ends with, lower-cased, or '' when it is +// not a solution file. Matched on the suffix rather than with path.extname, +// which reports no extension at all for a name that is only an extension +// (`.slnx`) and would send it to the wrong parser. Lower-casing also keeps the +// message this feeds identical to the one cli-extension-os-flows produces. +export const solutionExtension = (file: string): string => { + const lowerCased = file.toLowerCase(); + + if (lowerCased.endsWith(SLNX_EXTENSION)) { + return SLNX_EXTENSION; + } + if (lowerCased.endsWith(SLN_EXTENSION)) { + return SLN_EXTENSION; + } + + return ''; +}; + +export const isSolutionFile = (file: string): boolean => + solutionExtension(file) !== ''; + // slnFile should exist. // returns array of project paths (path/to/manifest.file) export const parsePathsFromSln = (slnFile) => { + const contents = loadFile(path.resolve(slnFile)); + + // Each format reduces a project path to its folder its own way; they do not + // agree, and `.sln` has to keep behaving exactly as it always has. + const paths = + solutionExtension(slnFile) === SLNX_EXTENSION + ? parseProjectPathsFromSlnx(contents).map(slnxProjectFolder) + : parseProjectPathsFromSln(contents).map(slnProjectFolder); + + debug('extracted paths from solution file: ', paths); + return paths; +}; + +// The original text format: `Project(...) = "Name", "path\to\project.csproj", "{guid}"`. +function parseProjectPathsFromSln(contents: string): string[] { // read project scopes from solution file // [\s\S] is like ., but with newlines! // *? means grab the shortest match - const projectScopes = - loadFile(path.resolve(slnFile)).match(/Project[\s\S]*?EndProject/g) || []; - - const paths = projectScopes - .map((projectScope) => { - const secondArg = projectScope.split(',')[1]; - // expected ` "path/to/manifest.file"`, clean it up - return secondArg && secondArg.trim().replace(/"/g, ''); + const projectScopes = contents.match(/Project[\s\S]*?EndProject/g) || []; + + return ( + projectScopes + .map((projectScope) => { + const secondArg = projectScope.split(',')[1]; + // expected ` "path/to/manifest.file"`, clean it up + return secondArg && secondArg.trim().replace(/"/g, ''); + }) + // drop falsey values + .filter(Boolean) + ); +} + +// `.slnx` is XML: a of elements, which may be +// nested inside elements to any depth. All we need out of it is the +// project paths, so we match the Project elements rather than pull an XML parser +// into the CLI's dependency tree. Comments are stripped first so a commented-out +// project isn't scanned. +function parseProjectPathsFromSlnx(contents: string): string[] { + const projectElements = + contents.replace(//g, '').match(/]*>/g) || []; + + return projectElements + .map((element) => { + const attribute = element.match(/\sPath\s*=\s*("([^"]*)"|'([^']*)')/); + // one of the two capture groups holds the value; which one depends on the + // quote style the author used + return attribute && (attribute[2] ?? attribute[3]); }) - // drop falsey values .filter(Boolean) - // convert path separators - .map((projectPath) => { - return path.dirname(projectPath.replace(/\\/g, path.sep)); - }); + .map(decodeXmlEntities); +} - debug('extracted paths from solution file: ', paths); - return paths; -}; +function decodeXmlEntities(value: string): string { + return ( + value + // Numeric character references first. `/` is a path separator, so + // leaving these encoded doesn't just misname a folder, it collapses the + // path to the solution's own directory. + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => + String.fromCodePoint(parseInt(hex, 16)), + ) + .replace(/&#(\d+);/g, (_, decimal) => + String.fromCodePoint(parseInt(decimal, 10)), + ) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + // Ampersand last, so `&#47;` decodes to the literal text `/` + // rather than to a separator. + .replace(/&/g, '&') + ); +} + +function toOsSeparators(projectPath: string): string { + return projectPath.replace(/\\/g, path.sep); +} + +// Unchanged from before `.slnx` existed, deliberately. An ASP.NET Website +// project is written with a trailing separator (`..\WebSites\Site1\`) and has +// always resolved to the folder *above* it. That may well be wrong, but it +// decides which projects a `.sln` scan covers, so correcting it belongs in its +// own change with its own release note — not smuggled in with a new format. +function slnProjectFolder(projectPath: string): string { + return path.dirname(toOsSeparators(projectPath)); +} + +// A `.slnx` project path normally names the project file, whose folder holds the +// manifest. A path written with a trailing separator names a folder instead — +// that is how an ASP.NET Website project is recorded, which has no project file +// at all — so the folder itself is what gets scanned. +function slnxProjectFolder(projectPath: string): string { + const normalised = toOsSeparators(projectPath); + + return /[\\/]$/.test(projectPath) + ? normalised.slice(0, -1) + : path.dirname(normalised); +} export const updateArgs = (args) => { if (!args.options.file || typeof args.options.file !== 'string') { diff --git a/test/acceptance/workspaces/emptySolution.slnx b/test/acceptance/workspaces/emptySolution.slnx new file mode 100644 index 0000000000..216997af2b --- /dev/null +++ b/test/acceptance/workspaces/emptySolution.slnx @@ -0,0 +1,3 @@ + + + diff --git a/test/acceptance/workspaces/sln-example-app/mySolution.slnx b/test/acceptance/workspaces/sln-example-app/mySolution.slnx new file mode 100644 index 0000000000..b929bafbc3 --- /dev/null +++ b/test/acceptance/workspaces/sln-example-app/mySolution.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/acceptance/workspaces/sln-no-supported-files/mySolution.slnx b/test/acceptance/workspaces/sln-no-supported-files/mySolution.slnx new file mode 100644 index 0000000000..f28b1d60d9 --- /dev/null +++ b/test/acceptance/workspaces/sln-no-supported-files/mySolution.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/test/acceptance/workspaces/sln-website-project/mySolution.sln b/test/acceptance/workspaces/sln-website-project/mySolution.sln new file mode 100644 index 0000000000..106d5a3bdf --- /dev/null +++ b/test/acceptance/workspaces/sln-website-project/mySolution.sln @@ -0,0 +1,8 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "WebSite1", "..\..\WebSites\WebSite1\", "{26B9B59B-C5AC-49CE-9BD6-4C72940DAC89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication2", "WebApplication2\WebApplication2.csproj", "{9A79CF26-C7E8-47A3-B718-C5E654AA2701}" +EndProject +Global +EndGlobal diff --git a/test/acceptance/workspaces/slnxSolution.slnx b/test/acceptance/workspaces/slnxSolution.slnx new file mode 100644 index 0000000000..d301fa690b --- /dev/null +++ b/test/acceptance/workspaces/slnxSolution.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/fixtures/nuget-sln/Service.slnx b/test/fixtures/nuget-sln/Service.slnx new file mode 100644 index 0000000000..2d634c0192 --- /dev/null +++ b/test/fixtures/nuget-sln/Service.slnx @@ -0,0 +1,3 @@ + + + diff --git a/test/jest/acceptance/cli-args.spec.ts b/test/jest/acceptance/cli-args.spec.ts index b23c3dc399..96c72cf50a 100644 --- a/test/jest/acceptance/cli-args.spec.ts +++ b/test/jest/acceptance/cli-args.spec.ts @@ -155,6 +155,20 @@ describe.each(userJourneyWorkflows)( expect(code).toEqual(2); }); + test('snyk test --file=file.slnx --project-name=NAME', async () => { + const { code, stdout } = await runSnykCLI( + `test --file=file.slnx --project-name=NAME`, + { + env, + }, + ); + + expect(stdout).toContainText( + 'The following option combination is not currently supported: file=*.slnx + project-name', + ); + expect(code).toEqual(2); + }); + test('snyk test --file=blah --scan-all-unmanaged', async () => { const { code, stdout } = await runSnykCLI( `test --file=blah --scan-all-unmanaged`, diff --git a/test/jest/acceptance/snyk-sbom/nuget-options.spec.ts b/test/jest/acceptance/snyk-sbom/nuget-options.spec.ts index d05fa57735..c4ceebcdee 100644 --- a/test/jest/acceptance/snyk-sbom/nuget-options.spec.ts +++ b/test/jest/acceptance/snyk-sbom/nuget-options.spec.ts @@ -147,4 +147,24 @@ describe('snyk sbom: nuget options (mocked server only)', () => { expect(bom.metadata.component.name).toEqual('Service'); expect(bom.components).toHaveLength(51); }); + + test('`sbom --file` generates an SBOM for the NuGet project by using the file flag with a .slnx solution file', async () => { + const project = await createProjectFromFixture('nuget-sln'); + + const { code, stdout } = await runSnykCLI( + `sbom --org aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee --format cyclonedx1.4+json --file=Service.slnx`, + { + cwd: project.path(), + env, + }, + ); + let bom; + + expect(code).toEqual(0); + expect(() => { + bom = JSON.parse(stdout); + }).not.toThrow(); + expect(bom.metadata.component.name).toEqual('Service'); + expect(bom.components).toHaveLength(51); + }); }); diff --git a/test/jest/unit/lib/sln.spec.ts b/test/jest/unit/lib/sln.spec.ts new file mode 100644 index 0000000000..4f103e013a --- /dev/null +++ b/test/jest/unit/lib/sln.spec.ts @@ -0,0 +1,185 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sln from '../../../../src/lib/sln'; +import { getWorkspacePath } from '../../util/getWorkspacePath'; + +const basenames = (paths: string[]) => paths.map((p) => path.basename(p)); + +// For solution contents that only need to be parsed, not scanned — no project +// folders have to exist on disk. +const writeTempSolution = (contents: string): string => { + const file = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'snyk-slnx-')), + 'mySolution.slnx', + ); + fs.writeFileSync(file, contents); + + return file; +}; + +// The extension decides three things: whether --file is a solution at all, +// which parser reads it, and which extension the unsupported-combination error +// names. cli-extension-os-flows produces that same message from its own +// suffix match, so this has to agree with it — including on case. +describe('solutionExtension', () => { + it.each([ + ['mySolution.sln', '.sln'], + ['mySolution.slnx', '.slnx'], + ['MYSOLUTION.SLNX', '.slnx'], + ['mySolution.SlNx', '.slnx'], + ['path/to/mySolution.slnx', '.slnx'], + // Only an extension, so path.extname reports nothing — it would send this + // to the text parser. + ['.slnx', '.slnx'], + ['.sln', '.sln'], + ['mySolution.slnf', ''], + ['myProject.csproj', ''], + ['slnx', ''], + ['dir.slnx/myProject.csproj', ''], + ])('%s -> "%s"', (file, expected) => { + expect(sln.solutionExtension(file)).toBe(expected); + expect(sln.isSolutionFile(file)).toBe(expected !== ''); + }); +}); + +// An ASP.NET Website project is written into a `.sln` with a trailing +// separator, and has always resolved to the folder above it. Adding `.slnx` +// must not change which folder a `.sln` scan covers. +describe('parsePathsFromSln for .sln solutions', () => { + it('resolves a trailing-separator project path to its parent folder', () => { + const slnFile = getWorkspacePath('sln-website-project/mySolution.sln'); + + expect(sln.parsePathsFromSln(slnFile)).toEqual([ + path.join('..', '..', 'WebSites'), + 'WebApplication2', + ]); + }); +}); + +describe('parsePathsFromSln for .slnx solutions', () => { + it('extracts project folders, including from solution folders', () => { + const slnxFile = getWorkspacePath('sln-example-app/mySolution.slnx'); + + expect(sln.parsePathsFromSln(slnxFile)).toEqual([ + 'dotnet2_new_mvc_project', + 'WebApplication2', + ]); + }); + + it('extracts the same folders as the equivalent .sln solution', () => { + const workspace = 'sln-example-app/mySolution'; + + expect( + sln.parsePathsFromSln(getWorkspacePath(`${workspace}.slnx`)), + ).toEqual(sln.parsePathsFromSln(getWorkspacePath(`${workspace}.sln`))); + }); + + it('ignores commented out projects', () => { + const slnxFile = getWorkspacePath('slnxSolution.slnx'); + + expect(sln.parsePathsFromSln(slnxFile)).not.toContain('removed-app'); + }); + + it('throws when the solution file does not exist', () => { + const slnxFile = getWorkspacePath('sln-example-app/noSuchSolution.slnx'); + + expect(() => sln.parsePathsFromSln(slnxFile)).toThrow('File not found: '); + }); + + // Solution paths are XML attribute values, so anything an XML writer is + // allowed to escape has to be unescaped before it is used as a path. A + // numeric reference for a separator is the case that matters: left encoded, + // the path collapses to the solution's own folder. + it.each([ + ['a named entity', 'R&D Lib/RD.Lib.csproj', 'R&D Lib'], + ['a hex numeric reference', 'R&D Lib/RD.Lib.csproj', 'R&D Lib'], + ['a decimal numeric reference', 'R&D Lib/RD.Lib.csproj', 'R&D Lib'], + ['an encoded separator', 'src/App/App.csproj', path.join('src', 'App')], + ['an escaped ampersand', 'R&amp;D/RD.csproj', 'R&D'], + ])('decodes %s in a project path', (_, written, expected) => { + const slnxFile = writeTempSolution( + ``, + ); + + expect(sln.parsePathsFromSln(slnxFile)).toEqual([expected]); + }); + + it('reads a single-quoted Path attribute', () => { + const slnxFile = writeTempSolution( + ``, + ); + + expect(sln.parsePathsFromSln(slnxFile)).toEqual(['Service']); + }); + + it('finds no projects in an empty solution', () => { + const slnxFile = writeTempSolution(''); + + expect(sln.parsePathsFromSln(slnxFile)).toEqual([]); + }); +}); + +describe('updateArgs for .slnx solutions', () => { + it('replaces --file with the folders of the projects it holds', () => { + const args = { + options: { + file: getWorkspacePath('sln-example-app/mySolution.slnx'), + _: [], + }, + }; + + sln.updateArgs(args); + + expect(args.options.file).toBeUndefined(); + args.options._.pop(); + expect(basenames(args.options._)).toEqual([ + 'dotnet2_new_mvc_project', + 'WebApplication2', + ]); + }); + + it('resolves project paths relative to the solution file', () => { + const args = { + options: { + file: getWorkspacePath('slnxSolution.slnx'), + _: [], + }, + }; + + sln.updateArgs(args); + + expect(args.options.file).toBeUndefined(); + args.options._.pop(); + expect(basenames(args.options._)).toEqual(['nuget-app', 'nuget-app-2.1']); + }); + + it('throws when no project in the solution has a supported manifest', () => { + const args = { + options: { + file: getWorkspacePath('sln-no-supported-files/mySolution.slnx'), + _: [], + }, + }; + + expect(() => sln.updateArgs(args)).toThrow( + 'Could not detect supported target files in dotnet2_new_mvc_project, WebApplication2', + ); + }); + + it('throws when the solution holds no resolvable project', () => { + const args = { + options: { file: getWorkspacePath('emptySolution.slnx'), _: [] }, + }; + + expect(() => sln.updateArgs(args)).toThrow( + /Could not detect supported target files in/, + ); + }); + + it('throws when --file is empty', () => { + expect(() => sln.updateArgs({ options: { _: [] } })).toThrow( + /Empty --file argument/, + ); + }); +});