From 183d89530d8e2d6848f4fc48207f136505fb2eaf Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 29 Mar 2026 21:42:55 -0700 Subject: [PATCH] add diff support for layers and vulns --- pkg/appview/db/queries.go | 25 + pkg/appview/handlers/diff.go | 462 ++++++++++++++++++ pkg/appview/handlers/diff_test.go | 402 +++++++++++++++ pkg/appview/handlers/upgrade_banner.go | 235 +++++++++ pkg/appview/routes/routes.go | 6 + pkg/appview/templates/pages/diff.html | 103 ++++ pkg/appview/templates/pages/digest.html | 7 + .../templates/partials/diff-content.html | 184 +++++++ .../templates/partials/upgrade-banner.html | 34 ++ pkg/appview/ui.go | 31 ++ 10 files changed, 1489 insertions(+) create mode 100644 pkg/appview/handlers/diff.go create mode 100644 pkg/appview/handlers/diff_test.go create mode 100644 pkg/appview/handlers/upgrade_banner.go create mode 100644 pkg/appview/templates/pages/diff.html create mode 100644 pkg/appview/templates/partials/diff-content.html create mode 100644 pkg/appview/templates/partials/upgrade-banner.html diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index a5e6282..3a1bb87 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -712,6 +712,31 @@ type LatestTagInfo struct { ArtifactType string } +// MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint. +type MostRecentTagInfo struct { + Tag string + Digest string + HoldEndpoint string + CreatedAt time.Time +} + +// GetMostRecentTag returns the most recently created tag with its digest and hold endpoint. +// Returns nil, nil if no tags exist. +func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) { + var info MostRecentTagInfo + err := db.QueryRow(` + SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at + FROM tags t + LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository + WHERE t.did = ? AND t.repository = ? + ORDER BY t.created_at DESC LIMIT 1 + `, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt) + if err != nil { + return nil, nil // no tags is not an error + } + return &info, nil +} + // RepositoryExists checks if any manifests exist for a given repository. func RepositoryExists(db DBTX, did, repository string) (bool, error) { var count int diff --git a/pkg/appview/handlers/diff.go b/pkg/appview/handlers/diff.go new file mode 100644 index 0000000..c38899a --- /dev/null +++ b/pkg/appview/handlers/diff.go @@ -0,0 +1,462 @@ +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 + } + + fromDigest := r.URL.Query().Get("from") + toDigest := r.URL.Query().Get("to") + if fromDigest == "" || toDigest == "" { + 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 + } + + // 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) + } +} diff --git a/pkg/appview/handlers/diff_test.go b/pkg/appview/handlers/diff_test.go new file mode 100644 index 0000000..6e56f35 --- /dev/null +++ b/pkg/appview/handlers/diff_test.go @@ -0,0 +1,402 @@ +package handlers + +import ( + "testing" +) + +func TestComputeLayerDiff_IdenticalLayers(t *testing.T) { + layers := []LayerDetail{ + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, + } + + diff := computeLayerDiff(layers, layers) + + if len(diff) != 2 { + t.Fatalf("expected 2 entries, got %d", len(diff)) + } + for _, e := range diff { + if e.Status != "shared" { + t.Errorf("expected shared, got %s", e.Status) + } + } +} + +func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100}, + {Index: 2, Digest: "sha256:old", Size: 200}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100}, + {Index: 2, Digest: "sha256:new1", Size: 300}, + {Index: 3, Digest: "sha256:new2", Size: 150}, + } + + diff := computeLayerDiff(from, to) + + // Lockstep: shared, then -/+ pair (no command match), then +1 added + if len(diff) != 4 { + t.Fatalf("expected 4 entries, got %d", len(diff)) + } + + expected := []struct { + status string + digest string + }{ + {"shared", "sha256:base"}, + {"removed", "sha256:old"}, // no command, different digest → -/+ + {"added", "sha256:new1"}, + {"added", "sha256:new2"}, // extra layer in to + } + + for i, e := range expected { + if diff[i].Status != e.status { + t.Errorf("[%d] expected status %s, got %s", i, e.status, diff[i].Status) + } + if diff[i].Layer.Digest != e.digest { + t.Errorf("[%d] expected digest %s, got %s", i, e.digest, diff[i].Layer.Digest) + } + } +} + +func TestComputeLayerDiff_SameCommandDifferentDigest(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:old2", Size: 300, Command: "RUN pip install flask"}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:new2", Size: 350, Command: "RUN pip install flask"}, + } + + diff := computeLayerDiff(from, to) + + if len(diff) != 3 { + t.Fatalf("expected 3 entries, got %d", len(diff)) + } + if diff[0].Status != "shared" { + t.Errorf("[0] expected shared, got %s", diff[0].Status) + } + if diff[1].Status != "rebuilt" { + t.Errorf("[1] expected rebuilt, got %s", diff[1].Status) + } + if diff[1].PrevLayer == nil || diff[1].PrevLayer.Size != 200 { + t.Error("[1] expected PrevLayer with size 200") + } + if diff[2].Status != "rebuilt" { + t.Errorf("[2] expected rebuilt, got %s", diff[2].Status) + } +} + +func TestComputeLayerDiff_DifferentCommandDifferentDigest(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN pip install requests==2.28"}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN pip install requests==2.31"}, + } + + diff := computeLayerDiff(from, to) + + if len(diff) != 3 { + t.Fatalf("expected 3 entries, got %d", len(diff)) + } + if diff[0].Status != "shared" { + t.Errorf("[0] expected shared, got %s", diff[0].Status) + } + // Different command → -/+ pair + if diff[1].Status != "removed" { + t.Errorf("[1] expected removed, got %s", diff[1].Status) + } + if diff[2].Status != "added" { + t.Errorf("[2] expected added, got %s", diff[2].Status) + } +} + +func TestComputeLayerDiff_InsertedLayer(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:ccc", Size: 300, Command: "RUN pip install flask"}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:ddd", Size: 210, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:eee", Size: 150, Command: "RUN apt-get install curl"}, + {Index: 4, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, + } + + diff := computeLayerDiff(from, to) + + // Expected: shared, rebuilt, +added, rebuilt + expected := []string{"shared", "rebuilt", "added", "rebuilt"} + if len(diff) != len(expected) { + t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) + } + for i, e := range expected { + if diff[i].Status != e { + t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) + } + } +} + +func TestComputeLayerDiff_RemovedLayer(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:ccc", Size: 150, Command: "RUN apt-get install curl"}, + {Index: 4, Digest: "sha256:ddd", Size: 300, Command: "RUN pip install flask"}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, + {Index: 2, Digest: "sha256:eee", Size: 210, Command: "RUN apt-get update"}, + {Index: 3, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, + } + + diff := computeLayerDiff(from, to) + + // Expected: shared, rebuilt, -removed, rebuilt + expected := []string{"shared", "rebuilt", "removed", "rebuilt"} + if len(diff) != len(expected) { + t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) + } + for i, e := range expected { + if diff[i].Status != e { + t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) + } + } +} + +// helper for test error messages +func diffStatuses(diff []LayerDiffEntry) []string { + var s []string + for _, d := range diff { + s = append(s, d.Status) + } + return s +} + +func TestComputeLayerDiff_EmptyLayersMatchByCommand(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100}, + {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, + {Index: 2, Digest: "sha256:old", Size: 200}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:base", Size: 100}, + {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, + {Index: 2, Digest: "sha256:new", Size: 300}, + } + + diff := computeLayerDiff(from, to) + + if len(diff) != 4 { + t.Fatalf("expected 4 entries, got %d", len(diff)) + } + if diff[0].Status != "shared" || diff[1].Status != "shared" { + t.Error("first two entries should be shared (base layer + empty layer)") + } + // Different digests, no command → -/+ pair + if diff[2].Status != "removed" { + t.Errorf("[2] expected removed, got %s", diff[2].Status) + } + if diff[3].Status != "added" { + t.Errorf("[3] expected added, got %s", diff[3].Status) + } +} + +func TestComputeLayerDiff_CompletelyDifferent(t *testing.T) { + from := []LayerDetail{ + {Index: 1, Digest: "sha256:old1", Size: 100}, + } + to := []LayerDetail{ + {Index: 1, Digest: "sha256:new1", Size: 200}, + {Index: 2, Digest: "sha256:new2", Size: 300}, + } + + diff := computeLayerDiff(from, to) + + // Lockstep: -/+ pair for position 1, then +1 added + if len(diff) != 3 { + t.Fatalf("expected 3 entries, got %d", len(diff)) + } + if diff[0].Status != "removed" { + t.Errorf("[0] expected removed, got %s", diff[0].Status) + } + if diff[1].Status != "added" { + t.Errorf("[1] expected added, got %s", diff[1].Status) + } + if diff[2].Status != "added" { + t.Errorf("[2] expected added, got %s", diff[2].Status) + } +} + +func TestComputeLayerDiff_EmptyInputs(t *testing.T) { + diff := computeLayerDiff(nil, nil) + if len(diff) != 0 { + t.Fatalf("expected 0 entries, got %d", len(diff)) + } + + diff = computeLayerDiff(nil, []LayerDetail{{Index: 1, Digest: "sha256:a"}}) + if len(diff) != 1 || diff[0].Status != "added" { + t.Error("expected 1 added entry") + } + + diff = computeLayerDiff([]LayerDetail{{Index: 1, Digest: "sha256:a"}}, nil) + if len(diff) != 1 || diff[0].Status != "removed" { + t.Error("expected 1 removed entry") + } +} + +func TestComputeVulnDiff_FixedAndNew(t *testing.T) { + from := []vulnMatch{ + {CVEID: "CVE-2024-001", Severity: "Critical", Package: "openssl", Version: "1.1.0"}, + {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, + {CVEID: "CVE-2024-003", Severity: "Medium", Package: "zlib", Version: "1.2.11"}, + } + to := []vulnMatch{ + {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, + {CVEID: "CVE-2025-001", Severity: "High", Package: "requests", Version: "2.31"}, + } + + diff := computeVulnDiff(from, to) + + counts := map[string]int{} + for _, e := range diff { + counts[e.Status]++ + } + + if counts["fixed"] != 2 { + t.Errorf("expected 2 fixed, got %d", counts["fixed"]) + } + if counts["new"] != 1 { + t.Errorf("expected 1 new, got %d", counts["new"]) + } + if counts["unchanged"] != 1 { + t.Errorf("expected 1 unchanged, got %d", counts["unchanged"]) + } +} + +func TestComputeVulnDiff_AllFixed(t *testing.T) { + from := []vulnMatch{ + {CVEID: "CVE-2024-001", Severity: "Critical"}, + {CVEID: "CVE-2024-002", Severity: "High"}, + } + + diff := computeVulnDiff(from, nil) + + for _, e := range diff { + if e.Status != "fixed" { + t.Errorf("expected fixed, got %s", e.Status) + } + } + if len(diff) != 2 { + t.Errorf("expected 2, got %d", len(diff)) + } +} + +func TestComputeVulnDiff_AllNew(t *testing.T) { + to := []vulnMatch{ + {CVEID: "CVE-2025-001", Severity: "Critical"}, + } + + diff := computeVulnDiff(nil, to) + + if len(diff) != 1 || diff[0].Status != "new" { + t.Error("expected 1 new entry") + } +} + +func TestComputeVulnDiff_Empty(t *testing.T) { + diff := computeVulnDiff(nil, nil) + if len(diff) != 0 { + t.Errorf("expected 0, got %d", len(diff)) + } +} + +func TestComputeDiffSummary(t *testing.T) { + fromLayers := []LayerDetail{ + {Index: 1, Size: 1000}, + {Index: 2, Size: 2000}, + } + toLayers := []LayerDetail{ + {Index: 1, Size: 1000}, + {Index: 2, Size: 2500}, + {Index: 3, Size: 500}, + } + + vulnDiff := []VulnDiffEntry{ + {Status: "fixed", Vuln: vulnMatch{Severity: "Critical"}}, + {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, + {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, + {Status: "new", Vuln: vulnMatch{Severity: "Medium"}}, + {Status: "unchanged", Vuln: vulnMatch{Severity: "Low"}}, + } + + summary := computeDiffSummary(fromLayers, toLayers, vulnDiff, true) + + if summary.SizeDelta != 1000 { + t.Errorf("expected size delta 1000, got %d", summary.SizeDelta) + } + if summary.LayerCountFrom != 2 { + t.Errorf("expected from count 2, got %d", summary.LayerCountFrom) + } + if summary.LayerCountTo != 3 { + t.Errorf("expected to count 3, got %d", summary.LayerCountTo) + } + if summary.VulnFixedCount != 3 { + t.Errorf("expected 3 fixed, got %d", summary.VulnFixedCount) + } + if summary.VulnNewCount != 1 { + t.Errorf("expected 1 new, got %d", summary.VulnNewCount) + } + if summary.VulnFixedBySev.Critical != 1 { + t.Errorf("expected 1 fixed critical, got %d", summary.VulnFixedBySev.Critical) + } + if summary.VulnFixedBySev.High != 2 { + t.Errorf("expected 2 fixed high, got %d", summary.VulnFixedBySev.High) + } + if summary.VulnNewBySev.Medium != 1 { + t.Errorf("expected 1 new medium, got %d", summary.VulnNewBySev.Medium) + } + if !summary.HasVulnData { + t.Error("expected HasVulnData to be true") + } +} + +func TestComputeDiffSummary_NoVulnData(t *testing.T) { + summary := computeDiffSummary( + []LayerDetail{{Size: 100}}, + []LayerDetail{{Size: 200}}, + nil, + false, + ) + + if summary.HasVulnData { + t.Error("expected HasVulnData to be false") + } + if summary.SizeDelta != 100 { + t.Errorf("expected size delta 100, got %d", summary.SizeDelta) + } +} + +func TestComputeDiffSummary_SmallerImage(t *testing.T) { + summary := computeDiffSummary( + []LayerDetail{{Size: 5000}, {Size: 3000}}, + []LayerDetail{{Size: 2000}}, + nil, + false, + ) + + if summary.SizeDelta != -6000 { + t.Errorf("expected size delta -6000, got %d", summary.SizeDelta) + } + if summary.LayerCountFrom != 2 || summary.LayerCountTo != 1 { + t.Error("unexpected layer counts") + } +} diff --git a/pkg/appview/handlers/upgrade_banner.go b/pkg/appview/handlers/upgrade_banner.go new file mode 100644 index 0000000..2859f8b --- /dev/null +++ b/pkg/appview/handlers/upgrade_banner.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "fmt" + "log/slog" + "net/http" + "strings" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/holdclient" + "atcr.io/pkg/atproto" + "github.com/go-chi/chi/v5" +) + +// UpgradeBannerHandler returns an HTMX fragment with an upgrade nudge +// when the user is viewing an older tagged manifest. +type UpgradeBannerHandler struct { + BaseUIHandler +} + +func (h *UpgradeBannerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + identifier := chi.URLParam(r, "handle") + pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2) + if len(pathParts) < 1 { + slog.Debug("Upgrade banner: no path parts") + w.WriteHeader(http.StatusNoContent) + return + } + repository := pathParts[0] + + currentDigest := r.URL.Query().Get("digest") + holdEndpoint := r.URL.Query().Get("holdEndpoint") + if currentDigest == "" { + slog.Debug("Upgrade banner: no digest param") + w.WriteHeader(http.StatusNoContent) + return + } + + slog.Debug("Upgrade banner request", "handle", identifier, "repo", repository, "digest", currentDigest, "holdEndpoint", holdEndpoint) + + // Resolve identity + did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier) + if err != nil { + slog.Debug("Upgrade banner: identity resolution failed", "error", err) + w.WriteHeader(http.StatusNoContent) + return + } + + // Get most recent tag + newest, err := db.GetMostRecentTag(h.ReadOnlyDB, did, repository) + if err != nil || newest == nil { + slog.Debug("Upgrade banner: no tags found", "did", did, "repo", repository) + w.WriteHeader(http.StatusNoContent) + return + } + if newest.Digest == currentDigest { + slog.Debug("Upgrade banner: already viewing newest tag", "tag", newest.Tag) + w.WriteHeader(http.StatusNoContent) + return + } + slog.Debug("Upgrade banner: newer tag found", "newerTag", newest.Tag, "newerDigest", newest.Digest) + + // Fetch layers for both to compute size/layer delta + currentManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, currentDigest) + if err != nil { + slog.Debug("Upgrade banner: failed to get current manifest", "error", err) + w.WriteHeader(http.StatusNoContent) + return + } + + newerManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, newest.Digest) + if err != nil { + slog.Debug("Upgrade banner: failed to get newer manifest", "error", err) + w.WriteHeader(http.StatusNoContent) + return + } + slog.Debug("Upgrade banner: fetched both manifests", + "currentID", currentManifest.ID, "newerID", newerManifest.ID, + "currentIsManifestList", currentManifest.IsManifestList, "newerIsManifestList", newerManifest.IsManifestList) + + // For multi-arch manifests, resolve to a common platform child + currentDigestForLayers := currentDigest + newerDigestForLayers := newest.Digest + currentHoldEndpoint := holdEndpoint + newerHoldEndpoint := newest.HoldEndpoint + if newerHoldEndpoint == "" { + newerHoldEndpoint = newerManifest.HoldEndpoint + } + + currentManifestForLayers := currentManifest + newerManifestForLayers := newerManifest + + if currentManifest.IsManifestList && newerManifest.IsManifestList { + // Find first common platform + found := false + for _, cp := range currentManifest.Platforms { + for _, np := range newerManifest.Platforms { + if cp.OS == np.OS && cp.Architecture == np.Architecture && cp.Variant == np.Variant { + // Resolve child manifests + cm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, cp.Digest) + if err != nil { + continue + } + nm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, np.Digest) + if err != nil { + continue + } + currentManifestForLayers = cm + newerManifestForLayers = nm + currentDigestForLayers = cp.Digest + newerDigestForLayers = np.Digest + if cp.HoldEndpoint != "" { + currentHoldEndpoint = cp.HoldEndpoint + } + if np.HoldEndpoint != "" { + newerHoldEndpoint = np.HoldEndpoint + } + found = true + slog.Debug("Upgrade banner: using common platform", "os", cp.OS, "arch", cp.Architecture) + break + } + } + if found { + break + } + } + if !found { + slog.Debug("Upgrade banner: no common platform found") + w.WriteHeader(http.StatusNoContent) + return + } + } else if currentManifest.IsManifestList || newerManifest.IsManifestList { + // One is multi-arch, the other isn't — can't compare meaningfully + // Still show a basic banner without layer/vuln details + } + + // Fetch layers for both + currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID) + newerDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, newerManifestForLayers.ID) + + // Build layer details (try to get config history for richer diff) + var currentLayers, newerLayers []LayerDetail + + // Resolve hold for current manifest + if currentHoldEndpoint != "" { + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint) + if holdErr == nil { + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, currentDigestForLayers) + if err == nil { + currentLayers = buildLayerDetails(config.History, currentDBLayers) + } + } + } + if currentLayers == nil { + currentLayers = buildLayerDetails(nil, currentDBLayers) + } + + // Resolve hold for newer manifest + if newerHoldEndpoint != "" { + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint) + if holdErr == nil { + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, newerDigestForLayers) + if err == nil { + newerLayers = buildLayerDetails(config.History, newerDBLayers) + } + } + } + if newerLayers == nil { + newerLayers = buildLayerDetails(nil, newerDBLayers) + } + + // Fetch vuln summaries for both + var vulnDiff []VulnDiffEntry + hasVulnData := false + + if currentHoldEndpoint != "" && newerHoldEndpoint != "" { + currentHold, err1 := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint) + newerHold, err2 := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint) + if err1 == nil && err2 == nil { + currentVuln := FetchVulnDetails(r.Context(), currentHold.DID, currentDigestForLayers) + newerVuln := FetchVulnDetails(r.Context(), newerHold.DID, newerDigestForLayers) + if currentVuln.Error == "" && newerVuln.Error == "" { + hasVulnData = true + vulnDiff = computeVulnDiff(currentVuln.Matches, newerVuln.Matches) + } + } + } + + summary := computeDiffSummary(currentLayers, newerLayers, vulnDiff, hasVulnData) + + slog.Debug("Upgrade banner: computed summary", "hasVulnData", hasVulnData, + "layersFrom", summary.LayerCountFrom, "layersTo", summary.LayerCountTo, "sizeDelta", summary.SizeDelta) + + // Don't show banner if nothing meaningful changed + if !hasVulnData && summary.LayerCountFrom == summary.LayerCountTo && summary.SizeDelta == 0 { + slog.Debug("Upgrade banner: no meaningful changes, skipping") + w.WriteHeader(http.StatusNoContent) + return + } + + owner, _ := db.GetUserByDID(h.ReadOnlyDB, did) + if owner == nil { + slog.Debug("Upgrade banner: owner not found", "did", did) + w.WriteHeader(http.StatusNoContent) + return + } + + data := struct { + NewerTag string + NewerDigest string + FromDigest string + Summary DiffSummary + DiffURL string + OwnerHandle string + Repository string + }{ + NewerTag: newest.Tag, + NewerDigest: newest.Digest, + FromDigest: currentDigest, + Summary: summary, + DiffURL: fmt.Sprintf("/diff/%s/%s?from=%s&to=%s", owner.Handle, repository, currentDigest, newest.Digest), + OwnerHandle: owner.Handle, + Repository: repository, + } + + slog.Debug("Upgrade banner: rendering template", "newerTag", data.NewerTag, "hasVulnData", data.Summary.HasVulnData, + "fixedCount", data.Summary.VulnFixedCount, "newCount", data.Summary.VulnNewCount, + "layersFrom", data.Summary.LayerCountFrom, "layersTo", data.Summary.LayerCountTo, + "sizeDelta", data.Summary.SizeDelta) + + w.Header().Set("Content-Type", "text/html") + if err := h.Templates.ExecuteTemplate(w, "upgrade-banner", data); err != nil { + slog.Error("Failed to render upgrade banner", "error", err) + } +} diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index fc270b8..ab117a8 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -157,6 +157,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { ).ServeHTTP) router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP) + router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP) + + // Diff page: /diff/{handle}/{repo}?from=...&to=... + router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( + &uihandlers.ManifestDiffHandler{BaseUIHandler: base}, + ).ServeHTTP) router.Get("/d/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( &uihandlers.DigestDetailHandler{BaseUIHandler: base}, diff --git a/pkg/appview/templates/pages/diff.html b/pkg/appview/templates/pages/diff.html new file mode 100644 index 0000000..634ba16 --- /dev/null +++ b/pkg/appview/templates/pages/diff.html @@ -0,0 +1,103 @@ +{{ define "diff" }} + + + + {{ template "head" . }} + {{ template "meta" .Meta }} + + + {{ template "nav" . }} + +
+
+ + + + +
+
+

+ {{ .FromTag }} + + {{ .ToTag }} +

+
+ +
+ +
+
Size
+
{{ humanizeByteDelta .Summary.SizeDelta }}
+
+ + +
+
Layers
+
{{ .Summary.LayerCountFrom }} → {{ .Summary.LayerCountTo }}
+
+ + {{ if .HasVulnData }} + + {{ if gt .Summary.VulnFixedCount 0 }} +
+
Fixed
+
-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}
+
+ {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}C {{ end }} + {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}H {{ end }} + {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}M {{ end }} + {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}L{{ end }} +
+
+ {{ end }} + + + {{ if gt .Summary.VulnNewCount 0 }} +
+
New
+
+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}
+
+ {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}C {{ end }} + {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}H {{ end }} + {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}M {{ end }} + {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}L{{ end }} +
+
+ {{ end }} + {{ end }} +
+ + {{ if .IsMultiArch }} +
+ + +
+ + {{ end }} +
+ + {{ template "diff-content" . }} +
+
+ + {{ template "footer" . }} + + +{{ end }} diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html index a65b0b1..360e858 100644 --- a/pkg/appview/templates/pages/digest.html +++ b/pkg/appview/templates/pages/digest.html @@ -77,6 +77,13 @@ {{ end }} + +
+
+
{{ if .Manifest.IsManifestList }} diff --git a/pkg/appview/templates/partials/diff-content.html b/pkg/appview/templates/partials/diff-content.html new file mode 100644 index 0000000..d52c0b4 --- /dev/null +++ b/pkg/appview/templates/partials/diff-content.html @@ -0,0 +1,184 @@ +{{ define "diff-content" }} + +
+ +
+

Layers

+ {{ if .LayerDiff }} +
+ + + + + + + + + + + {{ range .LayerDiff }} + + + + + + + {{ end }} + +
#CommandSize
{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}{{ .Layer.Index }} + {{ if .Layer.Command }} + {{ .Layer.Command }} + {{ end }} + + {{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }} + {{ if and (eq .Status "rebuilt") .PrevLayer }} + {{ if ne .Layer.Size .PrevLayer.Size }} + ({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }}) + {{ end }} + {{ end }} +
+
+ {{ else }} +

No layer information available

+ {{ end }} +
+ + +
+

Vulnerabilities

+ + {{ if not .HasVulnData }} +

Vulnerability scan data not available for both manifests

+ {{ else }} + + + {{ if .FixedVulns }} +
+ +
+ {{ icon "shield-check" "size-4 text-success" }} + Fixed ({{ len .FixedVulns }}) +
+
+
+ + + + + + + + + + + {{ range .FixedVulns }} + + + + + + + {{ end }} + +
CVESeverityPackageWas
+ {{ if .CVEURL }}{{ .CVEID }} + {{ else }}{{ .CVEID }}{{ end }} + + {{ .Severity }} + {{ .Package }}{{ .Version }}
+
+
+
+ {{ end }} + + + {{ if .NewVulns }} +
+ +
+ {{ icon "alert-triangle" "size-4 text-error" }} + New ({{ len .NewVulns }}) +
+
+
+ + + + + + + + + + + + {{ range .NewVulns }} + + + + + + + + {{ end }} + +
CVESeverityPackageVersionFix
+ {{ if .CVEURL }}{{ .CVEID }} + {{ else }}{{ .CVEID }}{{ end }} + + {{ .Severity }} + {{ .Package }}{{ .Version }}{{ .FixedIn }}
+
+
+
+ {{ end }} + + + {{ if .UnchangedVulns }} +
+ +
+ Unchanged ({{ len .UnchangedVulns }}) +
+
+
+ + + + + + + + + + + + {{ range .UnchangedVulns }} + + + + + + + + {{ end }} + +
CVESeverityPackageVersionFix
+ {{ if .CVEURL }}{{ .CVEID }} + {{ else }}{{ .CVEID }}{{ end }} + + {{ .Severity }} + {{ .Package }}{{ .Version }}{{ .FixedIn }}
+
+
+
+ {{ end }} + + {{ if and (not .FixedVulns) (not .NewVulns) (not .UnchangedVulns) }} +

No vulnerabilities found in either manifest

+ {{ end }} + + {{ end }} +
+
+{{ end }} diff --git a/pkg/appview/templates/partials/upgrade-banner.html b/pkg/appview/templates/partials/upgrade-banner.html new file mode 100644 index 0000000..e81328a --- /dev/null +++ b/pkg/appview/templates/partials/upgrade-banner.html @@ -0,0 +1,34 @@ +{{ define "upgrade-banner" }} +
+
+ {{ icon "info" "size-5 text-info shrink-0" }} +
+ {{ .NewerTag }} + {{ if .Summary.HasVulnData }} + {{ if gt .Summary.VulnFixedCount 0 }} + fixes + {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }} Critical{{ end }} + {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ if gt .Summary.VulnFixedBySev.Critical 0 }}, {{ end }}{{ .Summary.VulnFixedBySev.High }} High{{ end }} + {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ if or (gt .Summary.VulnFixedBySev.Critical 0) (gt .Summary.VulnFixedBySev.High 0) }}, {{ end }}{{ .Summary.VulnFixedBySev.Medium }} Medium{{ end }} + {{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) }}{{ .Summary.VulnFixedCount }} Low{{ end }} + vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }} + {{ else }} + is available + {{ end }} + {{ if gt .Summary.VulnNewCount 0 }} + (+{{ .Summary.VulnNewCount }} new) + {{ end }} + {{ else }} + is available + {{ end }} + {{ if ne .Summary.LayerCountFrom .Summary.LayerCountTo }} + · {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} layer{{ if ne (sub .Summary.LayerCountTo .Summary.LayerCountFrom) 1 }}s{{ end }} + {{ end }} + {{ if ne .Summary.SizeDelta 0 }} + ({{ humanizeByteDelta .Summary.SizeDelta }}) + {{ end }} +
+ View diff +
+
+{{ end }} diff --git a/pkg/appview/ui.go b/pkg/appview/ui.go index 1b0dae6..2cc13b1 100644 --- a/pkg/appview/ui.go +++ b/pkg/appview/ui.go @@ -226,6 +226,37 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) { return a - b }, + "sub64": func(a, b int64) int64 { + return a - b + }, + + "absInt": func(n int) int { + if n < 0 { + return -n + } + return n + }, + + "humanizeByteDelta": func(bytes int64) string { + prefix := "+" + if bytes < 0 { + prefix = "-" + bytes = -bytes + } else if bytes == 0 { + return "no change" + } + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%s%d B", prefix, bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%s%.1f %cB", prefix, float64(bytes)/float64(div), "KMGTPE"[exp]) + }, + "dict": func(values ...any) map[string]any { dict := make(map[string]any, len(values)/2) for i := 0; i < len(values); i += 2 {