Files
at-container-registry/pkg/appview/handlers/diff.go
T
Evan JarrettandClaude Opus 5 454a6bad3d db: key manifests by manifest_key and drop the rowid
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>
2026-08-11 22:44:41 -05:00

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)
}
}