diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b20a63..4403456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,8 @@ jobs: fuzz ./internal/ecosystem/composer FuzzParseComposerLock fuzz ./internal/ecosystem/nuget FuzzParsePackagesLock fuzz ./internal/ecosystem/pypi FuzzExtractFromTar + fuzz ./internal/ecosystem/clojure FuzzParseProjectClj + fuzz ./internal/ecosystem/clojure FuzzParseDepsEdn bench: name: benchmarks compile and run diff --git a/cmd/depsnort/d161_zero_coverage_test.go b/cmd/depsnort/d161_zero_coverage_test.go index 82c9e96..b9d71a0 100644 --- a/cmd/depsnort/d161_zero_coverage_test.go +++ b/cmd/depsnort/d161_zero_coverage_test.go @@ -13,6 +13,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -21,18 +22,50 @@ const leinProject = `(defproject swytch.jepsen "0.1.0" [org.postgresql/postgresql "42.7.4"]]) ` -func TestClojureManifestIsIncompleteCoverageNotCleanPass(t *testing.T) { +// D-162 superseded the D-161 shape this test originally pinned: project.clj +// is now CLAIMED and RESOLVED by the clojure adapter, so the jepsen fixture +// scans as a real project (fully pinned, flat-by-format) instead of a gap. +// The D-161 gap behavior itself is still pinned below on pom.xml, a manifest +// that remains recognized-but-unread. +func TestClojureManifestNowResolves(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "project.clj"), []byte(leinProject), 0o644); err != nil { t.Fatal(err) } + out := filepath.Join(t.TempDir(), "out.json") + if code := run([]string{"scan", "-no-osv", "-no-registry", "-out", out, dir}); code != 0 { + t.Errorf("scan of a pinned project.clj repo: exit = %d, want 0", code) + } + // Not just a clean exit: the manifest must actually RESOLVE — the pinned + // JDBC driver present as a maven node. Without this the test would pass + // vacuously if the adapter were unregistered (nothing-to-scan is also 0). + raw, err := os.ReadFile(out) + if err != nil { + t.Fatalf("no verdict written — project.clj was not scanned: %v", err) + } + if !strings.Contains(string(raw), "pkg:maven/org.postgresql/postgresql@42.7.4") { + t.Error("resolved graph must contain the pinned postgresql coordinate") + } + // Fully pinned direct deps: nothing unresolved, and flat resolution is a + // format limitation (the Pipfile.lock precedent, D-24) — it discloses, it + // does not gate. + if code := run([]string{"scan", "-no-osv", "-no-registry", "-fail-on-incomplete", dir}); code != 0 { + t.Errorf("-fail-on-incomplete on a fully-pinned project.clj: exit = %d, want 0", code) + } +} + +func TestUnreadManifestIsIncompleteCoverageNotCleanPass(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } // Without the gate: disclosed, advisory-tier, still exit 0. if code := run([]string{"scan", "-no-osv", "-no-registry", dir}); code != 0 { t.Errorf("ungated scan of a gap-only repo: exit = %d, want 0 (disclosure, not a gate)", code) } // With the gate: the recognized-but-unread manifest is degraded coverage. if code := run([]string{"scan", "-no-osv", "-no-registry", "-fail-on-incomplete", dir}); code != 3 { - t.Errorf("-fail-on-incomplete on a project.clj repo: exit = %d, want 3 (zero-coverage repos must not pass the coverage gate)", code) + t.Errorf("-fail-on-incomplete on a pom.xml repo: exit = %d, want 3 (zero-coverage repos must not pass the coverage gate)", code) } } diff --git a/cmd/depsnort/gap.go b/cmd/depsnort/gap.go index f7c800b..6c11ffe 100644 --- a/cmd/depsnort/gap.go +++ b/cmd/depsnort/gap.go @@ -50,14 +50,10 @@ var gapManifestByName = map[string]string{ "pubspec.yaml": "dart", "Podfile": "cocoapods", "Package.swift": "swift", - // D-161: the swytchdb live scan walked a Leiningen project whose project.clj - // declared a JDBC driver carrying three real advisories, and exited 0 with - // "nothing to scan" — these two names were simply missing from this table - // while the D-59 machinery for them already existed. .clj is any Clojure - // source and .edn any EDN data, so both are exact-name per the dedication - // rule above. - "project.clj": "leiningen", // Clojure/Leiningen; resolves from Clojars + Maven Central - "deps.edn": "clojure", // Clojure tools.deps + // project.clj / deps.edn entered this table at D-161 and were promoted out + // at D-162: the clojure adapter now claims and RESOLVES them, so per the + // supported-manifests rule at the top of this table they must not also be + // listed here (a dependency-less one is legitimately empty, not a gap). // go.work is deliberately omitted: it is a workspace aggregator whose local // `use` modules are each scanned on their own, so disclosing the workspace // file as an unread gap would be a spurious note on an already-covered repo — diff --git a/cmd/depsnort/gap_test.go b/cmd/depsnort/gap_test.go index 89e8903..cbb56ed 100644 --- a/cmd/depsnort/gap_test.go +++ b/cmd/depsnort/gap_test.go @@ -42,10 +42,6 @@ func TestClassifyGapManifest(t *testing.T) { "flake.lock": "nix", "conan.lock": "conan", "deno.lock": "deno", - // D-161 — Clojure manifests, missed live on a Leiningen project whose - // JDBC driver carried three real advisories. - "project.clj": "leiningen", - "deps.edn": "clojure", } for name, wantEco := range gaps { if eco, ok := classifyGapManifest(name); !ok || eco != wantEco { @@ -57,6 +53,8 @@ func TestClassifyGapManifest(t *testing.T) { for _, name := range []string{ "package.json", "requirements.txt", "pyproject.toml", "composer.json", "go.mod", "packages.lock.json", "packages.config", "Gemfile", "README.md", + // Promoted out at D-162: the clojure adapter claims these. + "project.clj", "deps.edn", // Adapter-handled .lock files must be excluded from the hail-mary catch-all — // their directories are claimed and scanned, not disclosed as unknown gaps. "Cargo.lock", "composer.lock", "yarn.lock", "Gemfile.lock", "Pipfile.lock", "paket.lock", diff --git a/cmd/depsnort/main.go b/cmd/depsnort/main.go index 8f6cb12..e7246e2 100644 --- a/cmd/depsnort/main.go +++ b/cmd/depsnort/main.go @@ -40,6 +40,7 @@ import ( "ihbv.io/depsnort/internal/datasource/registry" "ihbv.io/depsnort/internal/ecosystem" "ihbv.io/depsnort/internal/ecosystem/cargo" + "ihbv.io/depsnort/internal/ecosystem/clojure" "ihbv.io/depsnort/internal/ecosystem/composer" "ihbv.io/depsnort/internal/ecosystem/gomod" "ihbv.io/depsnort/internal/ecosystem/instsurf" @@ -288,6 +289,7 @@ func adapterRegistry(offline bool, scanRoot ...string) *ecosystem.Registry { composer.New(), nuget.New(), gomod.New(), + clojure.New(), ) } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c7cab73..24504e8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6727,3 +6727,75 @@ ask the run's report itself judged it. Actually PARSING Maven/Clojars manifests dependencies against OSV's strong Maven data) remains ecosystem work of a different size, tracked as a backlog item, not smuggled in here. And `-require-project` asserts only that at least one project or recognized gap was discovered — it does not (and should not) judge how many, or which. + +## D-162 — a Clojure adapter: project.clj and deps.edn resolve to Maven coordinates + +**Trigger:** the swytchdb live scan left two of four repos unscanned, and D-161 closed only the honesty +half — `swytch.jepsen`'s `project.clj` went from a silent clean pass to a disclosed gap, but its JDBC +driver's three real advisories stayed invisible. D-161's own residual named the remaining work: actually +resolving the declared dependencies. This is that increment, scoped deliberately to the Clojure manifest +family (Leiningen `project.clj`, tools.deps `deps.edn`) — the demonstrated live miss — with `pom.xml` left +a disclosed gap and Homebrew untouched. + +**The identity decision that shapes everything else: nodes carry Ecosystem `maven`, not `clojure`.** The +manifest family is Clojure, but the packages live at Maven coordinates in Maven Central and Clojars, and +every downstream authority speaks Maven: OSV's ecosystem is "Maven" with `group:artifact` names, deps.dev's +system is `maven` with the same form, the PURL type is `pkg:maven`. The adapter's Name() is "clojure" (what +it reads); its nodes say where the packages actually live (what they are). A future pom.xml or Gradle +adapter emits into the same coordinate space and every mapping added here serves it unchanged. One switch +case each in the OSV client ("maven" → "Maven" — the default pass-through would have silently returned +zero advisories, the exact failure the mapping's own comment warns about) and in deps.dev (both +directions), and the existing `-expand` tier can deepen a Clojure tree from deps.dev with no new code. + +**What the adapter claims, and what it refuses to claim (D-24).** A direct dependency with a literal +version IS an observed pin — Leiningen and tools.deps fetch exactly the stated version for direct deps — +so `[org.postgresql/postgresql "42.7.4"]` enters the graph as fact, `AttrSourceClass` registry. Everything +short of that literal is disclosed, never guessed: a range (`"[1.0,2.0)"`), `RELEASE`/`LATEST`, or a +build-time symbol version is declared-but-unresolved; a `:git/url` or `:local/root` coordinate carries +`SourceGit`/`SourcePath` with its ref — no registry coordinate, no advisory coverage, counted like any +other non-registry source; an entry the reader cannot parse at all becomes a placeholder in +`AttrUnresolved` (a shape we cannot name degrades coverage; it does not vanish). Neither format records +the transitive closure, so every root sets `AttrFlatResolution` — the Pipfile.lock precedent: a limitation +of the format disclosed, not a scan defect, and not a gate. Profile `:dependencies` and alias +`:extra-deps`/`:replace-deps` are read — a dev-profile dependency is fetched from the same registries and +is the same surface. Lein bare symbols map per Leiningen's own convention (`[postgresql "42.7.4"]` is +`postgresql:postgresql`). + +**The reader is a scanner, not an EDN parser.** depSNORT is zero-dependency, and these manifests need +three structural facts: where a `;` comment ends (respecting strings and `\;` character literals), where a +string literal ends, and where a balanced form ends. `#_` reader-discards are skipped as the +commented-out declarations they are; a coordinate symbol must match the Maven id shape or the entry is +unparsed rather than a mangled token entering the graph; a dependency spelled inside a comment or a +docstring never becomes a node (the D-153 lesson, applied at parse time rather than patched in later). + +**Promotion out of the gap tables.** `project.clj` and `deps.edn` leave `gapManifestByName` per that +table's own contract — a supported manifest is claimed by Detect and must not also be listed, or a +legitimately dependency-less one would read as a gap. Detect claims only a manifest that declares +something (the Gemfile bar, OPU-16). D-161's CLI regression is superseded knowingly: the jepsen fixture +now RESOLVES (asserted non-vacuously — the verdict must contain the postgresql PURL, so an unregistered +adapter cannot pass on exit codes alone), a fully-pinned project.clj passes `-fail-on-incomplete` (flat +discloses, nothing gates), and the D-161 gap behavior itself stays pinned on `pom.xml`, which remains +recognized-but-unread. + +**Validation:** two-sided unit tests (pins resolve with correct PURLs/coordinates; ranges, meta-versions, +symbol versions, and unparsed shapes disclose; discarded and comment/string-embedded "dependencies" never +resolve; git/local classify with refs); two fuzz targets over the untrusted-input readers +(`FuzzParseProjectClj`, `FuzzParseDepsEdn` — never panic, never stall, no coordinate escapes the symbol +shape), seeded, run 20s locally and added to the CI fuzz roster; mutation-checked at both load-bearing +wires (reverting the OSV mapping fails the wire-format test; reverting the registration fails the CLI +resolution test); full suite green (35 packages), `-race` clean, gofmt/vet silent. Live-fired through the +built binary on the jepsen shape: discovered, resolved to four `pkg:maven` nodes, and — with `api.osv.dev` +egress-blocked in the landing environment — the scan disclosed `degraded data source(s): osv … NOT an +all-clear` rather than passing quietly, which is itself the D-24 machinery working for the new ecosystem. +The OSV round-trip that egress denied is pinned at the wire instead (`maven_test.go`): the request body +must carry `"ecosystem":"Maven"` and the verbatim `group:artifact` name. + +Residual limitations: transitive coverage exists only through the `-expand` tier (asserted/presumed, +labelled, never gating) — there is no committed lockfile format to read. No Maven Central/Clojars registry +metadata source exists yet, so VC-004/VC-005/VC-011/VC-012 do not fire on maven nodes — the same shape as +any ecosystem before its registry source landed, and the natural next increment. `:plugins` and +`:managed-dependencies` are deliberately unread (a Leiningen-process surface and a version-authority +question, each its own decision), as are `pom.xml` (still a disclosed gap; property interpolation and +parent chains are real work, not a name-table entry) and Homebrew formulae (unchanged from D-161's +reasoning). And Maven version-range resolution is not implemented anywhere in this tool — a range is +disclosed as unresolved, never evaluated. diff --git a/internal/datasource/depsdev/depsdev.go b/internal/datasource/depsdev/depsdev.go index 58bcb21..39b588b 100644 --- a/internal/datasource/depsdev/depsdev.go +++ b/internal/datasource/depsdev/depsdev.go @@ -63,6 +63,11 @@ func system(ecosystem string) string { return "nuget" case "gem": return "rubygems" + case "maven": + // Maven-coordinate nodes (the clojure adapter's project.clj / + // deps.edn pins, D-162): deps.dev's maven system uses the same + // "group:artifact" name form the nodes already carry. + return "maven" default: return "" } @@ -195,6 +200,8 @@ func ecosystemOf(system string) string { return "nuget" case "RUBYGEMS": return "gem" + case "MAVEN": + return "maven" default: return lower(system) } diff --git a/internal/datasource/osv/maven_test.go b/internal/datasource/osv/maven_test.go new file mode 100644 index 0000000..6d00023 --- /dev/null +++ b/internal/datasource/osv/maven_test.go @@ -0,0 +1,49 @@ +package osv + +import ( + "context" + "strings" + "testing" + "time" + + "ihbv.io/depsnort/internal/datasource" +) + +// D-162: a maven-coordinate node (the clojure adapter's project.clj / +// deps.edn pins) must reach OSV as ecosystem "Maven" with the group:artifact +// name intact — OSV's documented Maven spelling. Passing the internal id +// through unmapped would silently return zero advisories, which on the +// swytch.jepsen shape means three real postgresql advisories invisible again. +// The live api.osv.dev round-trip was egress-blocked in the environment this +// landed from, so the wire format is pinned here instead. +func TestMavenCoordReachesOSVAsMaven(t *testing.T) { + resp := `{"results":[ + {"vulns":[{"id":"GHSA-hq9p-pm7w-8p54","modified":"2026-01-01T00:00:00Z"}]} + ]}` + doer := &fakeDoer{body: resp} + fixed := time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC) + cache := datasource.NewCache(t.TempDir(), 24*time.Hour) + cache.Now = func() time.Time { return fixed } + c := &Client{ + HTTP: doer, + Cache: cache, + Endpoint: "http://test.invalid", + Now: func() time.Time { return fixed }, + } + + got, err := c.QueryBatch(context.Background(), []datasource.Coord{ + {Ecosystem: "maven", Name: "org.postgresql:postgresql", Version: "42.7.4"}, + }) + if err != nil { + t.Fatalf("QueryBatch: %v", err) + } + if !strings.Contains(doer.lastBody, `"ecosystem":"Maven"`) { + t.Errorf("request must carry OSV's Maven spelling, got body: %s", doer.lastBody) + } + if !strings.Contains(doer.lastBody, `"name":"org.postgresql:postgresql"`) { + t.Errorf("request must carry the group:artifact coordinate verbatim, got body: %s", doer.lastBody) + } + if len(got) != 1 || len(got[0]) != 1 || got[0][0].ID != "GHSA-hq9p-pm7w-8p54" { + t.Errorf("advisory must round-trip onto the maven coord, got %+v", got) + } +} diff --git a/internal/datasource/osv/osv.go b/internal/datasource/osv/osv.go index 69f5b2b..652c766 100644 --- a/internal/datasource/osv/osv.go +++ b/internal/datasource/osv/osv.go @@ -81,6 +81,11 @@ func ecosystemName(eco string) string { return "NuGet" case "gomod": return "Go" + case "maven": + // Maven coordinates regardless of manifest family: the clojure + // adapter's project.clj / deps.edn nodes resolve here (D-162). Node + // names are already "group:artifact", OSV's Maven package spelling. + return "Maven" default: return eco } diff --git a/internal/ecosystem/clojure/clojure.go b/internal/ecosystem/clojure/clojure.go new file mode 100644 index 0000000..426d9c8 --- /dev/null +++ b/internal/ecosystem/clojure/clojure.go @@ -0,0 +1,268 @@ +// Package clojure is the Clojure ecosystem adapter (D-162). It statically +// parses Leiningen project.clj and tools.deps deps.edn manifests and resolves +// their DIRECT dependencies to Maven coordinates — the coordinate system both +// tools share, and the one OSV indexes ("Maven"). Nodes therefore carry +// Ecosystem "maven" and pkg:maven PURLs: the manifest family is Clojure, but +// the packages live in Maven/Clojars registries and advisory data speaks +// Maven. +// +// What this adapter claims and what it does not (D-24 honesty): +// - A direct dependency with a literal version IS an observed pin — both +// tools fetch exactly the stated version for direct deps — so those nodes +// enter the graph as facts, not presumptions. +// - Neither manifest format records the transitive closure, so every root +// resolves flat (AttrFlatResolution): a format limitation, disclosed, not +// a scan defect. The -expand tier can deepen it from deps.dev. +// - A version that is not a literal pin (a range, RELEASE/LATEST, a symbol +// evaluated at build time) is declared-but-unresolved, never guessed. +// - Git and :local/root coordinates are recorded with their source class — +// no registry coordinate, no advisory coverage, disclosed like any other +// non-registry source. +// +// Maven dependency FETCH executes nothing — resolution downloads jars, and no +// install-time hook runs on the consumer's machine (build-time plugins are a +// different surface, out of scope here) — so this adapter's empty install +// surface is a fact about the ecosystem, not an extraction gap. +// +// Nothing here installs or executes anything (Decision D-04). +package clojure + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "ihbv.io/depsnort/internal/graph" + "ihbv.io/depsnort/internal/purl" +) + +// Adapter implements ecosystem.Adapter for Clojure (Leiningen + tools.deps). +type Adapter struct{} + +// New returns a Clojure adapter. +func New() *Adapter { return &Adapter{} } + +// Name implements ecosystem.Adapter. +func (*Adapter) Name() string { return "clojure" } + +const ( + projectCljName = "project.clj" + depsEdnName = "deps.edn" +) + +// Detect implements ecosystem.Adapter. A project.clj or deps.edn that declares +// at least one dependency claims the directory — the same declares-something +// bar the Gemfile path uses (OPU-16), so a dependency-less manifest stays +// legitimately unclaimed rather than erroring through Resolve. +func (*Adapter) Detect(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if info.IsDir() { + return projectCljDeclares(filepath.Join(path, projectCljName)) || + depsEdnDeclares(filepath.Join(path, depsEdnName)) + } + switch filepath.Base(path) { + case projectCljName: + return projectCljDeclares(path) + case depsEdnName: + return depsEdnDeclares(path) + } + return false +} + +func projectCljDeclares(p string) bool { + raw, err := os.ReadFile(p) + if err != nil { + return false + } + deps, unparsed, _, _ := parseProjectClj(string(raw)) + return len(deps) > 0 || unparsed > 0 +} + +func depsEdnDeclares(p string) bool { + raw, err := os.ReadFile(p) + if err != nil { + return false + } + deps, unparsed := parseDepsEdn(string(raw)) + return len(deps) > 0 || unparsed > 0 +} + +// Resolve implements ecosystem.Adapter. When a directory carries both +// manifests, both are read into one graph under one root — they describe the +// same project's fetches, and dropping either would be the silent-coverage +// shape D-59 exists to prevent. +func (*Adapter) Resolve(path string) (*graph.Graph, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("clojure: %w", err) + } + + type manifest struct { + name string + raw string + } + var manifests []manifest + read := func(p string) error { + raw, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("clojure: reading %s: %w", filepath.Base(p), err) + } + manifests = append(manifests, manifest{name: filepath.Base(p), raw: string(raw)}) + return nil + } + dir := path + if info.IsDir() { + for _, name := range []string{projectCljName, depsEdnName} { + p := filepath.Join(path, name) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + if err := read(p); err != nil { + return nil, err + } + } + } + } else { + dir = filepath.Dir(path) + if err := read(path); err != nil { + return nil, err + } + } + if len(manifests) == 0 { + return nil, fmt.Errorf("clojure: no %s or %s at %s", projectCljName, depsEdnName, path) + } + + var ( + deps []dep + unparsed int + rootName, rootVersion string + sources []string + ) + for _, m := range manifests { + switch m.name { + case projectCljName: + d, u, n, v := parseProjectClj(m.raw) + deps, unparsed = append(deps, d...), unparsed+u + rootName, rootVersion = n, v + case depsEdnName: + d, u := parseDepsEdn(m.raw) + deps, unparsed = append(deps, d...), unparsed+u + } + sources = append(sources, m.name) + } + + g := graph.New() + root := rootNode(g, dir, rootName, rootVersion) + root.Attr["clojure.source"] = strings.Join(sources, ",") + + // Dedupe on the full coordinate@version: the same dep declared in a + // profile and the top level is one fact; the same coordinate at two + // versions is two facts, both kept. + var ( + declared []graph.DeclaredDep + unresolved []string + seenNode = map[string]bool{} + seenDecl = map[string]bool{} + ) + sort.Slice(deps, func(i, j int) bool { + if deps[i].coordinate() != deps[j].coordinate() { + return deps[i].coordinate() < deps[j].coordinate() + } + return deps[i].version < deps[j].version + }) + for _, d := range deps { + if !seenDecl[d.coordinate()] { + seenDecl[d.coordinate()] = true + declared = append(declared, graph.DeclaredDep{Name: d.coordinate(), Constraint: firstNonEmpty(d.version, d.constraint)}) + } + if d.version == "" && d.source == graph.SourceRegistry { + // Declared with no pin this reader may claim: disclosed, never + // presumed here (the expansion tier presumes, and labels it). + if !contains(unresolved, d.coordinate()) { + unresolved = append(unresolved, d.coordinate()) + } + continue + } + id := purl.NewMaven(d.group, d.artifact, d.version).String() + if seenNode[id] { + continue + } + seenNode[id] = true + n := &graph.Node{ + ID: id, + Ecosystem: "maven", + Name: d.coordinate(), + Version: d.version, + Direct: true, + Depth: 1, + Attr: map[string]string{graph.AttrSourceClass: d.source}, + } + if d.ref != "" { + n.Attr[graph.AttrSourceRef] = d.ref + } + g.AddNode(n) + g.AddEdge(root.ID, id, graph.EdgeDependsOn) + } + for i := 0; i < unparsed; i++ { + // An entry the reader could not read at all still degrades coverage — + // a shape we cannot name gets a placeholder, not silence. + unresolved = append(unresolved, fmt.Sprintf("unparsed-entry#%d", i+1)) + } + + root.Attr[graph.AttrDeclaredDeps] = graph.EncodeDeclaredDeps(declared) + if len(unresolved) > 0 { + sort.Strings(unresolved) + root.Attr[graph.AttrUnresolved] = strings.Join(unresolved, ",") + root.Attr[graph.AttrUnresolvedCount] = fmt.Sprintf("%d", len(unresolved)) + } + // Neither manifest format records inter-package relationships: one layer + // deep by construction, a property of the format, disclosed as such. + root.Attr[graph.AttrFlatResolution] = "maven" + return g, nil +} + +// rootNode builds the project root, named from defproject when project.clj +// states an identity, else from the directory. +func rootNode(g *graph.Graph, dir, name, version string) *graph.Node { + if name == "" { + name = filepath.Base(filepath.Clean(dir)) + if name == "." || name == "" || name == string(filepath.Separator) { + name = "clojure-project" + } + } + if version == "" { + version = "0.0.0" + } + group, artifact, ok := splitCoord(name) + if !ok { + group, artifact = "", "clojure-project" + } else if group == artifact && !strings.Contains(name, "/") { + group = "" // a bare project name is not a group:artifact claim + } + id := purl.NewMaven(group, artifact, version).String() + n := g.AddNode(&graph.Node{ + ID: id, Ecosystem: "maven", Name: name, Version: version, Depth: 0, + Attr: map[string]string{}, + }) + g.MarkRoot(id) + return n +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/internal/ecosystem/clojure/clojure_test.go b/internal/ecosystem/clojure/clojure_test.go new file mode 100644 index 0000000..9ed4703 --- /dev/null +++ b/internal/ecosystem/clojure/clojure_test.go @@ -0,0 +1,186 @@ +package clojure + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "ihbv.io/depsnort/internal/graph" +) + +// The jepsen shape that motivated D-162: literal pins must resolve as observed +// direct dependencies with Maven coordinates. +const jepsenProjectClj = `(defproject swytch.jepsen "0.1.0" + :description "Jepsen harness" ; not a dependency + :dependencies [[org.clojure/clojure "1.12.4"] + [org.postgresql/postgresql "42.7.4"] + [com.taoensso/carmine "3.5.0" :exclusions [org.clojure/clojure]] + [postgresql-bare "9.9.9"]]) +` + +func writeManifest(t *testing.T, name, content string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func resolve(t *testing.T, dir string) *graph.Graph { + t.Helper() + a := New() + if !a.Detect(dir) { + t.Fatalf("Detect(%s) = false, want true", dir) + } + g, err := a.Resolve(dir) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + return g +} + +func nodeByID(g *graph.Graph, id string) *graph.Node { return g.Nodes[id] } + +func TestProjectCljResolvesLiteralPins(t *testing.T) { + g := resolve(t, writeManifest(t, "project.clj", jepsenProjectClj)) + + want := map[string]string{ + "pkg:maven/org.postgresql/postgresql@42.7.4": "org.postgresql:postgresql", + "pkg:maven/org.clojure/clojure@1.12.4": "org.clojure:clojure", + "pkg:maven/com.taoensso/carmine@3.5.0": "com.taoensso:carmine", + // Bare symbol: group == artifact, Leiningen's own convention. + "pkg:maven/postgresql-bare/postgresql-bare@9.9.9": "postgresql-bare:postgresql-bare", + } + for id, name := range want { + n := nodeByID(g, id) + if n == nil { + t.Errorf("missing node %s", id) + continue + } + if n.Ecosystem != "maven" || n.Name != name || !n.Direct || n.Depth != 1 { + t.Errorf("node %s = eco %q name %q direct %v depth %d", id, n.Ecosystem, n.Name, n.Direct, n.Depth) + } + if n.Attr[graph.AttrSourceClass] != graph.SourceRegistry { + t.Errorf("node %s source class = %q, want registry", id, n.Attr[graph.AttrSourceClass]) + } + } + + // Root identity comes from defproject; flat resolution is disclosed. + root := nodeByID(g, "pkg:maven/swytch.jepsen@0.1.0") + if root == nil { + t.Fatalf("missing defproject-named root; nodes: %v", ids(g)) + } + if root.Attr[graph.AttrFlatResolution] != "maven" { + t.Error("flat resolution must be disclosed: the formats record no transitive structure") + } + if root.Attr[graph.AttrUnresolved] != "" { + t.Errorf("fully-pinned manifest must have nothing unresolved, got %q", root.Attr[graph.AttrUnresolved]) + } +} + +func TestProjectCljDisclosesWhatItCannotPin(t *testing.T) { + src := `(defproject x "1" + :dependencies [[good/dep "1.0.0"] + [ranged/dep "[1.0,2.0)"] ; a range is not a pin + [meta/dep "RELEASE"] ; meta-version + [symver/dep my-version] ; build-time symbol + #_[discarded/dep "6.6.6"] ; reader-discarded: not declared + [org.clojure/clojure "1.12.4"]]) +` + g := resolve(t, writeManifest(t, "project.clj", src)) + + if n := nodeByID(g, "pkg:maven/good/dep@1.0.0"); n == nil { + t.Error("literal pin beside unresolvable entries must still resolve") + } + if n := nodeByID(g, "pkg:maven/discarded/dep@6.6.6"); n != nil { + t.Error("a #_ discarded entry is not a declaration and must not resolve") + } + var root *graph.Node + for _, id := range g.Roots { + root = g.Nodes[id] + } + unres := root.Attr[graph.AttrUnresolved] + for _, want := range []string{"ranged:dep", "meta:dep", "symver:dep"} { + if !strings.Contains(unres, want) { + t.Errorf("unresolved %q must include %s", unres, want) + } + } + if strings.Contains(unres, "discarded") { + t.Errorf("discarded entry leaked into unresolved: %q", unres) + } +} + +func TestDepsEdnResolvesAndClassifiesSources(t *testing.T) { + src := `{:paths ["src"] + :deps {org.postgresql/postgresql {:mvn/version "42.7.4"} + io.github.someone/gitlib {:git/url "https://github.com/someone/gitlib" :git/sha "abc123"} + local/thing {:local/root "../thing"}} + :aliases {:test {:extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}}}}} +` + g := resolve(t, writeManifest(t, "deps.edn", src)) + + if n := nodeByID(g, "pkg:maven/org.postgresql/postgresql@42.7.4"); n == nil { + t.Error(":mvn/version literal must resolve") + } + // Alias extra-deps are the same fetch surface. + if n := nodeByID(g, "pkg:maven/org.clojure/test.check@1.1.1"); n == nil { + t.Error(":aliases :extra-deps must be read") + } + // Git and local coordinates carry their source class — disclosed, not + // laundered into registry coordinates. + var git, local *graph.Node + for _, n := range g.SortedNodes() { + switch n.Attr[graph.AttrSourceClass] { + case graph.SourceGit: + git = n + case graph.SourcePath: + local = n + } + } + if git == nil || git.Attr[graph.AttrSourceRef] != "https://github.com/someone/gitlib" { + t.Errorf("git coordinate must carry SourceGit + ref, got %+v", git) + } + if local == nil || local.Attr[graph.AttrSourceRef] != "../thing" { + t.Errorf(":local/root coordinate must carry SourcePath + ref, got %+v", local) + } +} + +func TestDetectRefusesWhatItShould(t *testing.T) { + // A dependency-less project.clj does not claim the directory (the Gemfile + // declares-something bar): legitimately nothing to scan, not an error. + dir := writeManifest(t, "project.clj", `(defproject empty "1.0" :description "no deps")`) + if New().Detect(dir) { + t.Error("a dependency-less project.clj must not claim the directory") + } + // An unrelated directory is not claimed. + if New().Detect(t.TempDir()) { + t.Error("an empty directory must not be claimed") + } +} + +func TestCommentsAndStringsAreNotDependencies(t *testing.T) { + src := `(defproject x "1" + ;; :dependencies [[evil/from-comment "6.6.6"]] + :description "docs say :dependencies [[evil/from-string \"6.6.6\"]] here" + :dependencies [[real/dep "1.0.0"]]) +` + g := resolve(t, writeManifest(t, "project.clj", src)) + for _, n := range g.SortedNodes() { + if strings.Contains(n.ID, "evil") { + t.Errorf("dependency manufactured from comment or string: %s", n.ID) + } + } + if n := nodeByID(g, "pkg:maven/real/dep@1.0.0"); n == nil { + t.Error("the real dependency must still resolve") + } +} + +func ids(g *graph.Graph) []string { + var out []string + for _, n := range g.SortedNodes() { + out = append(out, n.ID) + } + return out +} diff --git a/internal/ecosystem/clojure/fuzz_test.go b/internal/ecosystem/clojure/fuzz_test.go new file mode 100644 index 0000000..5184aa9 --- /dev/null +++ b/internal/ecosystem/clojure/fuzz_test.go @@ -0,0 +1,46 @@ +package clojure + +import "testing" + +// FuzzParseProjectClj drives arbitrary bytes at the project.clj reader — a +// hand-rolled bracket/string scanner over untrusted repo content, the same +// slicing-bug habitat every other manifest parser here fuzzes (D-33). The +// invariants: never panic, never stall, and never emit a dependency whose +// coordinate fails the symbol shape the reader claims to enforce. +func FuzzParseProjectClj(f *testing.F) { + f.Add([]byte(`(defproject x "1" :dependencies [[a/b "1.0"]])`)) + f.Add([]byte(`(defproject x "1" :dependencies [[a/b "1.0" :exclusions [c/d]] #_[e "2"]])`)) + f.Add([]byte(`:dependencies [[unclosed "1.0"`)) + f.Add([]byte(`:dependencies [ ; comment ]\n[a "1"]]`)) + f.Add([]byte(`:dependencies [["not-a-sym" "1"] [a/b ranged]]`)) + f.Add([]byte("\\; \\[ \\\" :dependencies [[a \"1\"]]")) + f.Add([]byte("")) + + f.Fuzz(func(t *testing.T, raw []byte) { + deps, _, _, _ := parseProjectClj(string(raw)) + for _, d := range deps { + if !mavenSymRe.MatchString(d.group) || !mavenSymRe.MatchString(d.artifact) { + t.Fatalf("coordinate escaped the symbol shape: %q:%q", d.group, d.artifact) + } + } + }) +} + +// FuzzParseDepsEdn does the same for the deps.edn map reader. +func FuzzParseDepsEdn(f *testing.F) { + f.Add([]byte(`{:deps {a/b {:mvn/version "1.0"}}}`)) + f.Add([]byte(`{:deps {a/b {:git/url "https://x" :git/sha "s"} c {:local/root "../c"}}}`)) + f.Add([]byte(`{:aliases {:t {:extra-deps {x/y {:mvn/version "2"}}}}}`)) + f.Add([]byte(`{:deps {unclosed {`)) + f.Add([]byte(`{:deps {"str-key" {:mvn/version "1"} #_a/b {:mvn/version "9"}}}`)) + f.Add([]byte("")) + + f.Fuzz(func(t *testing.T, raw []byte) { + deps, _ := parseDepsEdn(string(raw)) + for _, d := range deps { + if !mavenSymRe.MatchString(d.group) || !mavenSymRe.MatchString(d.artifact) { + t.Fatalf("coordinate escaped the symbol shape: %q:%q", d.group, d.artifact) + } + } + }) +} diff --git a/internal/ecosystem/clojure/parse.go b/internal/ecosystem/clojure/parse.go new file mode 100644 index 0000000..10d0b78 --- /dev/null +++ b/internal/ecosystem/clojure/parse.go @@ -0,0 +1,315 @@ +package clojure + +import ( + "fmt" + "regexp" + "strings" + + "ihbv.io/depsnort/internal/graph" +) + +// dep is one declared dependency as the manifest states it. +type dep struct { + group string // Maven groupId + artifact string // Maven artifactId + version string // exact literal pin; "" when the manifest names no usable pin + // constraint records what the manifest DID say when version is "" — a + // range string, a symbol, a truncated form — so the declared-deps attr can + // carry the fact without depSNORT pretending to have resolved it. + constraint string + source string // graph.SourceRegistry / SourceGit / SourcePath + ref string // git URL or local path, for AttrSourceRef +} + +func (d dep) coordinate() string { return d.group + ":" + d.artifact } + +// mavenSymRe is the shape of a Maven-coordinate symbol as Leiningen and +// tools.deps write it: `artifact` or `group/artifact`, each side drawn from +// the characters Maven ids actually use. Anything else (macro output, a +// reader conditional, line noise) is NOT read as a name — the entry is +// disclosed as unparsed instead of a mangled token entering the graph. +var mavenSymRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$`) + +// splitCoord maps a Clojure dependency symbol to Maven group:artifact. +// `group/artifact` is explicit; a bare `artifact` means group == artifact — +// Leiningen's own convention ([postgresql "42.7.4"] is +// postgresql:postgresql). +func splitCoord(sym string) (group, artifact string, ok bool) { + if !mavenSymRe.MatchString(sym) { + return "", "", false + } + if i := strings.IndexByte(sym, '/'); i > 0 { + return sym[:i], sym[i+1:], true + } + return sym, sym, true +} + +// literalPin reports whether a version string is an exact Maven version this +// tool may claim as observed. Ranges ("[1.0,2.0)"), the RELEASE/LATEST +// meta-versions, and anything with whitespace are declarations, not pins. +func literalPin(v string) bool { + if v == "" || strings.ContainsAny(v, " \t\n[](),") { + return false + } + switch v { + case "RELEASE", "LATEST": + return false + } + return true +} + +// skipDiscard advances past a `#_` reader-discard and the single form it +// discards, so a commented-out dependency is neither scanned nor disclosed. +func skipDiscard(s string, i int) int { + i += 2 // past #_ + i = skipWS(s, i) + if i >= len(s) { + return i + } + switch s[i] { + case '[', '{', '(': + return scanBalanced(s, i) + case '"': + return scanCljString(s, i) + default: + _, i = readSymbol(s, i) + return i + } +} + +// parseProjectClj reads a Leiningen project.clj: every `:dependencies` vector +// (profiles included — a dev-profile dependency is fetched from the same +// registries and is the same supply-chain surface), each entry +// `[group/artifact "version" & opts]`. It also returns the defproject +// name/version for the root when stated. `:plugins` and `:managed-dependencies` +// are deliberately not read yet (disclosed in the DECISIONS entry, not +// silently dropped: plugins are a Leiningen-process surface, managed deps a +// version-authority question, each its own increment). +func parseProjectClj(src string) (deps []dep, unparsed int, rootName, rootVersion string) { + s := stripCljComments(src) + + if loc := defprojectRe.FindStringSubmatchIndex(s); loc != nil { + i := skipWS(s, loc[1]) + sym, j := readSymbol(s, i) + if mavenSymRe.MatchString(sym) { + rootName = sym + j = skipWS(s, j) + if j < len(s) && s[j] == '"' { + if v, _ := readString(s, j); literalPin(v) { + rootVersion = v + } + } + } + } + + for _, loc := range dependenciesKeyRe.FindAllStringIndex(s, -1) { + i := skipWS(s, loc[1]) + if i >= len(s) || s[i] != '[' { + continue + } + end := scanBalanced(s, i) + body := s[i+1 : max(i+1, end-1)] + d, u := parseDepVector(body) + deps = append(deps, d...) + unparsed += u + } + return deps, unparsed, rootName, rootVersion +} + +var ( + defprojectRe = regexp.MustCompile(`\(\s*defproject\s`) + // The :dependencies keyword as a key position: preceded by start, + // whitespace, or an opening bracket, so :managed-dependencies and + // :plugin-dependencies never match. + dependenciesKeyRe = regexp.MustCompile(`(?:^|[\s\[{(,]):dependencies\b`) + depsEdnKeyRe = regexp.MustCompile(`(?:^|[\s\[{(,]):(deps|extra-deps|replace-deps)\b`) +) + +// parseDepVector reads the inside of one :dependencies vector: a sequence of +// `[sym "version" ...]` entries. +func parseDepVector(body string) (deps []dep, unparsed int) { + i := 0 + for i < len(body) { + i = skipWS(body, i) + if i >= len(body) { + break + } + if strings.HasPrefix(body[i:], "#_") { + i = skipDiscard(body, i) + continue + } + if body[i] != '[' { + // Not an entry vector (stray metadata, a reader conditional): + // step over one form and count it — a shape this reader does not + // understand degrades coverage, it does not vanish. + i = stepForm(body, i) + unparsed++ + continue + } + end := scanBalanced(body, i) + entry := body[i+1 : max(i+1, end-1)] + i = end + d, ok := parseDepEntry(entry) + if !ok { + unparsed++ + continue + } + deps = append(deps, d) + } + return deps, unparsed +} + +// parseDepEntry reads one `[sym "version" & opts]` entry. +func parseDepEntry(entry string) (dep, bool) { + i := skipWS(entry, 0) + sym, i := readSymbol(entry, i) + group, artifact, ok := splitCoord(sym) + if !ok { + return dep{}, false + } + d := dep{group: group, artifact: artifact, source: graph.SourceRegistry} + i = skipWS(entry, i) + if i < len(entry) && entry[i] == '"' { + v, _ := readString(entry, i) + if literalPin(v) { + d.version = v + } else { + d.constraint = v + } + return d, true + } + // No string version: a symbol/expression version ([foo my-version]) is a + // declaration whose pin lives outside this reader's static reach. + if i < len(entry) { + tok, _ := readSymbol(entry, i) + d.constraint = tok + } + return d, true +} + +// stepForm advances past one form of any shape. +func stepForm(s string, i int) int { + switch s[i] { + case '[', '{', '(': + return scanBalanced(s, i) + case '"': + return scanCljString(s, i) + default: + _, j := readSymbol(s, i) + if j == i { + return i + 1 // a stray closer or unknown byte: never stall + } + return j + } +} + +// parseDepsEdn reads a tools.deps deps.edn: the `:deps` map plus every +// `:extra-deps` and `:replace-deps` map under :aliases (alias deps are fetched +// from the same registries — same surface, same reasoning as lein profiles). +// Each entry is `sym {:mvn/version "v"}` (registry), `sym {:git/url ...}` +// (git), or `sym {:local/root ...}` (path). +func parseDepsEdn(src string) (deps []dep, unparsed int) { + s := stripCljComments(src) + for _, loc := range depsEdnKeyRe.FindAllStringSubmatchIndex(s, -1) { + i := skipWS(s, loc[1]) + if i >= len(s) || s[i] != '{' { + continue + } + end := scanBalanced(s, i) + body := s[i+1 : max(i+1, end-1)] + d, u := parseDepsEdnMap(body) + deps = append(deps, d...) + unparsed += u + } + return deps, unparsed +} + +func parseDepsEdnMap(body string) (deps []dep, unparsed int) { + i := 0 + for i < len(body) { + i = skipWS(body, i) + if i >= len(body) { + break + } + if strings.HasPrefix(body[i:], "#_") { + i = skipDiscard(body, i) + continue + } + sym, j := readSymbol(body, i) + if j == i { // not a symbol (stray bracket or string): step and count + i = stepForm(body, i) + unparsed++ + continue + } + i = skipWS(body, j) + group, artifact, symOK := splitCoord(sym) + if i >= len(body) || body[i] != '{' { + // A key with no coordinate map — step over whatever value form is + // there and count the entry as unparsed. + if i < len(body) { + i = stepForm(body, i) + } + unparsed++ + continue + } + end := scanBalanced(body, i) + coord := body[i+1 : max(i+1, end-1)] + i = end + if !symOK { + unparsed++ + continue + } + d := dep{group: group, artifact: artifact} + fillCoordMap(&d, coord) + deps = append(deps, d) + } + return deps, unparsed +} + +// fillCoordMap reads a deps.edn coordinate map body into d. +func fillCoordMap(d *dep, coord string) { + d.source = graph.SourceRegistry + if v := ednStringValue(coord, ":mvn/version"); v != "" { + if literalPin(v) { + d.version = v + } else { + d.constraint = v + } + return + } + if u := ednStringValue(coord, ":git/url"); u != "" { + d.source, d.ref = graph.SourceGit, u + return + } + if strings.Contains(coord, ":git/sha") || strings.Contains(coord, ":git/tag") { + d.source = graph.SourceGit + return + } + if p := ednStringValue(coord, ":local/root"); p != "" { + d.source, d.ref = graph.SourcePath, p + return + } + // A coordinate shape this reader does not know: declared, unresolved. + d.constraint = strings.TrimSpace(truncate(coord, 40)) +} + +// ednStringValue finds `key "value"` inside a coordinate map body. +func ednStringValue(body, key string) string { + idx := strings.Index(body, key) + if idx < 0 { + return "" + } + i := skipWS(body, idx+len(key)) + if i >= len(body) || body[i] != '"' { + return "" + } + v, _ := readString(body, i) + return v +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return fmt.Sprintf("%s…", s[:n]) +} diff --git a/internal/ecosystem/clojure/scan.go b/internal/ecosystem/clojure/scan.go new file mode 100644 index 0000000..63716e3 --- /dev/null +++ b/internal/ecosystem/clojure/scan.go @@ -0,0 +1,128 @@ +package clojure + +import "strings" + +// Minimal Clojure-source scanning utilities shared by the project.clj and +// deps.edn readers. This is NOT an EDN parser — depSNORT is zero-dependency +// and these two manifests need only three structural facts: where a line +// comment ends, where a string literal ends, and where a balanced []/{}/( ) +// form ends. Everything subtler (tagged literals, metadata, namespaced maps) +// is treated as opaque text; a dependency entry the scanner cannot read with +// confidence is disclosed as unresolved, never guessed (D-24 discipline). + +// stripCljComments removes `;` line comments, respecting string literals so a +// semicolon inside a version or URL string is not read as a comment opener. +// String contents are preserved verbatim — the version pins live there. +func stripCljComments(src string) string { + var b strings.Builder + b.Grow(len(src)) + i, n := 0, len(src) + for i < n { + switch src[i] { + case '"': + end := scanCljString(src, i) + b.WriteString(src[i:end]) + i = end + case '\\': + // A character literal (\a, \newline, \;) — copy the backslash and + // the next byte so \; is not read as a comment opener. + b.WriteByte(src[i]) + i++ + if i < n { + b.WriteByte(src[i]) + i++ + } + case ';': + for i < n && src[i] != '\n' { + i++ + } + default: + b.WriteByte(src[i]) + i++ + } + } + return b.String() +} + +// scanCljString returns the index just past the string literal opening at i. +func scanCljString(s string, i int) int { + n := len(s) + i++ // past the opening quote + for i < n { + switch s[i] { + case '\\': + i += 2 + case '"': + return i + 1 + default: + i++ + } + } + return n +} + +// scanBalanced returns the index just past the balanced form whose opening +// bracket sits at i, honoring all three bracket kinds and string literals. If +// the form never closes, it returns len(s) — the caller's read of a truncated +// form then falls out as unresolved entries, not as an invented close. +func scanBalanced(s string, i int) int { + n := len(s) + depth := 0 + for i < n { + switch s[i] { + case '"': + i = scanCljString(s, i) + continue + case '\\': + i += 2 + continue + case '[', '{', '(': + depth++ + case ']', '}', ')': + depth-- + if depth == 0 { + return i + 1 + } + } + i++ + } + return n +} + +// skipWS advances past whitespace and commas (whitespace in Clojure). +func skipWS(s string, i int) int { + for i < len(s) { + switch s[i] { + case ' ', '\t', '\n', '\r', ',': + i++ + default: + return i + } + } + return i +} + +// readSymbol reads a Clojure symbol or keyword starting at i and returns it +// with the index just past it. Symbols end at whitespace, a comma, a bracket, +// or a quote. +func readSymbol(s string, i int) (string, int) { + start := i + for i < len(s) { + switch s[i] { + case ' ', '\t', '\n', '\r', ',', '[', ']', '{', '}', '(', ')', '"': + return s[start:i], i + } + i++ + } + return s[start:i], i +} + +// readString reads the string literal opening at i and returns its contents +// (escapes left verbatim — a Maven version has none) with the index past it. +func readString(s string, i int) (string, int) { + end := scanCljString(s, i) + body := s[i:end] + body = strings.TrimPrefix(body, `"`) + body = strings.TrimSuffix(body, `"`) + return body, end +} diff --git a/internal/purl/purl.go b/internal/purl/purl.go index 4035fdb..544d37f 100644 --- a/internal/purl/purl.go +++ b/internal/purl/purl.go @@ -107,6 +107,13 @@ func NewCargo(name, version string) PURL { // NewComposer builds a PURL for a Composer (PHP) package. Composer packages // are always vendor/package (two segments). The vendor is the namespace. +// NewMaven builds a PURL for a Maven-coordinate package (groupId:artifactId). +// The group is the PURL namespace and the artifact the name, per the purl-spec +// maven type. Coordinates are case-sensitive; nothing is folded. +func NewMaven(group, artifact, version string) PURL { + return PURL{Type: "maven", Namespace: strings.TrimSpace(group), Name: strings.TrimSpace(artifact), Version: version} +} + func NewComposer(name, version string) PURL { name = strings.TrimSpace(name) if i := strings.IndexByte(name, '/'); i > 0 {