mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-07 18:56:55 +00:00
add diff support for layers and vulns
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{{ define "diff" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
|
||||
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
|
||||
<li>Comparing {{ .FromTag }} to {{ .ToTag }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<h1 class="text-xl font-bold">
|
||||
<span class="font-mono">{{ .FromTag }}</span>
|
||||
<span class="text-base-content/40 mx-1">→</span>
|
||||
<span class="font-mono">{{ .ToTag }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- Size delta -->
|
||||
<div class="stat bg-base-200/50 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">Size</div>
|
||||
<div class="stat-value text-sm">{{ humanizeByteDelta .Summary.SizeDelta }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Layer count -->
|
||||
<div class="stat bg-base-200/50 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">Layers</div>
|
||||
<div class="stat-value text-sm">{{ .Summary.LayerCountFrom }} → {{ .Summary.LayerCountTo }}</div>
|
||||
</div>
|
||||
|
||||
{{ if .HasVulnData }}
|
||||
<!-- Vulns fixed -->
|
||||
{{ if gt .Summary.VulnFixedCount 0 }}
|
||||
<div class="stat bg-success/10 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">Fixed</div>
|
||||
<div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}</div>
|
||||
<div class="stat-desc text-xs">
|
||||
{{ 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 }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Vulns new -->
|
||||
{{ if gt .Summary.VulnNewCount 0 }}
|
||||
<div class="stat bg-error/10 rounded-lg p-3">
|
||||
<div class="stat-title text-xs">New</div>
|
||||
<div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}</div>
|
||||
<div class="stat-desc text-xs">
|
||||
{{ 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 }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .IsMultiArch }}
|
||||
<div class="flex items-center gap-3 pt-4 border-t border-base-200">
|
||||
<label for="diff-arch-select" class="text-sm font-medium whitespace-nowrap">{{ icon "cpu" "size-4" }} Platform</label>
|
||||
<select id="diff-arch-select" class="select select-sm select-bordered"
|
||||
onchange="switchDiffPlatform(this.value)">
|
||||
{{ range .CommonPlatforms }}
|
||||
{{ $platKey := printf "%s/%s" .OS .Architecture }}{{ if .Variant }}{{ $platKey = printf "%s/%s/%s" .OS .Architecture .Variant }}{{ end }}
|
||||
<option value="{{ $platKey }}"{{ if eq $platKey $.SelectedPlatform }} selected{{ end }}>{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<script>
|
||||
function switchDiffPlatform(platform) {
|
||||
var url = '/diff/{{ .Owner.Handle }}/{{ .Repository }}?from={{ .FromDigest }}&to={{ .ToDigest }}&platform=' + encodeURIComponent(platform);
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ template "diff-content" . }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -77,6 +77,13 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Banner (HTMX lazy-loaded) -->
|
||||
<div id="upgrade-banner"
|
||||
hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ .Manifest.HoldEndpoint }}{{ end }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
|
||||
<!-- Content: Layers + Vulnerabilities -->
|
||||
<div id="digest-content">
|
||||
{{ if .Manifest.IsManifestList }}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
{{ define "diff-content" }}
|
||||
<!-- Layers + Vulnerabilities Diff -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Layer Diff (Left) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<h2 class="text-lg font-semibold">Layers</h2>
|
||||
{{ if .LayerDiff }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th class="w-6"></th>
|
||||
<th class="w-8">#</th>
|
||||
<th>Command</th>
|
||||
<th class="text-right w-24">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .LayerDiff }}
|
||||
<tr class="{{ if eq .Status "added" }}bg-success/10{{ else if eq .Status "removed" }}bg-error/10{{ else if eq .Status "rebuilt" }}bg-warning/10{{ else }}opacity-60{{ end }}">
|
||||
<td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}</td>
|
||||
<td class="font-mono text-xs">{{ .Layer.Index }}</td>
|
||||
<td>
|
||||
{{ if .Layer.Command }}
|
||||
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Layer.Command }}">{{ .Layer.Command }}</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right text-sm whitespace-nowrap">
|
||||
{{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }}
|
||||
{{ if and (eq .Status "rebuilt") .PrevLayer }}
|
||||
{{ if ne .Layer.Size .PrevLayer.Size }}
|
||||
<span class="text-xs text-base-content/50">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-base-content/60">No layer information available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Vulnerability Diff (Right) -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
|
||||
|
||||
{{ if not .HasVulnData }}
|
||||
<p class="text-base-content/60">Vulnerability scan data not available for both manifests</p>
|
||||
{{ else }}
|
||||
|
||||
<!-- Fixed Vulns -->
|
||||
{{ if .FixedVulns }}
|
||||
<div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title font-medium text-sm flex items-center gap-2">
|
||||
{{ icon "shield-check" "size-4 text-success" }}
|
||||
Fixed ({{ len .FixedVulns }})
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th>CVE</th>
|
||||
<th>Severity</th>
|
||||
<th>Package</th>
|
||||
<th>Was</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .FixedVulns }}
|
||||
<tr>
|
||||
<td>
|
||||
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
|
||||
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
|
||||
</td>
|
||||
<td class="text-xs">{{ .Package }}</td>
|
||||
<td class="text-xs font-mono">{{ .Version }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- New Vulns -->
|
||||
{{ if .NewVulns }}
|
||||
<div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title font-medium text-sm flex items-center gap-2">
|
||||
{{ icon "alert-triangle" "size-4 text-error" }}
|
||||
New ({{ len .NewVulns }})
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th>CVE</th>
|
||||
<th>Severity</th>
|
||||
<th>Package</th>
|
||||
<th>Version</th>
|
||||
<th>Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .NewVulns }}
|
||||
<tr>
|
||||
<td>
|
||||
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
|
||||
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
|
||||
</td>
|
||||
<td class="text-xs">{{ .Package }}</td>
|
||||
<td class="text-xs font-mono">{{ .Version }}</td>
|
||||
<td class="text-xs font-mono">{{ .FixedIn }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Unchanged Vulns -->
|
||||
{{ if .UnchangedVulns }}
|
||||
<div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title font-medium text-sm text-base-content/60">
|
||||
Unchanged ({{ len .UnchangedVulns }})
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th>CVE</th>
|
||||
<th>Severity</th>
|
||||
<th>Package</th>
|
||||
<th>Version</th>
|
||||
<th>Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .UnchangedVulns }}
|
||||
<tr>
|
||||
<td>
|
||||
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
|
||||
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
|
||||
</td>
|
||||
<td class="text-xs">{{ .Package }}</td>
|
||||
<td class="text-xs font-mono">{{ .Version }}</td>
|
||||
<td class="text-xs font-mono">{{ .FixedIn }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if and (not .FixedVulns) (not .NewVulns) (not .UnchangedVulns) }}
|
||||
<p class="text-base-content/60">No vulnerabilities found in either manifest</p>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{ define "upgrade-banner" }}
|
||||
<div class="alert shadow-sm border border-info/30 bg-info/5">
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
{{ icon "info" "size-5 text-info shrink-0" }}
|
||||
<div class="flex-1 text-sm">
|
||||
<span class="font-semibold">{{ .NewerTag }}</span>
|
||||
{{ if .Summary.HasVulnData }}
|
||||
{{ if gt .Summary.VulnFixedCount 0 }}
|
||||
fixes
|
||||
{{ if gt .Summary.VulnFixedBySev.Critical 0 }}<span class="font-semibold text-error">{{ .Summary.VulnFixedBySev.Critical }} Critical</span>{{ end }}
|
||||
{{ if gt .Summary.VulnFixedBySev.High 0 }}{{ if gt .Summary.VulnFixedBySev.Critical 0 }}, {{ end }}<span class="font-semibold text-warning">{{ .Summary.VulnFixedBySev.High }} High</span>{{ end }}
|
||||
{{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ if or (gt .Summary.VulnFixedBySev.Critical 0) (gt .Summary.VulnFixedBySev.High 0) }}, {{ end }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Medium }} Medium</span>{{ end }}
|
||||
{{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) }}<span class="font-semibold">{{ .Summary.VulnFixedCount }} Low</span>{{ end }}
|
||||
vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}
|
||||
{{ else }}
|
||||
is available
|
||||
{{ end }}
|
||||
{{ if gt .Summary.VulnNewCount 0 }}
|
||||
<span class="text-base-content/60">(+{{ .Summary.VulnNewCount }} new)</span>
|
||||
{{ 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 }}
|
||||
</div>
|
||||
<a href="{{ .DiffURL }}" class="btn btn-sm btn-info btn-outline shrink-0">View diff</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user