Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/inventory/inventory.go

190 lines
6.4 KiB
Go
Raw Permalink Normal View History

// Package inventory turns SBOMs uploaded for a project into the package
// versions the proxy records for it, named the way the proxy's handlers name
// the packages they cache, so the two can be joined.
package inventory
import (
"errors"
"fmt"
"strings"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/sbom"
"github.com/git-pkgs/vers"
)
// MaxProjectNameLength bounds a project name, such as a GitLab project path.
const MaxProjectNameLength = 255
// Component is one package version a project uses.
type Component struct {
// Ecosystem is the name the proxy's handlers store the package under,
// e.g. "npm", "composer", "golang" or "alpine".
Ecosystem string
// Name is the ecosystem-native package name, e.g. "@babel/core",
// "symfony/console" or "org.slf4j:slf4j-api".
Name string
// Version is the version as the SBOM gives it; it may be empty.
Version string
// PackagePURL is the unversioned package URL the proxy records the
// package under, so it matches packages.purl.
PackagePURL string
}
// servedTypes are the PURL types the proxy serves packages of and the
// inventory records.
var servedTypes = map[string]bool{
"alpm": true, "apk": true, "bsd": true, "cargo": true, "chef": true,
"composer": true, "conan": true, "conda": true, "cran": true, "deb": true,
"gem": true, "golang": true, "hex": true, "julia": true, "maven": true,
"npm": true, "nuget": true, "pub": true, "pypi": true, "rpm": true,
"swift": true,
}
// ErrUnsupportedPURL is returned for a package URL whose type the proxy
// doesn't serve.
var ErrUnsupportedPURL = errors.New("package type is not served by the proxy")
// ErrNoCondaChannel is returned for a conda package URL without a channel
// qualifier: the proxy records conda packages per channel.
var ErrNoCondaChannel = errors.New("conda package URL has no channel qualifier")
// FromPURL maps a package URL to a component, with the package named the way
// the handler for its type records it. Qualifiers and subpath are dropped;
// the version is kept as given.
func FromPURL(s string) (Component, error) {
p, err := purl.Parse(s)
if err != nil {
return Component{}, err
}
if !servedTypes[p.Type] {
return Component{}, fmt.Errorf("%w: %s", ErrUnsupportedPURL, p.Type)
}
ecosystem, name := packageurl.HandlerPackage(p)
if ecosystem == "conda" && !strings.Contains(name, "/") {
return Component{}, ErrNoCondaChannel
}
packagePURL, _ := packageurl.MakeCacheStrings(ecosystem, name, "")
if packagePURL == "" {
return Component{}, fmt.Errorf("package URL %q has no valid package name", s)
}
return Component{Ecosystem: ecosystem, Name: name, Version: p.Version, PackagePURL: packagePURL}, nil
}
// syftMainModule is the property syft sets on modules found in a Go binary,
// naming the module the binary was built from.
const syftMainModule = "syft:metadata:mainModule"
// unknownVersion is what syft gives a package without a resolved version,
// such as the project's own module in go.mod or the root of a lock file.
const unknownVersion = "UNKNOWN"
// Parse reads a CycloneDX (JSON or XML) or SPDX SBOM and returns the package
// versions it lists, without duplicates. Packages without a package URL, of a
// type the proxy doesn't serve, without a version, or conda packages without
// a channel are counted in skipped.
// So are the project's own packages: Go modules its binaries were built from,
// and the roots sbom finds for the dependency graph.
func Parse(data []byte) (components []Component, skipped int, err error) {
doc, err := sbom.Parse(data)
if err != nil {
return nil, 0, err
}
ownModules := mainModules(doc.Packages)
roots := map[string]bool{}
for _, id := range doc.ProjectRoots() {
roots[id] = true
}
seen := make(map[[2]string]bool, len(doc.Packages))
for _, pkg := range doc.Packages {
c, ok := componentOf(pkg)
if !ok || roots[pkg.ID] || (c.Ecosystem == "golang" && ownModules[c.Name]) {
skipped++
continue
}
key := [2]string{c.PackagePURL, c.Version}
if seen[key] {
continue
}
seen[key] = true
components = append(components, c)
}
return components, skipped, nil
}
func componentOf(pkg sbom.Package) (Component, bool) {
locator := pkg.PURL()
if locator == "" {
return Component{}, false
}
c, err := FromPURL(locator)
if err != nil {
return Component{}, false
}
if c.Version == "" {
c.Version = pkg.Version
}
// A package without a version is the project itself (the module in
// go.mod, the root of package-lock.json), not something it depends on.
if c.Version == "" || c.Version == unknownVersion {
return Component{}, false
}
return c, true
}
// mainModules returns the Go modules the SBOM's binaries were built from,
// which are the project's own. Module paths are case-sensitive and package
// URLs keep their case, so they are compared as they are.
func mainModules(pkgs []sbom.Package) map[string]bool {
own := map[string]bool{}
for _, pkg := range pkgs {
for _, prop := range pkg.Properties {
if prop.Name == syftMainModule && prop.Value != "" {
own[prop.Value] = true
}
}
}
return own
}
// ValidateProjectName checks a project name such as "group/sub/repo": one or
// more segments of letters, digits, ".", "_" and "-", none of them "." or
// "..".
func ValidateProjectName(name string) error {
if name == "" {
return errors.New("project name is empty")
}
if len(name) > MaxProjectNameLength {
return fmt.Errorf("project name is longer than %d characters", MaxProjectNameLength)
}
for segment := range strings.SplitSeq(name, "/") {
if segment == "" || segment == "." || segment == ".." {
return fmt.Errorf("project name %q has an empty, \".\" or \"..\" segment", name)
}
for _, r := range segment {
if !validNameRune(r) {
return fmt.Errorf("project name %q may only contain letters, digits, '.', '_', '-' and '/'", name)
}
}
}
return nil
}
func validNameRune(r rune) bool {
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-'
}
// CompareVersions compares two versions of a package in ecosystem with the
// ecosystem's version rules, returning -1, 0 or 1.
func CompareVersions(ecosystem, a, b string) int {
return vers.CompareWithScheme(a, b, purl.EcosystemToPURLType(ecosystem))
}
// Outdated reports whether version is lower than latest.
func Outdated(ecosystem, version, latest string) bool {
if version == "" || latest == "" || version == latest {
return false
}
return CompareVersions(ecosystem, version, latest) < 0
}