diff --git a/Changelog.md b/Changelog.md index 08a06be75..7f557ea77 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,7 @@ ## Unreleased +- Maven: Static analysis of `pom.xml` files now reports the dependencies a project declares as direct. Previously the project itself was reported as the only direct dependency and everything it declared was reported as transitive, and in a multi-module project the root pom and every submodule were also reported as dependencies. This affects analysis without `mvn` on `PATH`, `--static-only-analysis`, and container scanning; analysis using `mvn` was already correct. Selecting a submodule with `--only-target` now reports that submodule's dependencies as direct instead of reporting none at all. ([#1768](https://github.com/fossas/fossa-cli/pull/1768)) - Diagnostics: When an error or warning group contains multiple errors, each error's `Traceback:` header is now printed on its own line instead of being glued onto the last line of the preceding error message (e.g. `...none passed validationTraceback:`). ([#1758](https://github.com/fossas/fossa-cli/pull/1758)) ## 3.18.2 diff --git a/docs/references/strategies/languages/maven/pomxml.md b/docs/references/strategies/languages/maven/pomxml.md index 2dedd52a8..b4b342075 100644 --- a/docs/references/strategies/languages/maven/pomxml.md +++ b/docs/references/strategies/languages/maven/pomxml.md @@ -16,4 +16,15 @@ poms are linked together by their `` references into multi-project proje Each project in the single- or multi-project structure has its pom information overlayed on top of parent poms, and a dependency graph is gathered from each project. +The root pom and every submodule are excluded from the reported graph, and the +dependencies they declare are reported as direct. This is the same treatment the +`mavenplugin` and `treecmd` tactics give them: those are the user's own projects, +not things they depend on. A pom file only declares direct dependencies, so this +tactic reports no transitive dependencies and no edges. + +For example, given a project `com.example:root` whose `mod-a` submodule declares +`junit:junit` and whose `mod-b` submodule declares `com.google.guava:guava`, the +reported dependencies are `junit:junit` and `com.google.guava:guava`, both direct. +Neither `com.example:root` nor either submodule is reported. + We have limited support for naive `${property}` interpolation. diff --git a/src/Strategy/Maven.hs b/src/Strategy/Maven.hs index 8f5bef247..5ae0edc6a 100644 --- a/src/Strategy/Maven.hs +++ b/src/Strategy/Maven.hs @@ -3,6 +3,7 @@ module Strategy.Maven ( mkProject, MavenProject (..), getDeps, + getDepsStatically, ) where import App.Fossa.Analyze.LicenseAnalyze (LicenseAnalyzeProject, licenseAnalyzeProject) @@ -18,14 +19,14 @@ import Data.Set (Set) import Data.Set qualified as Set import Data.Set.NonEmpty (nonEmpty, toSet) import Data.Text hiding (group, map) -import DepTypes (Dependency) +import DepTypes (Dependency (dependencyName)) import Diag.Common (MissingDeepDeps (MissingDeepDeps), MissingEdges (MissingEdges)) import Discovery.Filters (AllFilters, MavenScopeFilters, mavenScopeFilterSet) import Discovery.Simple (simpleDiscover) import Effect.Exec (CandidateCommandEffs, GetDepsEffs) import Effect.ReadFS (ReadFS) import GHC.Generics (Generic) -import Graphing (Graphing, gmap, shrinkRoots) +import Graphing (Graphing, gmap, promoteToDirect, shrink, shrinkRoots) import Path (Abs, Dir, Path, parent) import Strategy.Maven.Common (MavenDependency (..), filterMavenDependencyByScope, filterMavenSubmodules, mavenDependencyToDependency) import Strategy.Maven.DepTree qualified as DepTreeCmd @@ -188,7 +189,31 @@ getStaticAnalysis submoduleTargets closure = do let allSubmodules = PomClosure.closureSubmodules closure (graph, graphBreadth) <- context "Static analysis" $ pure (Pom.analyze' closure, Partial) filteredGraph <- applyMavenFilters submoduleTargets allSubmodules graph - pure (filteredGraph, graphBreadth) + pure (withoutProjectsAsDeps allSubmodules filteredGraph, graphBreadth) + +-- | Remove the user's own packages -- the root pom and every submodule -- from a +-- static graph, promoting the dependencies they declare to direct. +-- +-- 'Pom.analyze'' builds a graph rooted at the project itself, so without this the +-- project is the only direct dependency and every dependency it declares is +-- reported as transitive. Marking the project packages direct and then shrinking +-- them away is the same two steps the dynamic tactics take, where +-- 'Plugin.buildGraph' marks them and 'shrinkRoots' removes them. +-- +-- Both steps are needed. 'shrinkRoots' alone would remove only the root pom and +-- promote the submodules to direct dependencies rather than dropping them. A +-- 'shrink' alone would leave a submodule-filtered graph with no direct +-- dependencies at all, because submodule filtering removes the root pom node +-- without rewiring the edges through it. +-- +-- This runs after submodule filtering, which needs the submodule nodes present in +-- the graph to work out which dependencies belong to which submodule. +withoutProjectsAsDeps :: Set Text -> Graphing Dependency -> Graphing Dependency +withoutProjectsAsDeps projectPackages = + shrink (not . isProjectPackage) . promoteToDirect isProjectPackage + where + isProjectPackage :: Dependency -> Bool + isProjectPackage dep = dependencyName dep `Set.member` projectPackages applyMavenFilters :: (Has Diagnostics sig m, Has (Reader MavenScopeFilters) sig m) => Set Text -> Set Text -> Graphing MavenDependency -> m (Graphing Dependency) applyMavenFilters targetSet submoduleSet graph = do diff --git a/test/Maven/PomStrategySpec.hs b/test/Maven/PomStrategySpec.hs index d8cd3419a..813745499 100644 --- a/test/Maven/PomStrategySpec.hs +++ b/test/Maven/PomStrategySpec.hs @@ -1,11 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + module Maven.PomStrategySpec ( spec, ) where +import Control.Carrier.Reader (runReader) +import Control.Effect.Lift (sendIO) +import Data.ByteString.Char8 qualified as BS +import Data.List (sort) import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Set.NonEmpty (nonEmpty) +import Data.Text (Text) +import DepTypes (Dependency (dependencyName)) +import Discovery.Filters (MavenScopeFilters (MavenScopeIncludeFilters)) +import Graphing (Graphing) +import Graphing qualified +import Path (Abs, Dir, File, Path, Rel, reldir, relfile, toFilePath, ()) +import Path.IO qualified as PIO +import Strategy.Maven (MavenProject (MavenProject), getDepsStatically, mkProject) import Strategy.Maven.Pom (MavenPackage (..), buildMavenPackage, interpolateProperties) +import Strategy.Maven.Pom.Closure (findProjects) import Strategy.Maven.Pom.PomFile +import Test.Effect (EffectStack, expectationFailure', itWithTempDir', shouldBe') import Test.Hspec +import Types (BuildTarget (BuildTarget), DependencyResults (dependencyGraph), DiscoveredProject (projectBuildTargets), FoundTargets (FoundTargets, ProjectWithoutTargets)) spec :: Spec spec = do @@ -50,3 +69,167 @@ spec = do } ) result `shouldBe` MavenPackage "MYGROUP" "MYARTIFACT" (Just "MYVERSION") + + -- The pom tactic builds its graph rooted at the project itself. These tests pin + -- that the project's own packages are removed from the reported graph and the + -- dependencies they declare are reported as direct, matching what the dynamic + -- tactics report for the same project. + describe "static analysis of a single-module project" $ do + itWithTempDir' "reports the declared dependencies as direct rather than the project itself" $ \dir -> do + writePom dir [relfile|pom.xml|] singleModulePom + onStaticGraph dir allTargets $ \graph -> + directNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + + itWithTempDir' "does not report the project itself as a dependency" $ \dir -> do + writePom dir [relfile|pom.xml|] singleModulePom + onStaticGraph dir allTargets $ \graph -> + vertexNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + + describe "static analysis of a multi-module project" $ do + -- commons-lang3 is declared by the root pom, so both submodules inherit it. + itWithTempDir' "reports the dependencies of every module as direct" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir allTargets $ \graph -> + directNames graph `shouldBe'` ["com.google.guava:guava", "junit:junit", "org.apache.commons:commons-lang3"] + + itWithTempDir' "does not report the root pom or any submodule as a dependency" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir allTargets $ \graph -> + vertexNames graph `shouldBe'` ["com.google.guava:guava", "junit:junit", "org.apache.commons:commons-lang3"] + + -- Submodule filtering deletes the root pom's node, which is the only direct + -- node in the graph the pom tactic builds. Unless the surviving submodules are + -- promoted to direct before being removed, this reports nothing as direct. + itWithTempDir' "reports the selected submodule's dependencies as direct" $ \dir -> do + createMultiModuleFixture dir + onStaticGraph dir (onlyTargets ["com.example:mod-a"]) $ \graph -> + directNames graph `shouldBe'` ["junit:junit", "org.apache.commons:commons-lang3"] + +directNames :: Graphing Dependency -> [Text] +directNames = sort . map dependencyName . Graphing.directList + +vertexNames :: Graphing Dependency -> [Text] +vertexNames = sort . map dependencyName . Graphing.vertexList + +-- | Statically analyze the maven project in @dir@ and hand its dependency graph +-- to @act@. @select@ picks which build targets to analyze. +onStaticGraph :: + Path Abs Dir -> + (MavenProject -> FoundTargets) -> + (Graphing Dependency -> EffectStack ()) -> + EffectStack () +onStaticGraph dir select act = do + closures <- findProjects dir + case closures of + [closure] -> do + let project = MavenProject closure + results <- runReader noScopeFilters $ getDepsStatically (select project) project + act $ dependencyGraph results + -- Each fixture is one project closure; a different count means discovery went + -- wrong, and saying so is more useful than an assertion about the graph. + _ -> expectationFailure' $ "expected one project closure, found " <> show (length closures) + where + noScopeFilters :: MavenScopeFilters + noScopeFilters = MavenScopeIncludeFilters mempty + +-- | Every submodule, as @fossa analyze@ selects them when given no target filter. +allTargets :: MavenProject -> FoundTargets +allTargets = projectBuildTargets . mkProject + +-- | Only the named submodules. +onlyTargets :: [Text] -> MavenProject -> FoundTargets +onlyTargets targets _ = + maybe ProjectWithoutTargets FoundTargets . nonEmpty . Set.fromList $ map BuildTarget targets + +writePom :: Path Abs Dir -> Path Rel File -> BS.ByteString -> EffectStack () +writePom dir name = sendIO . BS.writeFile (toFilePath (dir name)) + +-- | Writes a two-module project into @dir@: +-- +-- @ +-- pom.xml -- com.example:root:1.0, packaging=pom, declares commons-lang3 +-- mod-a/pom.xml -- declares junit +-- mod-b/pom.xml -- declares guava +-- @ +createMultiModuleFixture :: Path Abs Dir -> EffectStack () +createMultiModuleFixture dir = do + sendIO $ PIO.createDirIfMissing True (dir [reldir|mod-a|]) + sendIO $ PIO.createDirIfMissing True (dir [reldir|mod-b|]) + writePom dir [relfile|pom.xml|] multiModuleRootPom + writePom (dir [reldir|mod-a|]) [relfile|pom.xml|] (modulePom "mod-a" "junit" "junit" "4.13.2") + writePom (dir [reldir|mod-b|]) [relfile|pom.xml|] (modulePom "mod-b" "com.google.guava" "guava" "31.1-jre") + +-- | Concatenates lines of an XML document. +packLines :: [String] -> BS.ByteString +packLines = BS.concat . map BS.pack + +singleModulePom :: BS.ByteString +singleModulePom = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " com.example\n" + , " demo\n" + , " 1.0\n" + , " \n" + , " \n" + , " junit\n" + , " junit\n" + , " 4.13.2\n" + , " \n" + , " \n" + , " org.apache.commons\n" + , " commons-lang3\n" + , " 3.12.0\n" + , " \n" + , " \n" + , "\n" + ] + +multiModuleRootPom :: BS.ByteString +multiModuleRootPom = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " com.example\n" + , " root\n" + , " 1.0\n" + , " pom\n" + , " \n" + , " mod-a\n" + , " mod-b\n" + , " \n" + , " \n" + , " \n" + , " org.apache.commons\n" + , " commons-lang3\n" + , " 3.12.0\n" + , " \n" + , " \n" + , "\n" + ] + +modulePom :: String -> String -> String -> String -> BS.ByteString +modulePom artifactId depGroup depArtifact depVersion' = + packLines + [ "\n" + , "\n" + , " 4.0.0\n" + , " \n" + , " com.example\n" + , " root\n" + , " 1.0\n" + , " ../pom.xml\n" + , " \n" + , " " <> artifactId <> "\n" + , " \n" + , " \n" + , " " <> depGroup <> "\n" + , " " <> depArtifact <> "\n" + , " " <> depVersion' <> "\n" + , " \n" + , " \n" + , "\n" + ]