mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
745 lines
22 KiB
Go
745 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdclient"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// LayerDiffEntry represents one row in the layer diff table.
|
|
type LayerDiffEntry struct {
|
|
Status string // "shared", "rebuilt", "added", "removed"
|
|
Layer LayerDetail // the "to" layer (or from-layer for "removed")
|
|
PrevLayer *LayerDetail // set for "rebuilt" — the old layer
|
|
}
|
|
|
|
// VulnDiffEntry represents a vulnerability categorized by diff status.
|
|
type VulnDiffEntry struct {
|
|
Status string // "fixed", "new", "unchanged"
|
|
Vuln vulnMatch
|
|
}
|
|
|
|
// SbomDiffEntry represents one package categorized by diff status.
|
|
type SbomDiffEntry struct {
|
|
Status string // "added", "removed", "changed", "unchanged"
|
|
Package sbomPackage // the "to" package (or the "from" package for "removed")
|
|
PrevVersion string // set for "changed" — the old version
|
|
}
|
|
|
|
// DiffSummary is the top-line summary for the banner and diff page.
|
|
type DiffSummary struct {
|
|
SizeDelta int64 // bytes, positive = "to" is larger
|
|
LayerCountFrom int
|
|
LayerCountTo int
|
|
VulnFixedCount int
|
|
VulnNewCount int
|
|
VulnFixedBySev vulnSummary
|
|
VulnNewBySev vulnSummary
|
|
HasVulnData bool
|
|
PkgAddedCount int
|
|
PkgRemovedCount int
|
|
PkgChangedCount int
|
|
HasSbomData bool
|
|
}
|
|
|
|
// layerKey returns the matching key for a layer — digest for real layers, command for empty layers.
|
|
func layerKey(l LayerDetail) string {
|
|
if l.Command != "" {
|
|
return l.Command
|
|
}
|
|
return l.Digest
|
|
}
|
|
|
|
// computeLayerDiff compares two ordered LayerDetail slices using LCS on commands (git diff style).
|
|
// Handles insertions and deletions in the middle, not just prefix divergence.
|
|
func computeLayerDiff(fromLayers, toLayers []LayerDetail) []LayerDiffEntry {
|
|
n := len(fromLayers)
|
|
m := len(toLayers)
|
|
|
|
// Build LCS table on layer keys (command or digest)
|
|
dp := make([][]int, n+1)
|
|
for i := range dp {
|
|
dp[i] = make([]int, m+1)
|
|
}
|
|
for i := 1; i <= n; i++ {
|
|
for j := 1; j <= m; j++ {
|
|
if layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
|
|
dp[i][j] = dp[i-1][j-1] + 1
|
|
} else if dp[i-1][j] >= dp[i][j-1] {
|
|
dp[i][j] = dp[i-1][j]
|
|
} else {
|
|
dp[i][j] = dp[i][j-1]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Backtrack to produce the diff
|
|
var result []LayerDiffEntry
|
|
i, j := n, m
|
|
// Build in reverse, then flip
|
|
var rev []LayerDiffEntry
|
|
for i > 0 || j > 0 {
|
|
if i > 0 && j > 0 && layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
|
|
fl := fromLayers[i-1]
|
|
tl := toLayers[j-1]
|
|
|
|
// Same key — check if digest also matches
|
|
sameDigest := false
|
|
if fl.EmptyLayer && tl.EmptyLayer {
|
|
sameDigest = true // empty layers matched by command
|
|
} else if !fl.EmptyLayer && !tl.EmptyLayer {
|
|
sameDigest = fl.Digest == tl.Digest
|
|
}
|
|
|
|
if sameDigest {
|
|
rev = append(rev, LayerDiffEntry{Status: "shared", Layer: tl})
|
|
} else {
|
|
prevLayer := fl
|
|
rev = append(rev, LayerDiffEntry{Status: "rebuilt", Layer: tl, PrevLayer: &prevLayer})
|
|
}
|
|
i--
|
|
j--
|
|
} else if j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]) {
|
|
rev = append(rev, LayerDiffEntry{Status: "added", Layer: toLayers[j-1]})
|
|
j--
|
|
} else {
|
|
rev = append(rev, LayerDiffEntry{Status: "removed", Layer: fromLayers[i-1]})
|
|
i--
|
|
}
|
|
}
|
|
|
|
// Reverse
|
|
result = make([]LayerDiffEntry, len(rev))
|
|
for k, v := range rev {
|
|
result[len(rev)-1-k] = v
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// computeVulnDiff compares two vulnerability match slices by CVE ID.
|
|
func computeVulnDiff(fromMatches, toMatches []vulnMatch) []VulnDiffEntry {
|
|
fromSet := make(map[string]vulnMatch, len(fromMatches))
|
|
for _, m := range fromMatches {
|
|
fromSet[m.CVEID] = m
|
|
}
|
|
|
|
toSet := make(map[string]vulnMatch, len(toMatches))
|
|
for _, m := range toMatches {
|
|
toSet[m.CVEID] = m
|
|
}
|
|
|
|
var result []VulnDiffEntry
|
|
|
|
// Fixed: in from but not to
|
|
for id, m := range fromSet {
|
|
if _, ok := toSet[id]; !ok {
|
|
result = append(result, VulnDiffEntry{Status: "fixed", Vuln: m})
|
|
}
|
|
}
|
|
|
|
// New: in to but not from
|
|
for id, m := range toSet {
|
|
if _, ok := fromSet[id]; !ok {
|
|
result = append(result, VulnDiffEntry{Status: "new", Vuln: m})
|
|
}
|
|
}
|
|
|
|
// Unchanged: in both
|
|
for id, m := range toSet {
|
|
if _, ok := fromSet[id]; ok {
|
|
result = append(result, VulnDiffEntry{Status: "unchanged", Vuln: m})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// computeSbomDiff compares two package lists. Identical entries
|
|
// (name+type+version) match first as "unchanged"; leftovers pair by
|
|
// name+type as "changed" (version bump); the rest are "added"/"removed".
|
|
// Multiset counting keeps duplicate identical entries on one side from all
|
|
// matching a single entry on the other, and the name+type grouping keeps a
|
|
// deb package from pairing against a binary of the same name.
|
|
func computeSbomDiff(fromPkgs, toPkgs []sbomPackage) []SbomDiffEntry {
|
|
exactKey := func(p sbomPackage) string {
|
|
return p.Name + "\x00" + p.Type + "\x00" + p.Version
|
|
}
|
|
nameKey := func(p sbomPackage) string {
|
|
return p.Name + "\x00" + p.Type
|
|
}
|
|
|
|
// Exact pass: count from-side entries, consume per to-side match.
|
|
fromCounts := make(map[string]int, len(fromPkgs))
|
|
for _, p := range fromPkgs {
|
|
fromCounts[exactKey(p)]++
|
|
}
|
|
|
|
var result []SbomDiffEntry
|
|
var toLeft []sbomPackage
|
|
for _, p := range toPkgs {
|
|
if k := exactKey(p); fromCounts[k] > 0 {
|
|
fromCounts[k]--
|
|
result = append(result, SbomDiffEntry{Status: "unchanged", Package: p})
|
|
} else {
|
|
toLeft = append(toLeft, p)
|
|
}
|
|
}
|
|
fromGroups := make(map[string][]sbomPackage)
|
|
for _, p := range fromPkgs {
|
|
if k := exactKey(p); fromCounts[k] > 0 {
|
|
fromCounts[k]--
|
|
fromGroups[nameKey(p)] = append(fromGroups[nameKey(p)], p)
|
|
}
|
|
}
|
|
|
|
// Version-change pass: pair leftovers that share name+type. Versions are
|
|
// sorted lexically per group — determinism matters more than semver
|
|
// correctness for pairing multiple installed versions.
|
|
toGroups := make(map[string][]sbomPackage)
|
|
for _, p := range toLeft {
|
|
toGroups[nameKey(p)] = append(toGroups[nameKey(p)], p)
|
|
}
|
|
byVersion := func(pkgs []sbomPackage) {
|
|
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Version < pkgs[j].Version })
|
|
}
|
|
for k, toGroup := range toGroups {
|
|
fromGroup := fromGroups[k]
|
|
byVersion(fromGroup)
|
|
byVersion(toGroup)
|
|
paired := min(len(fromGroup), len(toGroup))
|
|
for i := range paired {
|
|
result = append(result, SbomDiffEntry{Status: "changed", Package: toGroup[i], PrevVersion: fromGroup[i].Version})
|
|
}
|
|
for _, p := range toGroup[paired:] {
|
|
result = append(result, SbomDiffEntry{Status: "added", Package: p})
|
|
}
|
|
for _, p := range fromGroup[paired:] {
|
|
result = append(result, SbomDiffEntry{Status: "removed", Package: p})
|
|
}
|
|
delete(fromGroups, k)
|
|
}
|
|
for _, fromGroup := range fromGroups {
|
|
for _, p := range fromGroup {
|
|
result = append(result, SbomDiffEntry{Status: "removed", Package: p})
|
|
}
|
|
}
|
|
|
|
// Map iteration above is unordered — sort for stable rendering.
|
|
sort.Slice(result, func(i, j int) bool {
|
|
a, b := result[i].Package, result[j].Package
|
|
if a.Name != b.Name {
|
|
return a.Name < b.Name
|
|
}
|
|
if a.Type != b.Type {
|
|
return a.Type < b.Type
|
|
}
|
|
if a.Version != b.Version {
|
|
return a.Version < b.Version
|
|
}
|
|
return result[i].Status < result[j].Status
|
|
})
|
|
return result
|
|
}
|
|
|
|
// computeDiffSummary derives the top-line summary from layer and vuln diffs.
|
|
func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData bool, sbomDiff []SbomDiffEntry, hasSbomData bool) DiffSummary {
|
|
var fromSize, toSize int64
|
|
for _, l := range fromLayers {
|
|
fromSize += l.Size
|
|
}
|
|
for _, l := range toLayers {
|
|
toSize += l.Size
|
|
}
|
|
|
|
summary := DiffSummary{
|
|
SizeDelta: toSize - fromSize,
|
|
LayerCountFrom: len(fromLayers),
|
|
LayerCountTo: len(toLayers),
|
|
HasVulnData: hasVulnData,
|
|
HasSbomData: hasSbomData,
|
|
}
|
|
|
|
for _, entry := range vulnDiff {
|
|
switch entry.Status {
|
|
case "fixed":
|
|
summary.VulnFixedCount++
|
|
addToSevCount(&summary.VulnFixedBySev, entry.Vuln.Severity)
|
|
case "new":
|
|
summary.VulnNewCount++
|
|
addToSevCount(&summary.VulnNewBySev, entry.Vuln.Severity)
|
|
}
|
|
}
|
|
|
|
for _, entry := range sbomDiff {
|
|
switch entry.Status {
|
|
case "added":
|
|
summary.PkgAddedCount++
|
|
case "removed":
|
|
summary.PkgRemovedCount++
|
|
case "changed":
|
|
summary.PkgChangedCount++
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
func addToSevCount(s *vulnSummary, severity string) {
|
|
// Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land
|
|
// in the same bucket. Unknown severities count toward the total but don't
|
|
// bump any bucket — the template renders them as "Unknown" via the
|
|
// severityLabel helper.
|
|
switch strings.ToLower(strings.TrimSpace(severity)) {
|
|
case "critical", "crit", "c":
|
|
s.Critical++
|
|
case "high", "h":
|
|
s.High++
|
|
case "medium", "med", "m":
|
|
s.Medium++
|
|
case "low", "l":
|
|
s.Low++
|
|
}
|
|
s.Total++
|
|
}
|
|
|
|
// sbomDiffStatus maps one side's SBOM fetch result to a status the template
|
|
// can branch on: "ok", "no-data" (no scan record or SBOM blob),
|
|
// "not-applicable" (scanner skipped this artifact type), or
|
|
// "hold-unreachable" (couldn't resolve or reach the hold).
|
|
func sbomDiffStatus(d *sbomDetailsData) string {
|
|
switch {
|
|
case d == nil:
|
|
return "hold-unreachable"
|
|
case d.Status == atproto.ScanStatusSkipped:
|
|
return "not-applicable"
|
|
case d.Error != "":
|
|
return "no-data"
|
|
default:
|
|
return "ok"
|
|
}
|
|
}
|
|
|
|
// ManifestDiffHandler renders the full diff page comparing two manifests.
|
|
type ManifestDiffHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
identifier := chi.URLParam(r, "handle")
|
|
// Route: /diff/{handle}/* — wildcard captures the repo name
|
|
repo := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
|
|
if repo == "" {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
fromParam := r.URL.Query().Get("from")
|
|
toParam := r.URL.Query().Get("to")
|
|
if fromParam == "" || toParam == "" {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
// Resolve identity
|
|
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
|
|
if err != nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
|
|
if err != nil || owner == nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
if owner.Handle != resolvedHandle {
|
|
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
|
owner.Handle = resolvedHandle
|
|
}
|
|
|
|
// Resolve from/to params — accept either digests (sha256:...) or tag names
|
|
fromDigest := fromParam
|
|
toDigest := toParam
|
|
if !strings.HasPrefix(fromDigest, "sha256:") {
|
|
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam)
|
|
if err != nil || tag == nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
fromDigest = tag.Digest
|
|
}
|
|
if !strings.HasPrefix(toDigest, "sha256:") {
|
|
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam)
|
|
if err != nil || tag == nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
toDigest = tag.Digest
|
|
}
|
|
|
|
// Fetch both manifests
|
|
type manifestData struct {
|
|
manifest *db.ManifestWithMetadata
|
|
layers []LayerDetail
|
|
vulnData *vulnDetailsData
|
|
sbomData *sbomDetailsData
|
|
err error
|
|
}
|
|
|
|
// fetchManifest fetches layers and vulns for a digest.
|
|
// For manifest lists, it uses the provided platform child digest instead.
|
|
fetchManifest := func(digest, platformDigest string) manifestData {
|
|
m, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, digest)
|
|
if err != nil {
|
|
return manifestData{err: err}
|
|
}
|
|
|
|
// For multi-arch, resolve to the platform child
|
|
layerManifest := m
|
|
layerDigest := digest
|
|
holdEndpoint := m.HoldEndpoint
|
|
|
|
if m.IsManifestList && platformDigest != "" {
|
|
child, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, platformDigest)
|
|
if err == nil {
|
|
layerManifest = child
|
|
layerDigest = platformDigest
|
|
if child.HoldEndpoint != "" {
|
|
holdEndpoint = child.HoldEndpoint
|
|
}
|
|
}
|
|
}
|
|
|
|
dbLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, layerManifest.Key)
|
|
|
|
var layers []LayerDetail
|
|
var vulnData *vulnDetailsData
|
|
var sbomData *sbomDetailsData
|
|
|
|
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
|
|
if holdErr == nil {
|
|
// Parallelize the three hold fetches. They're independent and
|
|
// each takes a network round-trip; serial runs add up on slow links.
|
|
var fwg sync.WaitGroup
|
|
fwg.Add(3)
|
|
go func() {
|
|
defer fwg.Done()
|
|
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest)
|
|
if err == nil {
|
|
layers = buildLayerDetails(config.History, dbLayers)
|
|
} else {
|
|
layers = buildLayerDetails(nil, dbLayers)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer fwg.Done()
|
|
vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest)
|
|
vulnData = &vd
|
|
}()
|
|
go func() {
|
|
defer fwg.Done()
|
|
sd := FetchSbomDetails(r.Context(), hold.DID, layerDigest)
|
|
sbomData = &sd
|
|
}()
|
|
fwg.Wait()
|
|
} else {
|
|
layers = buildLayerDetails(nil, dbLayers)
|
|
}
|
|
|
|
return manifestData{manifest: m, layers: layers, vulnData: vulnData, sbomData: sbomData}
|
|
}
|
|
|
|
// First fetch both top-level manifests to check for multi-arch
|
|
fromManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, fromDigest)
|
|
if err != nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
toManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, toDigest)
|
|
if err != nil {
|
|
RenderNotFound(w, r, &h.BaseUIHandler)
|
|
return
|
|
}
|
|
|
|
// Find common platforms for multi-arch
|
|
var commonPlatforms []db.PlatformInfo
|
|
var selectedPlatform string
|
|
isMultiArch := fromManifest.IsManifestList && toManifest.IsManifestList
|
|
|
|
fromPlatformDigest := ""
|
|
toPlatformDigest := ""
|
|
|
|
// platKey returns "os/arch[/variant]" for a platform.
|
|
platKey := func(os, arch, variant string) string {
|
|
k := os + "/" + arch
|
|
if variant != "" {
|
|
k += "/" + variant
|
|
}
|
|
return k
|
|
}
|
|
|
|
// pickPlatformChild returns the child digest from a manifest list whose
|
|
// platform matches the given key. Returns "" if no match.
|
|
pickPlatformChild := func(m *db.ManifestWithMetadata, key string) string {
|
|
for _, p := range m.Platforms {
|
|
if platKey(p.OS, p.Architecture, p.Variant) == key {
|
|
return p.Digest
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if isMultiArch {
|
|
// Build intersection of platforms
|
|
for _, fp := range fromManifest.Platforms {
|
|
for _, tp := range toManifest.Platforms {
|
|
if fp.OS == tp.OS && fp.Architecture == tp.Architecture && fp.Variant == tp.Variant {
|
|
commonPlatforms = append(commonPlatforms, tp)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use query param or default to first common platform
|
|
selectedPlatform = r.URL.Query().Get("platform")
|
|
if len(commonPlatforms) > 0 {
|
|
if selectedPlatform == "" {
|
|
selectedPlatform = platKey(commonPlatforms[0].OS, commonPlatforms[0].Architecture, commonPlatforms[0].Variant)
|
|
}
|
|
fromPlatformDigest = pickPlatformChild(fromManifest, selectedPlatform)
|
|
toPlatformDigest = pickPlatformChild(toManifest, selectedPlatform)
|
|
}
|
|
} else if fromManifest.IsManifestList != toManifest.IsManifestList {
|
|
// Mixed: one side is a manifest list, the other is a platform child.
|
|
// Match them by looking up the single-arch side's platform via its
|
|
// parent manifest_references row and picking the matching child from
|
|
// the manifest list side.
|
|
var listSide *db.ManifestWithMetadata
|
|
var childDigest string
|
|
if fromManifest.IsManifestList {
|
|
listSide = fromManifest
|
|
childDigest = toDigest
|
|
} else {
|
|
listSide = toManifest
|
|
childDigest = fromDigest
|
|
}
|
|
|
|
plat, _ := db.GetChildManifestPlatform(h.ReadOnlyDB, owner.DID, repo, childDigest)
|
|
var listChildDigest string
|
|
if plat != nil {
|
|
listChildDigest = pickPlatformChild(listSide, platKey(plat.OS, plat.Architecture, plat.Variant))
|
|
}
|
|
// Fallback: if we couldn't determine the platform (or no match),
|
|
// default to the first non-attestation child of the manifest list so
|
|
// the diff at least shows real layers instead of an empty index.
|
|
if listChildDigest == "" {
|
|
for _, p := range listSide.Platforms {
|
|
if p.Digest != "" {
|
|
listChildDigest = p.Digest
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if fromManifest.IsManifestList {
|
|
fromPlatformDigest = listChildDigest
|
|
} else {
|
|
toPlatformDigest = listChildDigest
|
|
}
|
|
}
|
|
|
|
// Fetch layer/vuln data in parallel
|
|
var fromData, toData manifestData
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
fromData = fetchManifest(fromDigest, fromPlatformDigest)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
toData = fetchManifest(toDigest, toPlatformDigest)
|
|
}()
|
|
wg.Wait()
|
|
|
|
// Track per-side fetch failures so we render the page with an inline
|
|
// alert naming which tag failed, instead of a generic 404 that makes
|
|
// users guess whether they typoed a tag or hit a transient outage.
|
|
// fromData.manifest / toData.manifest is nil only when the re-fetch at
|
|
// the top of fetchManifest hit a DB error (the tag resolution earlier
|
|
// already ruled out typos).
|
|
fromFailed := fromData.err != nil || fromData.manifest == nil
|
|
toFailed := toData.err != nil || toData.manifest == nil
|
|
|
|
// Fall back to the top-level manifest we already fetched so the page
|
|
// still has something to render for tag labels and metadata.
|
|
if fromFailed {
|
|
fromData.manifest = fromManifest
|
|
}
|
|
if toFailed {
|
|
toData.manifest = toManifest
|
|
}
|
|
|
|
// Compute diffs
|
|
layerDiff := computeLayerDiff(fromData.layers, toData.layers)
|
|
|
|
// ScanStatus distinguishes why vuln data may be missing: "ok" when both
|
|
// sides returned clean scan results; "no-data" when a scan was never
|
|
// recorded; "hold-unreachable" when we couldn't reach the hold to ask.
|
|
// The template branches on these so users can tell "not scanned yet"
|
|
// from "hold offline" at a glance.
|
|
fromScanStatus := "ok"
|
|
toScanStatus := "ok"
|
|
if fromData.vulnData == nil {
|
|
fromScanStatus = "hold-unreachable"
|
|
} else if fromData.vulnData.Error != "" {
|
|
fromScanStatus = "no-data"
|
|
}
|
|
if toData.vulnData == nil {
|
|
toScanStatus = "hold-unreachable"
|
|
} else if toData.vulnData.Error != "" {
|
|
toScanStatus = "no-data"
|
|
}
|
|
|
|
var vulnDiff []VulnDiffEntry
|
|
hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok"
|
|
if hasVulnData {
|
|
vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
|
|
}
|
|
|
|
// SBOM status mirrors the scan status, with one extra case: the scanner
|
|
// records status="skipped" for artifact types it doesn't scan, which the
|
|
// template surfaces as "not applicable" rather than "not scanned yet".
|
|
fromSbomStatus := sbomDiffStatus(fromData.sbomData)
|
|
toSbomStatus := sbomDiffStatus(toData.sbomData)
|
|
|
|
var sbomDiff []SbomDiffEntry
|
|
hasSbomData := fromSbomStatus == "ok" && toSbomStatus == "ok"
|
|
if hasSbomData {
|
|
sbomDiff = computeSbomDiff(fromData.sbomData.Packages, toData.sbomData.Packages)
|
|
}
|
|
|
|
summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData, sbomDiff, hasSbomData)
|
|
|
|
// Determine tag labels
|
|
fromTag := fromDigest
|
|
if len(fromData.manifest.Tags) > 0 {
|
|
fromTag = fromData.manifest.Tags[0]
|
|
}
|
|
toTag := toDigest
|
|
if len(toData.manifest.Tags) > 0 {
|
|
toTag = toData.manifest.Tags[0]
|
|
}
|
|
|
|
// Count vulns by status for template
|
|
var fixedVulns, newVulns, unchangedVulns []vulnMatch
|
|
for _, entry := range vulnDiff {
|
|
switch entry.Status {
|
|
case "fixed":
|
|
fixedVulns = append(fixedVulns, entry.Vuln)
|
|
case "new":
|
|
newVulns = append(newVulns, entry.Vuln)
|
|
case "unchanged":
|
|
unchangedVulns = append(unchangedVulns, entry.Vuln)
|
|
}
|
|
}
|
|
|
|
// Split packages by status for template
|
|
var addedPackages, removedPackages, changedPackages, unchangedPackages []SbomDiffEntry
|
|
for _, entry := range sbomDiff {
|
|
switch entry.Status {
|
|
case "added":
|
|
addedPackages = append(addedPackages, entry)
|
|
case "removed":
|
|
removedPackages = append(removedPackages, entry)
|
|
case "changed":
|
|
changedPackages = append(changedPackages, entry)
|
|
case "unchanged":
|
|
unchangedPackages = append(unchangedPackages, entry)
|
|
}
|
|
}
|
|
|
|
title := fmt.Sprintf("Diff: %s → %s - %s/%s - %s", fromTag, toTag, owner.Handle, repo, h.ClientShortName)
|
|
description := fmt.Sprintf("Comparing %s to %s in %s/%s", fromTag, toTag, owner.Handle, repo)
|
|
meta := NewPageMeta(title, description).
|
|
WithCanonical(fmt.Sprintf("https://%s/diff/%s/%s?from=%s&to=%s", h.SiteURL, owner.Handle, repo, fromDigest, toDigest)).
|
|
WithSiteName(h.ClientShortName)
|
|
|
|
data := struct {
|
|
PageData
|
|
Meta *PageMeta
|
|
Owner *db.User
|
|
Repository string
|
|
FromManifest *db.ManifestWithMetadata
|
|
ToManifest *db.ManifestWithMetadata
|
|
FromTag string
|
|
ToTag string
|
|
Summary DiffSummary
|
|
LayerDiff []LayerDiffEntry
|
|
FixedVulns []vulnMatch
|
|
NewVulns []vulnMatch
|
|
UnchangedVulns []vulnMatch
|
|
HasVulnData bool
|
|
FromScanStatus string
|
|
ToScanStatus string
|
|
AddedPackages []SbomDiffEntry
|
|
RemovedPackages []SbomDiffEntry
|
|
ChangedPackages []SbomDiffEntry
|
|
UnchangedPackages []SbomDiffEntry
|
|
HasSbomData bool
|
|
FromSbomStatus string
|
|
ToSbomStatus string
|
|
FromFailed bool
|
|
ToFailed bool
|
|
IsMultiArch bool
|
|
CommonPlatforms []db.PlatformInfo
|
|
SelectedPlatform string
|
|
FromDigest string
|
|
ToDigest string
|
|
}{
|
|
PageData: NewPageData(r, &h.BaseUIHandler),
|
|
Meta: meta,
|
|
Owner: owner,
|
|
Repository: repo,
|
|
FromManifest: fromData.manifest,
|
|
ToManifest: toData.manifest,
|
|
FromTag: fromTag,
|
|
ToTag: toTag,
|
|
Summary: summary,
|
|
LayerDiff: layerDiff,
|
|
FixedVulns: fixedVulns,
|
|
NewVulns: newVulns,
|
|
UnchangedVulns: unchangedVulns,
|
|
HasVulnData: hasVulnData,
|
|
FromScanStatus: fromScanStatus,
|
|
ToScanStatus: toScanStatus,
|
|
AddedPackages: addedPackages,
|
|
RemovedPackages: removedPackages,
|
|
ChangedPackages: changedPackages,
|
|
UnchangedPackages: unchangedPackages,
|
|
HasSbomData: hasSbomData,
|
|
FromSbomStatus: fromSbomStatus,
|
|
ToSbomStatus: toSbomStatus,
|
|
FromFailed: fromFailed,
|
|
ToFailed: toFailed,
|
|
IsMultiArch: isMultiArch,
|
|
CommonPlatforms: commonPlatforms,
|
|
SelectedPlatform: selectedPlatform,
|
|
FromDigest: fromDigest,
|
|
ToDigest: toDigest,
|
|
}
|
|
|
|
if err := h.Templates.ExecuteTemplate(w, "diff", data); err != nil {
|
|
slog.Warn("Failed to render diff page", "error", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|