mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
483 lines
13 KiB
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)
|
|
}
|
|
}
|