Skip to content

Commit 1f01ec7

Browse files
zlavclaude
andauthored
[ANE-2809] Add NuGet Central Package Management (CPM) support (#1694)
* [ANE-2809] Add NuGet Central Package Management (CPM) support When .csproj files use CPM, PackageReference elements omit the Version attribute—versions live in Directory.Packages.props instead. Previously these deps were sent to the backend with no version, causing FOSSA to resolve the latest from NuGet.org and produce false-positive license and vulnerability results. Walk parent directories from each project file looking for Directory.Packages.props, parse its PackageVersion entries into a version map, and fill in any missing PackageReference versions before building the dependency graph. Inline Version attributes still take precedence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add integration test for NuGet CPM version resolution Runs the full NuGet discover → analyze pipeline on a local fixture with Directory.Packages.props + a .csproj that omits versions. Verifies: - CPM versions are resolved for versionless PackageReferences - Inline versions take precedence over CPM versions - Graph breadth is Partial and all deps are NuGetType Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix hlint warnings: eta-reduce analyze', use traverse over mapM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace local CPM test with FixtureArtifact-based integration test Remove the local-testdata-style NugetCpmSpec and add a proper FixtureArtifact test to NugetSpec using DapperLib/DapperAOT@1.0.48, a real project that uses NuGet Central Package Management. The test downloads the archive and verifies discovery finds all 11 .csproj targets with CPM version resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback: case-insensitive NuGet IDs, qualified imports, stronger assertions - Normalize CPM version map keys with Text.toCaseFold in buildVersionMap and use toCaseFold on lookup in buildGraphWithCPM, since NuGet package IDs are case-insensitive - Add mixed-case package entry to test fixture and assertions to verify case-folded lookup - Use explicit qualified imports in DirectoryPackagesPropsSpec - Add expectDirect/expectEdges to "prefers inline version" test case Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add PR link to changelog entry for NuGet CPM support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Assert CPM-resolved versions in DapperAOT integration test Beyond target count, verify that PackageReferences in Dapper.AOT.Test.Integration.csproj (all versionless) resolve to the exact versions pinned in Directory.Packages.props — proving the NuGet CPM catalog is actually driving dep versions. * Move NuGet CPM entry from released 3.17.1 to Unreleased 3.17.1 has already been released, so the CPM entry belongs in the Unreleased section for this PR. * Bump changelog section to 3.17.3 * Fold Swift and NuGet CPM entries into 3.17.2 3.17.2 is not yet released, so these changes belong in that section. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bc894c6 commit 1f01ec7

11 files changed

Lines changed: 288 additions & 10 deletions

File tree

Changelog.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
# FOSSA CLI Changelog
22

3-
## Unreleased
4-
5-
- Swift: Fix a bug in the `Package.swift` parser which would cause it to error on valid syntax.
6-
73
## 3.17.2
84

5+
- Swift: Fix a bug in the `Package.swift` parser which would cause it to error on valid syntax.
6+
- NuGet: Add Central Package Management (CPM) support — versions defined in `Directory.Packages.props` are now resolved for `PackageReference` entries that omit a `Version` attribute. ([#1694](https://github.com/fossas/fossa-cli/pull/1694))
97
- Poetry: Support PEP 621 `[project].dependencies` for Poetry 2.x projects. Production dependencies declared in the standard `[project]` section are now correctly detected alongside legacy `[tool.poetry.dependencies]`. ([#1683](https://github.com/fossas/fossa-cli/pull/1683))
108

119
## 3.17.1

docs/references/strategies/languages/dotnet/packagereference.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,26 @@ Parse the XML project files, and collect dependency data from all `PackageRefere
1616
<PackageReference Include="Sitecore.Kernel" Version="12.0.0" />
1717
<!-- ... -->
1818
</ItemGroup>
19-
```
19+
```
20+
21+
## Central Package Management (CPM)
22+
23+
Projects using [NuGet Central Package Management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) define dependency versions in a `Directory.Packages.props` file rather than in individual project files. When a `PackageReference` has no `Version` attribute, FOSSA searches parent directories for `Directory.Packages.props` and resolves the version from matching `PackageVersion` entries:
24+
25+
```xml
26+
<!-- Directory.Packages.props -->
27+
<Project>
28+
<ItemGroup>
29+
<PackageVersion Include="Sitecore.Kernel" Version="12.0.0" />
30+
</ItemGroup>
31+
</Project>
32+
```
33+
34+
```xml
35+
<!-- MyProject.csproj — version omitted, resolved from Directory.Packages.props -->
36+
<ItemGroup>
37+
<PackageReference Include="Sitecore.Kernel" />
38+
</ItemGroup>
39+
```
40+
41+
Inline `Version` attributes on `PackageReference` always take precedence over the centrally managed version.

integration-test/Analysis/NugetSpec.hs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ module Analysis.NugetSpec (spec) where
66
import Analysis.FixtureExpectationUtils
77
import Analysis.FixtureUtils
88
import App.Types (Mode (NonStrict))
9+
import Data.Foldable (find)
910
import Data.Set (member)
1011
import Data.Set qualified as Set
1112
import Discovery.Walk (fileName)
13+
import Graphing qualified
1214
import Path
1315
import Strategy.NuGet qualified as NuGet
1416
import Strategy.NuGet.PackagesConfig qualified as PackagesConfig
@@ -65,8 +67,79 @@ testDotnetCoreTwoExampleForProjectAssetsJson =
6567
doesProjectAssetsJsonTargetExist `shouldBe` True
6668
doesCsprojTargetExist `shouldBe` True
6769

70+
-- | DapperAOT uses NuGet Central Package Management (CPM).
71+
-- PackageReference entries in .csproj files omit Version attributes;
72+
-- versions are defined centrally in Directory.Packages.props.
73+
dapperAOT :: AnalysisTestFixture NuGet.NuGetProject
74+
dapperAOT =
75+
AnalysisTestFixture
76+
"DapperAOT-CPM"
77+
NuGet.discover
78+
LocalEnvironment
79+
Nothing
80+
$ FixtureArtifact
81+
"https://github.com/DapperLib/DapperAOT/archive/refs/tags/1.0.48.tar.gz"
82+
[reldir|nuget/DapperAOT/|]
83+
[reldir|DapperAOT-1.0.48//|]
84+
85+
-- | Versions pinned in DapperAOT's Directory.Packages.props at tag 1.0.48.
86+
-- Used to verify that PackageReferences without inline versions are resolved
87+
-- from the NuGet CPM catalog.
88+
dapperAOTCpmVersions :: Set.Set (Dependency)
89+
dapperAOTCpmVersions =
90+
Set.fromList
91+
[ mkCpmDep "Dapper" "2.1.66"
92+
, mkCpmDep "Microsoft.CodeAnalysis.CSharp" "4.12.0"
93+
, mkCpmDep "Microsoft.Data.SqlClient" "6.0.1"
94+
, mkCpmDep "Microsoft.NET.Test.Sdk" "17.12.0"
95+
, mkCpmDep "Npgsql" "9.0.2"
96+
, mkCpmDep "Testcontainers.PostgreSql" "4.1.0"
97+
, mkCpmDep "xunit" "[2.3.0]"
98+
, mkCpmDep "xunit.runner.visualstudio" "[2.3.0]"
99+
]
100+
where
101+
mkCpmDep name ver =
102+
Dependency
103+
{ dependencyType = NuGetType
104+
, dependencyName = name
105+
, dependencyVersion = Just (CEq ver)
106+
, dependencyLocations = []
107+
, dependencyEnvironments = mempty
108+
, dependencyTags = mempty
109+
}
110+
111+
testDapperAOTForCPM :: Spec
112+
testDapperAOTForCPM =
113+
aroundAll (withAnalysisOf NonStrict dapperAOT) $ do
114+
describe "DapperAOT-CPM" $ do
115+
it "should find targets" $ \(result, _) -> do
116+
length result `shouldBe` 11
117+
118+
-- Dapper.AOT.Test.Integration.csproj has only PackageReferences without
119+
-- inline Version attributes, so every direct dep must be resolved from
120+
-- the Directory.Packages.props catalog.
121+
it "should resolve PackageReference versions from Directory.Packages.props" $ \(result, _) -> do
122+
let testIntegration =
123+
find
124+
( (== "Dapper.AOT.Test.Integration.csproj")
125+
. fileName
126+
. NuGet.nugetProjectFile
127+
. Types.projectData
128+
. fst
129+
)
130+
result
131+
case testIntegration of
132+
Nothing -> expectationFailure "expected to find Dapper.AOT.Test.Integration project"
133+
Just (_, deps) -> do
134+
let directDeps =
135+
Set.fromList
136+
. Graphing.directList
137+
$ dependencyGraph deps
138+
directDeps `shouldBe` dapperAOTCpmVersions
139+
68140
spec :: Spec
69141
spec = do
70142
testServiceStackForPkgReferences
71143
testServiceStackForPkgConfig
72144
testDotnetCoreTwoExampleForProjectAssetsJson
145+
testDapperAOTForCPM

spectrometer.cabal

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ library
493493
Strategy.Node.YarnV2.Resolvers
494494
Strategy.Node.YarnV2.YarnLock
495495
Strategy.NuGet
496+
Strategy.NuGet.DirectoryPackagesProps
496497
Strategy.NuGet.Nuspec
497498
Strategy.NuGet.PackageReference
498499
Strategy.NuGet.PackagesConfig
@@ -690,6 +691,7 @@ test-suite unit-tests
690691
Node.PackageJsonSpec
691692
Node.PackageLockSpec
692693
Node.PackageLockV3Spec
694+
NuGet.DirectoryPackagesPropsSpec
693695
NuGet.NuspecSpec
694696
NuGet.PackageReferenceSpec
695697
NuGet.PackagesConfigSpec

src/Strategy/NuGet.hs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import Discovery.Walk (
2929
import Effect.ReadFS (ReadFS)
3030
import GHC.Generics (Generic)
3131
import Path (Abs, Dir, File, Path, parent)
32+
import Strategy.NuGet.DirectoryPackagesProps qualified as DirectoryPackagesProps
3233
import Strategy.NuGet.PackageReference qualified as PackageReference
3334
import Strategy.NuGet.ProjectAssetsJson qualified as ProjectAssetsJson
3435
import Types (
@@ -91,4 +92,7 @@ getAssetsJsonDeps :: (Has ReadFS sig m, Has Diagnostics sig m) => NuGetProject -
9192
getAssetsJsonDeps = context "ProjectAssetsJson" . context "Static analysis" . ProjectAssetsJson.analyze' . nugetProjectFile
9293

9394
getPackageReferenceDeps :: (Has ReadFS sig m, Has Diagnostics sig m) => NuGetProject -> m DependencyResults
94-
getPackageReferenceDeps = context "PackageReference" . context "Static analysis" . PackageReference.analyze' . nugetProjectFile
95+
getPackageReferenceDeps project = context "PackageReference" . context "Static analysis" $ do
96+
let file = nugetProjectFile project
97+
versionMap <- context "Directory.Packages.props" $ DirectoryPackagesProps.findAndParse (parent file)
98+
PackageReference.analyzeWithCPM versionMap file
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
module Strategy.NuGet.DirectoryPackagesProps (
2+
DirectoryPackagesProps (..),
3+
PackageVersionEntry (..),
4+
findAndParse,
5+
buildVersionMap,
6+
) where
7+
8+
import Control.Applicative ((<|>))
9+
import Control.Effect.Diagnostics (Diagnostics, Has, warnOnErr)
10+
import Data.Map.Strict (Map)
11+
import Data.Map.Strict qualified as Map
12+
import Data.Text (Text)
13+
import Data.Text qualified as Text
14+
import Diag.Common (MissingDeepDeps (MissingDeepDeps))
15+
import Effect.ReadFS (ReadFS, doesFileExist, readContentsXML, resolveFile')
16+
import Parse.XML (FromXML (..), attr, children)
17+
import Path (Abs, Dir, File, Path, parent, toFilePath)
18+
19+
-- | Represents a parsed Directory.Packages.props file.
20+
-- See: https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management
21+
newtype DirectoryPackagesProps = DirectoryPackagesProps
22+
{ packageVersionGroups :: [PackageVersionGroup]
23+
}
24+
deriving (Eq, Ord, Show)
25+
26+
newtype PackageVersionGroup = PackageVersionGroup
27+
{ packageVersions :: [PackageVersionEntry]
28+
}
29+
deriving (Eq, Ord, Show)
30+
31+
data PackageVersionEntry = PackageVersionEntry
32+
{ pvName :: Text
33+
, pvVersion :: Text
34+
}
35+
deriving (Eq, Ord, Show)
36+
37+
instance FromXML DirectoryPackagesProps where
38+
parseElement el = DirectoryPackagesProps <$> children "ItemGroup" el
39+
40+
instance FromXML PackageVersionGroup where
41+
parseElement el = PackageVersionGroup <$> children "PackageVersion" el
42+
43+
instance FromXML PackageVersionEntry where
44+
parseElement el =
45+
PackageVersionEntry
46+
<$> (attr "Include" el <|> attr "Update" el)
47+
<*> (attr "Version" el)
48+
49+
-- | Build a map from package name to version from a parsed Directory.Packages.props.
50+
buildVersionMap :: DirectoryPackagesProps -> Map Text Text
51+
buildVersionMap props =
52+
Map.fromList
53+
. map (\pv -> (Text.toCaseFold (pvName pv), pvVersion pv))
54+
. concatMap packageVersions
55+
$ packageVersionGroups props
56+
57+
-- | Search for Directory.Packages.props starting from the given directory,
58+
-- walking up parent directories. If found, parse it and return the version map.
59+
findAndParse ::
60+
(Has ReadFS sig m, Has Diagnostics sig m) =>
61+
Path Abs Dir ->
62+
m (Map Text Text)
63+
findAndParse dir = warnOnErr MissingDeepDeps $ do
64+
found <- findPropsFile dir
65+
case found of
66+
Nothing -> pure Map.empty
67+
Just propsFile -> do
68+
props <- readContentsXML @DirectoryPackagesProps propsFile
69+
pure (buildVersionMap props)
70+
71+
-- | Walk up from @dir@ looking for Directory.Packages.props.
72+
findPropsFile ::
73+
(Has ReadFS sig m) =>
74+
Path Abs Dir ->
75+
m (Maybe (Path Abs File))
76+
findPropsFile dir = do
77+
let parentDir = parent dir
78+
resolved <- resolveFile' dir "Directory.Packages.props"
79+
case resolved of
80+
Right file -> do
81+
exists <- doesFileExist file
82+
if exists
83+
then pure (Just file)
84+
else
85+
if toFilePath dir == toFilePath parentDir
86+
then pure Nothing -- reached root
87+
else findPropsFile parentDir
88+
Left _ ->
89+
if toFilePath dir == toFilePath parentDir
90+
then pure Nothing
91+
else findPropsFile parentDir

src/Strategy/NuGet/PackageReference.hs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
module Strategy.NuGet.PackageReference (
44
buildGraph,
5+
buildGraphWithCPM,
56
analyze',
7+
analyzeWithCPM,
68
PackageReference (..),
79
ItemGroup (..),
810
Package (..),
911
) where
1012

1113
import Control.Applicative (optional, (<|>))
1214
import Control.Effect.Diagnostics (Diagnostics, Has, context)
15+
import Data.Map.Strict (Map)
1316
import Data.Map.Strict qualified as Map
1417
import Data.Text (Text)
18+
import Data.Text qualified as Text
1519
import DepTypes (
1620
DepType (NuGetType),
1721
Dependency (..),
@@ -28,9 +32,14 @@ import Types (
2832
)
2933

3034
analyze' :: (Has ReadFS sig m, Has Diagnostics sig m) => Path Abs File -> m DependencyResults
31-
analyze' file = do
35+
analyze' = analyzeWithCPM Map.empty
36+
37+
-- | Analyze a project file, resolving missing PackageReference versions from
38+
-- a CPM (Central Package Management) version map built from Directory.Packages.props.
39+
analyzeWithCPM :: (Has ReadFS sig m, Has Diagnostics sig m) => Map Text Text -> Path Abs File -> m DependencyResults
40+
analyzeWithCPM versionMap file = do
3241
ref <- readContentsXML @PackageReference file
33-
graph <- context "Building dependency graph" $ pure (buildGraph ref)
42+
graph <- context "Building dependency graph" $ pure (buildGraphWithCPM versionMap ref)
3443
pure $
3544
DependencyResults
3645
{ dependencyGraph = graph
@@ -71,14 +80,20 @@ instance FromXML Package where
7180
<*> optional (attr "Version" el <|> child "Version" el)
7281

7382
buildGraph :: PackageReference -> Graphing Dependency
74-
buildGraph project = Graphing.fromList (map toDependency direct)
83+
buildGraph = buildGraphWithCPM Map.empty
84+
85+
-- | Build a dependency graph, resolving missing versions from a CPM version map.
86+
-- When a PackageReference has no Version attribute, the version is looked up
87+
-- from the map (sourced from Directory.Packages.props).
88+
buildGraphWithCPM :: Map Text Text -> PackageReference -> Graphing Dependency
89+
buildGraphWithCPM versionMap project = Graphing.fromList (map toDependency direct)
7590
where
7691
direct = concatMap dependencies (groups project)
7792
toDependency Package{..} =
7893
Dependency
7994
{ dependencyType = NuGetType
8095
, dependencyName = depID
81-
, dependencyVersion = fmap CEq depVersion
96+
, dependencyVersion = fmap CEq (depVersion <|> Map.lookup (Text.toCaseFold depID) versionMap)
8297
, dependencyLocations = []
8398
, dependencyEnvironments = mempty
8499
, dependencyTags = Map.empty
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module NuGet.DirectoryPackagesPropsSpec (
2+
spec,
3+
) where
4+
5+
import Data.Map.Strict qualified as Map
6+
import Data.String.Conversion (toString)
7+
import Data.Text.IO qualified as TIO
8+
import Parse.XML (parseXML, xmlErrorPretty)
9+
import Strategy.NuGet.DirectoryPackagesProps (buildVersionMap)
10+
import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe)
11+
12+
spec :: Spec
13+
spec = do
14+
propsFile <- runIO (TIO.readFile "test/NuGet/testdata/Directory.Packages.props")
15+
16+
describe "Directory.Packages.props parser" $ do
17+
it "parses PackageVersion entries" $ do
18+
case parseXML propsFile of
19+
Right props -> do
20+
let versions = buildVersionMap props
21+
Map.lookup "one" versions `shouldBe` Just "1.0.0"
22+
Map.lookup "two" versions `shouldBe` Just "2.0.0"
23+
Map.lookup "three" versions `shouldBe` Just "3.0.0"
24+
Map.lookup "four" versions `shouldBe` Just "4.0.0"
25+
Map.lookup "five" versions `shouldBe` Just "5.0.0"
26+
-- Keys are case-folded for case-insensitive NuGet package ID matching
27+
Map.lookup "mixedcase.package" versions `shouldBe` Just "6.0.0"
28+
Map.lookup "MixedCase.Package" versions `shouldBe` Nothing
29+
Map.lookup "nonexistent" versions `shouldBe` Nothing
30+
Left err -> expectationFailure (toString ("could not parse Directory.Packages.props: " <> xmlErrorPretty err))

test/NuGet/PackageReferenceSpec.hs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,22 @@ spec = do
8888
expectDeps [dependencyOne, dependencyTwo, dependencyThree, dependencyFour] graph
8989
expectDirect [dependencyOne, dependencyTwo, dependencyThree, dependencyFour] graph
9090
expectEdges [] graph
91+
92+
it "resolves missing versions from CPM version map" $ do
93+
let versionMap = Map.fromList [("four", "4.0.0"), ("five", "5.0.0")]
94+
graph = buildGraphWithCPM versionMap packageReference
95+
dependencyFourResolved =
96+
dependencyFour{dependencyVersion = Just (CEq "4.0.0")}
97+
expectDeps [dependencyOne, dependencyTwo, dependencyThree, dependencyFourResolved] graph
98+
expectDirect [dependencyOne, dependencyTwo, dependencyThree, dependencyFourResolved] graph
99+
expectEdges [] graph
100+
101+
it "prefers inline version over CPM version" $ do
102+
let versionMap = Map.fromList [("one", "9.9.9"), ("four", "4.0.0")]
103+
graph = buildGraphWithCPM versionMap packageReference
104+
dependencyFourResolved =
105+
dependencyFour{dependencyVersion = Just (CEq "4.0.0")}
106+
-- "one" keeps its inline version 1.0.0, not the CPM version 9.9.9
107+
expectDeps [dependencyOne, dependencyTwo, dependencyThree, dependencyFourResolved] graph
108+
expectDirect [dependencyOne, dependencyTwo, dependencyThree, dependencyFourResolved] graph
109+
expectEdges [] graph
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project>
2+
<PropertyGroup>
3+
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4+
</PropertyGroup>
5+
<ItemGroup>
6+
<PackageVersion Include="one" Version="1.0.0" />
7+
<PackageVersion Include="two" Version="2.0.0" />
8+
<PackageVersion Include="three" Version="3.0.0" />
9+
<PackageVersion Include="four" Version="4.0.0" />
10+
<PackageVersion Include="five" Version="5.0.0" />
11+
<PackageVersion Include="MixedCase.Package" Version="6.0.0" />
12+
</ItemGroup>
13+
</Project>

0 commit comments

Comments
 (0)