Files
at-container-registry/pkg/appview/handlers/diff.go

483 lines
13 KiB
Go

package handlers
import (
"fmt"
"log/slog"
"net/http"
"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
}
// 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
}
// 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
}
// computeDiffSummary derives the top-line summary from layer and vuln diffs.
func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData 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,
}
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)
}
}
return summary
}
func addToSevCount(s *vulnSummary, severity string) {
switch severity {
case "Critical":
s.Critical++
case "High":
s.High++
case "Medium":
s.Medium++
case "Low":
s.Low++
}
s.Total++
}
// 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.ReadOnlyDB, 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
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.ID)
var layers []LayerDetail
var vulnData *vulnDetailsData
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if holdErr == nil {
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest)
if err == nil {
layers = buildLayerDetails(config.History, dbLayers)
} else {
layers = buildLayerDetails(nil, dbLayers)
}
vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest)
vulnData = &vd
} else {
layers = buildLayerDetails(nil, dbLayers)
}
return manifestData{manifest: m, layers: layers, vulnData: vulnData}
}
// 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 := ""
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 = commonPlatforms[0].OS + "/" + commonPlatforms[0].Architecture
if commonPlatforms[0].Variant != "" {
selectedPlatform += "/" + commonPlatforms[0].Variant
}
}
// Find matching platform digests
for _, fp := range fromManifest.Platforms {
platKey := fp.OS + "/" + fp.Architecture
if fp.Variant != "" {
platKey += "/" + fp.Variant
}
if platKey == selectedPlatform {
fromPlatformDigest = fp.Digest
break
}
}
for _, tp := range toManifest.Platforms {
platKey := tp.OS + "/" + tp.Architecture
if tp.Variant != "" {
platKey += "/" + tp.Variant
}
if platKey == selectedPlatform {
toPlatformDigest = tp.Digest
break
}
}
}
}
// 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()
if fromData.err != nil || toData.err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Compute diffs
layerDiff := computeLayerDiff(fromData.layers, toData.layers)
var vulnDiff []VulnDiffEntry
hasVulnData := fromData.vulnData != nil && toData.vulnData != nil &&
fromData.vulnData.Error == "" && toData.vulnData.Error == ""
if hasVulnData {
vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
}
summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData)
// 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)
}
}
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
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,
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)
}
}