Files
Evan Jarrett 6758996300 add SBOM package diffing, verify hold-service captain records
- diff view gains a Packages tab with added/removed/changed/unchanged
  package tables and purl-derived type/license/upstream links
- captain records verified against the DID's atcr_hold service before
  caching (processor + batch backfill), preventing forged holds
- fix empty-handle updates clobbering cached handles and colliding on
  the UNIQUE constraint
- move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical,
  repomgr kept as a test oracle
- surface read-only crew status in hold selector
- reconcile docs
2026-06-13 12:49:03 -05:00

200 lines
5.3 KiB
Go

package handlers
import (
"net/url"
"strings"
)
// purlInfo is the subset of a package URL (pkg:type/namespace/name@version)
// needed for display: the ecosystem type and an upstream link.
type purlInfo struct {
Type string // purl type, lowercased (e.g. "deb", "npm", "golang")
Namespace string // may be empty or multi-segment (golang)
Name string
Version string
Qualifiers map[string]string
}
// parsePurl parses a package URL (https://github.com/package-url/purl-spec):
// pkg:type/namespace/name@version?qualifiers#subpath
// Returns nil for anything malformed — callers fall back to supplier sniffing.
func parsePurl(s string) *purlInfo {
rest, ok := strings.CutPrefix(s, "pkg:")
if !ok {
return nil
}
if i := strings.IndexByte(rest, '#'); i >= 0 {
rest = rest[:i]
}
qualifiers := map[string]string{}
if i := strings.IndexByte(rest, '?'); i >= 0 {
for kv := range strings.SplitSeq(rest[i+1:], "&") {
if k, v, ok := strings.Cut(kv, "="); ok && k != "" {
if uv, err := url.QueryUnescape(v); err == nil {
v = uv
}
qualifiers[strings.ToLower(k)] = v
}
}
rest = rest[:i]
}
typ, rest, ok := strings.Cut(strings.TrimPrefix(rest, "/"), "/")
if !ok || typ == "" {
return nil
}
version := ""
if i := strings.LastIndexByte(rest, '@'); i >= 0 {
version = rest[i+1:]
rest = rest[:i]
}
namespace := ""
name := rest
if i := strings.LastIndexByte(rest, '/'); i >= 0 {
namespace = rest[:i]
name = rest[i+1:]
}
if name == "" {
return nil
}
unescape := func(s string) string {
if u, err := url.PathUnescape(s); err == nil {
return u
}
return s
}
return &purlInfo{
Type: strings.ToLower(typ),
Namespace: unescape(namespace),
Name: unescape(name),
Version: unescape(version),
Qualifiers: qualifiers,
}
}
// purlDisplayType maps purl types to the short labels the UI shows. Types
// without a friendlier label (deb, npm, gem, apk, rpm, nuget, ...) pass
// through unchanged; "generic" (binary catalogers) maps to empty so the
// column shows "-" rather than a meaningless badge.
func purlDisplayType(t string) string {
switch t {
case "golang":
return "go"
case "pypi":
return "python"
case "cargo":
return "rust"
case "maven":
return "java"
case "composer":
return "php"
case "generic":
return ""
default:
return t
}
}
// purlURL returns the canonical upstream page for a package, or "" when the
// ecosystem has no stable public index or the purl type is unknown.
func purlURL(p *purlInfo) string {
// Syft stamps "UNKNOWN" when a manifest has no version field. Published
// packages always have one (registries require it), so these entries are
// almost always phantom nested manifests (e.g. a dist/package.json inside
// another package's tarball) that don't exist upstream — don't link them.
if strings.EqualFold(p.Version, "unknown") {
return ""
}
esc := url.PathEscape
switch p.Type {
case "npm":
// Scoped names (@scope/name) are accepted literally by npmx.dev;
// versions use the /v/{version} path form (name@version 404s).
name := p.Name
if p.Namespace != "" {
name = p.Namespace + "/" + p.Name
}
if p.Version != "" {
return "https://npmx.dev/package/" + name + "/v/" + esc(p.Version)
}
return "https://npmx.dev/package/" + name
case "pypi":
if p.Version != "" {
return "https://pypi.org/project/" + esc(p.Name) + "/" + esc(p.Version) + "/"
}
return "https://pypi.org/project/" + esc(p.Name) + "/"
case "gem":
return "https://rubygems.org/gems/" + esc(p.Name)
case "golang":
path := p.Name
if p.Namespace != "" {
path = p.Namespace + "/" + p.Name
}
if p.Version != "" {
return "https://pkg.go.dev/" + path + "@" + esc(p.Version)
}
return "https://pkg.go.dev/" + path
case "cargo":
if p.Version != "" {
return "https://crates.io/crates/" + esc(p.Name) + "/" + esc(p.Version)
}
return "https://crates.io/crates/" + esc(p.Name)
case "nuget":
if p.Version != "" {
return "https://www.nuget.org/packages/" + esc(p.Name) + "/" + esc(p.Version)
}
return "https://www.nuget.org/packages/" + esc(p.Name)
case "maven":
if p.Namespace == "" {
return ""
}
u := "https://central.sonatype.com/artifact/" + esc(p.Namespace) + "/" + esc(p.Name)
if p.Version != "" {
u += "/" + esc(p.Version)
}
return u
case "composer":
if p.Namespace == "" {
return ""
}
return "https://packagist.org/packages/" + esc(p.Namespace) + "/" + esc(p.Name)
case "deb":
// Trackers are keyed by source package; Syft records it in the
// "upstream" qualifier for binary packages (e.g. libssl3t64 →
// openssl). Fall back to the binary name, which usually matches.
name := p.Name
if up := p.Qualifiers["upstream"]; up != "" {
if i := strings.IndexByte(up, '@'); i >= 0 {
up = up[:i]
}
name = up
}
switch p.Namespace {
case "debian":
return "https://tracker.debian.org/pkg/" + esc(name)
case "ubuntu":
return "https://launchpad.net/ubuntu/+source/" + esc(name)
}
return ""
case "apk":
return "https://pkgs.alpinelinux.org/packages?name=" + url.QueryEscape(p.Name)
case "rpm":
if p.Namespace == "fedora" {
return "https://packages.fedoraproject.org/pkgs/" + esc(p.Name) + "/"
}
return ""
case "github":
if p.Namespace == "" {
return ""
}
return "https://github.com/" + esc(p.Namespace) + "/" + esc(p.Name)
}
return ""
}