A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add diff support for layers and vulns

+1489
+25
pkg/appview/db/queries.go
··· 712 712 ArtifactType string 713 713 } 714 714 715 + // MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint. 716 + type MostRecentTagInfo struct { 717 + Tag string 718 + Digest string 719 + HoldEndpoint string 720 + CreatedAt time.Time 721 + } 722 + 723 + // GetMostRecentTag returns the most recently created tag with its digest and hold endpoint. 724 + // Returns nil, nil if no tags exist. 725 + func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) { 726 + var info MostRecentTagInfo 727 + err := db.QueryRow(` 728 + SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at 729 + FROM tags t 730 + LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 731 + WHERE t.did = ? AND t.repository = ? 732 + ORDER BY t.created_at DESC LIMIT 1 733 + `, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt) 734 + if err != nil { 735 + return nil, nil // no tags is not an error 736 + } 737 + return &info, nil 738 + } 739 + 715 740 // RepositoryExists checks if any manifests exist for a given repository. 716 741 func RepositoryExists(db DBTX, did, repository string) (bool, error) { 717 742 var count int
+462
pkg/appview/handlers/diff.go
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + "sync" 9 + 10 + "atcr.io/pkg/appview/db" 11 + "atcr.io/pkg/appview/holdclient" 12 + "atcr.io/pkg/atproto" 13 + "github.com/go-chi/chi/v5" 14 + ) 15 + 16 + // LayerDiffEntry represents one row in the layer diff table. 17 + type LayerDiffEntry struct { 18 + Status string // "shared", "rebuilt", "added", "removed" 19 + Layer LayerDetail // the "to" layer (or from-layer for "removed") 20 + PrevLayer *LayerDetail // set for "rebuilt" — the old layer 21 + } 22 + 23 + // VulnDiffEntry represents a vulnerability categorized by diff status. 24 + type VulnDiffEntry struct { 25 + Status string // "fixed", "new", "unchanged" 26 + Vuln vulnMatch 27 + } 28 + 29 + // DiffSummary is the top-line summary for the banner and diff page. 30 + type DiffSummary struct { 31 + SizeDelta int64 // bytes, positive = "to" is larger 32 + LayerCountFrom int 33 + LayerCountTo int 34 + VulnFixedCount int 35 + VulnNewCount int 36 + VulnFixedBySev vulnSummary 37 + VulnNewBySev vulnSummary 38 + HasVulnData bool 39 + } 40 + 41 + // layerKey returns the matching key for a layer — digest for real layers, command for empty layers. 42 + func layerKey(l LayerDetail) string { 43 + if l.Command != "" { 44 + return l.Command 45 + } 46 + return l.Digest 47 + } 48 + 49 + // computeLayerDiff compares two ordered LayerDetail slices using LCS on commands (git diff style). 50 + // Handles insertions and deletions in the middle, not just prefix divergence. 51 + func computeLayerDiff(fromLayers, toLayers []LayerDetail) []LayerDiffEntry { 52 + n := len(fromLayers) 53 + m := len(toLayers) 54 + 55 + // Build LCS table on layer keys (command or digest) 56 + dp := make([][]int, n+1) 57 + for i := range dp { 58 + dp[i] = make([]int, m+1) 59 + } 60 + for i := 1; i <= n; i++ { 61 + for j := 1; j <= m; j++ { 62 + if layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) { 63 + dp[i][j] = dp[i-1][j-1] + 1 64 + } else if dp[i-1][j] >= dp[i][j-1] { 65 + dp[i][j] = dp[i-1][j] 66 + } else { 67 + dp[i][j] = dp[i][j-1] 68 + } 69 + } 70 + } 71 + 72 + // Backtrack to produce the diff 73 + var result []LayerDiffEntry 74 + i, j := n, m 75 + // Build in reverse, then flip 76 + var rev []LayerDiffEntry 77 + for i > 0 || j > 0 { 78 + if i > 0 && j > 0 && layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) { 79 + fl := fromLayers[i-1] 80 + tl := toLayers[j-1] 81 + 82 + // Same key — check if digest also matches 83 + sameDigest := false 84 + if fl.EmptyLayer && tl.EmptyLayer { 85 + sameDigest = true // empty layers matched by command 86 + } else if !fl.EmptyLayer && !tl.EmptyLayer { 87 + sameDigest = fl.Digest == tl.Digest 88 + } 89 + 90 + if sameDigest { 91 + rev = append(rev, LayerDiffEntry{Status: "shared", Layer: tl}) 92 + } else { 93 + prevLayer := fl 94 + rev = append(rev, LayerDiffEntry{Status: "rebuilt", Layer: tl, PrevLayer: &prevLayer}) 95 + } 96 + i-- 97 + j-- 98 + } else if j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]) { 99 + rev = append(rev, LayerDiffEntry{Status: "added", Layer: toLayers[j-1]}) 100 + j-- 101 + } else { 102 + rev = append(rev, LayerDiffEntry{Status: "removed", Layer: fromLayers[i-1]}) 103 + i-- 104 + } 105 + } 106 + 107 + // Reverse 108 + result = make([]LayerDiffEntry, len(rev)) 109 + for k, v := range rev { 110 + result[len(rev)-1-k] = v 111 + } 112 + 113 + return result 114 + } 115 + 116 + // computeVulnDiff compares two vulnerability match slices by CVE ID. 117 + func computeVulnDiff(fromMatches, toMatches []vulnMatch) []VulnDiffEntry { 118 + fromSet := make(map[string]vulnMatch, len(fromMatches)) 119 + for _, m := range fromMatches { 120 + fromSet[m.CVEID] = m 121 + } 122 + 123 + toSet := make(map[string]vulnMatch, len(toMatches)) 124 + for _, m := range toMatches { 125 + toSet[m.CVEID] = m 126 + } 127 + 128 + var result []VulnDiffEntry 129 + 130 + // Fixed: in from but not to 131 + for id, m := range fromSet { 132 + if _, ok := toSet[id]; !ok { 133 + result = append(result, VulnDiffEntry{Status: "fixed", Vuln: m}) 134 + } 135 + } 136 + 137 + // New: in to but not from 138 + for id, m := range toSet { 139 + if _, ok := fromSet[id]; !ok { 140 + result = append(result, VulnDiffEntry{Status: "new", Vuln: m}) 141 + } 142 + } 143 + 144 + // Unchanged: in both 145 + for id, m := range toSet { 146 + if _, ok := fromSet[id]; ok { 147 + result = append(result, VulnDiffEntry{Status: "unchanged", Vuln: m}) 148 + } 149 + } 150 + 151 + return result 152 + } 153 + 154 + // computeDiffSummary derives the top-line summary from layer and vuln diffs. 155 + func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData bool) DiffSummary { 156 + var fromSize, toSize int64 157 + for _, l := range fromLayers { 158 + fromSize += l.Size 159 + } 160 + for _, l := range toLayers { 161 + toSize += l.Size 162 + } 163 + 164 + summary := DiffSummary{ 165 + SizeDelta: toSize - fromSize, 166 + LayerCountFrom: len(fromLayers), 167 + LayerCountTo: len(toLayers), 168 + HasVulnData: hasVulnData, 169 + } 170 + 171 + for _, entry := range vulnDiff { 172 + switch entry.Status { 173 + case "fixed": 174 + summary.VulnFixedCount++ 175 + addToSevCount(&summary.VulnFixedBySev, entry.Vuln.Severity) 176 + case "new": 177 + summary.VulnNewCount++ 178 + addToSevCount(&summary.VulnNewBySev, entry.Vuln.Severity) 179 + } 180 + } 181 + 182 + return summary 183 + } 184 + 185 + func addToSevCount(s *vulnSummary, severity string) { 186 + switch severity { 187 + case "Critical": 188 + s.Critical++ 189 + case "High": 190 + s.High++ 191 + case "Medium": 192 + s.Medium++ 193 + case "Low": 194 + s.Low++ 195 + } 196 + s.Total++ 197 + } 198 + 199 + // ManifestDiffHandler renders the full diff page comparing two manifests. 200 + type ManifestDiffHandler struct { 201 + BaseUIHandler 202 + } 203 + 204 + func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 205 + identifier := chi.URLParam(r, "handle") 206 + // Route: /diff/{handle}/* — wildcard captures the repo name 207 + repo := strings.TrimPrefix(chi.URLParam(r, "*"), "/") 208 + if repo == "" { 209 + RenderNotFound(w, r, &h.BaseUIHandler) 210 + return 211 + } 212 + 213 + fromDigest := r.URL.Query().Get("from") 214 + toDigest := r.URL.Query().Get("to") 215 + if fromDigest == "" || toDigest == "" { 216 + RenderNotFound(w, r, &h.BaseUIHandler) 217 + return 218 + } 219 + 220 + // Resolve identity 221 + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) 222 + if err != nil { 223 + RenderNotFound(w, r, &h.BaseUIHandler) 224 + return 225 + } 226 + 227 + owner, err := db.GetUserByDID(h.ReadOnlyDB, did) 228 + if err != nil || owner == nil { 229 + RenderNotFound(w, r, &h.BaseUIHandler) 230 + return 231 + } 232 + if owner.Handle != resolvedHandle { 233 + _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) 234 + owner.Handle = resolvedHandle 235 + } 236 + 237 + // Fetch both manifests 238 + type manifestData struct { 239 + manifest *db.ManifestWithMetadata 240 + layers []LayerDetail 241 + vulnData *vulnDetailsData 242 + err error 243 + } 244 + 245 + // fetchManifest fetches layers and vulns for a digest. 246 + // For manifest lists, it uses the provided platform child digest instead. 247 + fetchManifest := func(digest, platformDigest string) manifestData { 248 + m, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, digest) 249 + if err != nil { 250 + return manifestData{err: err} 251 + } 252 + 253 + // For multi-arch, resolve to the platform child 254 + layerManifest := m 255 + layerDigest := digest 256 + holdEndpoint := m.HoldEndpoint 257 + 258 + if m.IsManifestList && platformDigest != "" { 259 + child, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, platformDigest) 260 + if err == nil { 261 + layerManifest = child 262 + layerDigest = platformDigest 263 + if child.HoldEndpoint != "" { 264 + holdEndpoint = child.HoldEndpoint 265 + } 266 + } 267 + } 268 + 269 + dbLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, layerManifest.ID) 270 + 271 + var layers []LayerDetail 272 + var vulnData *vulnDetailsData 273 + 274 + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) 275 + if holdErr == nil { 276 + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest) 277 + if err == nil { 278 + layers = buildLayerDetails(config.History, dbLayers) 279 + } else { 280 + layers = buildLayerDetails(nil, dbLayers) 281 + } 282 + 283 + vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest) 284 + vulnData = &vd 285 + } else { 286 + layers = buildLayerDetails(nil, dbLayers) 287 + } 288 + 289 + return manifestData{manifest: m, layers: layers, vulnData: vulnData} 290 + } 291 + 292 + // First fetch both top-level manifests to check for multi-arch 293 + fromManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, fromDigest) 294 + if err != nil { 295 + RenderNotFound(w, r, &h.BaseUIHandler) 296 + return 297 + } 298 + toManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, toDigest) 299 + if err != nil { 300 + RenderNotFound(w, r, &h.BaseUIHandler) 301 + return 302 + } 303 + 304 + // Find common platforms for multi-arch 305 + var commonPlatforms []db.PlatformInfo 306 + var selectedPlatform string 307 + isMultiArch := fromManifest.IsManifestList && toManifest.IsManifestList 308 + 309 + fromPlatformDigest := "" 310 + toPlatformDigest := "" 311 + 312 + if isMultiArch { 313 + // Build intersection of platforms 314 + for _, fp := range fromManifest.Platforms { 315 + for _, tp := range toManifest.Platforms { 316 + if fp.OS == tp.OS && fp.Architecture == tp.Architecture && fp.Variant == tp.Variant { 317 + commonPlatforms = append(commonPlatforms, tp) 318 + break 319 + } 320 + } 321 + } 322 + 323 + // Use query param or default to first common platform 324 + selectedPlatform = r.URL.Query().Get("platform") 325 + if len(commonPlatforms) > 0 { 326 + if selectedPlatform == "" { 327 + selectedPlatform = commonPlatforms[0].OS + "/" + commonPlatforms[0].Architecture 328 + if commonPlatforms[0].Variant != "" { 329 + selectedPlatform += "/" + commonPlatforms[0].Variant 330 + } 331 + } 332 + // Find matching platform digests 333 + for _, fp := range fromManifest.Platforms { 334 + platKey := fp.OS + "/" + fp.Architecture 335 + if fp.Variant != "" { 336 + platKey += "/" + fp.Variant 337 + } 338 + if platKey == selectedPlatform { 339 + fromPlatformDigest = fp.Digest 340 + break 341 + } 342 + } 343 + for _, tp := range toManifest.Platforms { 344 + platKey := tp.OS + "/" + tp.Architecture 345 + if tp.Variant != "" { 346 + platKey += "/" + tp.Variant 347 + } 348 + if platKey == selectedPlatform { 349 + toPlatformDigest = tp.Digest 350 + break 351 + } 352 + } 353 + } 354 + } 355 + 356 + // Fetch layer/vuln data in parallel 357 + var fromData, toData manifestData 358 + var wg sync.WaitGroup 359 + wg.Add(2) 360 + go func() { 361 + defer wg.Done() 362 + fromData = fetchManifest(fromDigest, fromPlatformDigest) 363 + }() 364 + go func() { 365 + defer wg.Done() 366 + toData = fetchManifest(toDigest, toPlatformDigest) 367 + }() 368 + wg.Wait() 369 + 370 + if fromData.err != nil || toData.err != nil { 371 + RenderNotFound(w, r, &h.BaseUIHandler) 372 + return 373 + } 374 + 375 + // Compute diffs 376 + layerDiff := computeLayerDiff(fromData.layers, toData.layers) 377 + 378 + var vulnDiff []VulnDiffEntry 379 + hasVulnData := fromData.vulnData != nil && toData.vulnData != nil && 380 + fromData.vulnData.Error == "" && toData.vulnData.Error == "" 381 + if hasVulnData { 382 + vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches) 383 + } 384 + 385 + summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData) 386 + 387 + // Determine tag labels 388 + fromTag := fromDigest 389 + if len(fromData.manifest.Tags) > 0 { 390 + fromTag = fromData.manifest.Tags[0] 391 + } 392 + toTag := toDigest 393 + if len(toData.manifest.Tags) > 0 { 394 + toTag = toData.manifest.Tags[0] 395 + } 396 + 397 + // Count vulns by status for template 398 + var fixedVulns, newVulns, unchangedVulns []vulnMatch 399 + for _, entry := range vulnDiff { 400 + switch entry.Status { 401 + case "fixed": 402 + fixedVulns = append(fixedVulns, entry.Vuln) 403 + case "new": 404 + newVulns = append(newVulns, entry.Vuln) 405 + case "unchanged": 406 + unchangedVulns = append(unchangedVulns, entry.Vuln) 407 + } 408 + } 409 + 410 + title := fmt.Sprintf("Diff: %s → %s - %s/%s - %s", fromTag, toTag, owner.Handle, repo, h.ClientShortName) 411 + description := fmt.Sprintf("Comparing %s to %s in %s/%s", fromTag, toTag, owner.Handle, repo) 412 + meta := NewPageMeta(title, description). 413 + WithCanonical(fmt.Sprintf("https://%s/diff/%s/%s?from=%s&to=%s", h.SiteURL, owner.Handle, repo, fromDigest, toDigest)). 414 + WithSiteName(h.ClientShortName) 415 + 416 + data := struct { 417 + PageData 418 + Meta *PageMeta 419 + Owner *db.User 420 + Repository string 421 + FromManifest *db.ManifestWithMetadata 422 + ToManifest *db.ManifestWithMetadata 423 + FromTag string 424 + ToTag string 425 + Summary DiffSummary 426 + LayerDiff []LayerDiffEntry 427 + FixedVulns []vulnMatch 428 + NewVulns []vulnMatch 429 + UnchangedVulns []vulnMatch 430 + HasVulnData bool 431 + IsMultiArch bool 432 + CommonPlatforms []db.PlatformInfo 433 + SelectedPlatform string 434 + FromDigest string 435 + ToDigest string 436 + }{ 437 + PageData: NewPageData(r, &h.BaseUIHandler), 438 + Meta: meta, 439 + Owner: owner, 440 + Repository: repo, 441 + FromManifest: fromData.manifest, 442 + ToManifest: toData.manifest, 443 + FromTag: fromTag, 444 + ToTag: toTag, 445 + Summary: summary, 446 + LayerDiff: layerDiff, 447 + FixedVulns: fixedVulns, 448 + NewVulns: newVulns, 449 + UnchangedVulns: unchangedVulns, 450 + HasVulnData: hasVulnData, 451 + IsMultiArch: isMultiArch, 452 + CommonPlatforms: commonPlatforms, 453 + SelectedPlatform: selectedPlatform, 454 + FromDigest: fromDigest, 455 + ToDigest: toDigest, 456 + } 457 + 458 + if err := h.Templates.ExecuteTemplate(w, "diff", data); err != nil { 459 + slog.Warn("Failed to render diff page", "error", err) 460 + http.Error(w, err.Error(), http.StatusInternalServerError) 461 + } 462 + }
+402
pkg/appview/handlers/diff_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestComputeLayerDiff_IdenticalLayers(t *testing.T) { 8 + layers := []LayerDetail{ 9 + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, 10 + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, 11 + } 12 + 13 + diff := computeLayerDiff(layers, layers) 14 + 15 + if len(diff) != 2 { 16 + t.Fatalf("expected 2 entries, got %d", len(diff)) 17 + } 18 + for _, e := range diff { 19 + if e.Status != "shared" { 20 + t.Errorf("expected shared, got %s", e.Status) 21 + } 22 + } 23 + } 24 + 25 + func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) { 26 + from := []LayerDetail{ 27 + {Index: 1, Digest: "sha256:base", Size: 100}, 28 + {Index: 2, Digest: "sha256:old", Size: 200}, 29 + } 30 + to := []LayerDetail{ 31 + {Index: 1, Digest: "sha256:base", Size: 100}, 32 + {Index: 2, Digest: "sha256:new1", Size: 300}, 33 + {Index: 3, Digest: "sha256:new2", Size: 150}, 34 + } 35 + 36 + diff := computeLayerDiff(from, to) 37 + 38 + // Lockstep: shared, then -/+ pair (no command match), then +1 added 39 + if len(diff) != 4 { 40 + t.Fatalf("expected 4 entries, got %d", len(diff)) 41 + } 42 + 43 + expected := []struct { 44 + status string 45 + digest string 46 + }{ 47 + {"shared", "sha256:base"}, 48 + {"removed", "sha256:old"}, // no command, different digest → -/+ 49 + {"added", "sha256:new1"}, 50 + {"added", "sha256:new2"}, // extra layer in to 51 + } 52 + 53 + for i, e := range expected { 54 + if diff[i].Status != e.status { 55 + t.Errorf("[%d] expected status %s, got %s", i, e.status, diff[i].Status) 56 + } 57 + if diff[i].Layer.Digest != e.digest { 58 + t.Errorf("[%d] expected digest %s, got %s", i, e.digest, diff[i].Layer.Digest) 59 + } 60 + } 61 + } 62 + 63 + func TestComputeLayerDiff_SameCommandDifferentDigest(t *testing.T) { 64 + from := []LayerDetail{ 65 + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, 66 + {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN apt-get update"}, 67 + {Index: 3, Digest: "sha256:old2", Size: 300, Command: "RUN pip install flask"}, 68 + } 69 + to := []LayerDetail{ 70 + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, 71 + {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN apt-get update"}, 72 + {Index: 3, Digest: "sha256:new2", Size: 350, Command: "RUN pip install flask"}, 73 + } 74 + 75 + diff := computeLayerDiff(from, to) 76 + 77 + if len(diff) != 3 { 78 + t.Fatalf("expected 3 entries, got %d", len(diff)) 79 + } 80 + if diff[0].Status != "shared" { 81 + t.Errorf("[0] expected shared, got %s", diff[0].Status) 82 + } 83 + if diff[1].Status != "rebuilt" { 84 + t.Errorf("[1] expected rebuilt, got %s", diff[1].Status) 85 + } 86 + if diff[1].PrevLayer == nil || diff[1].PrevLayer.Size != 200 { 87 + t.Error("[1] expected PrevLayer with size 200") 88 + } 89 + if diff[2].Status != "rebuilt" { 90 + t.Errorf("[2] expected rebuilt, got %s", diff[2].Status) 91 + } 92 + } 93 + 94 + func TestComputeLayerDiff_DifferentCommandDifferentDigest(t *testing.T) { 95 + from := []LayerDetail{ 96 + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, 97 + {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN pip install requests==2.28"}, 98 + } 99 + to := []LayerDetail{ 100 + {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, 101 + {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN pip install requests==2.31"}, 102 + } 103 + 104 + diff := computeLayerDiff(from, to) 105 + 106 + if len(diff) != 3 { 107 + t.Fatalf("expected 3 entries, got %d", len(diff)) 108 + } 109 + if diff[0].Status != "shared" { 110 + t.Errorf("[0] expected shared, got %s", diff[0].Status) 111 + } 112 + // Different command → -/+ pair 113 + if diff[1].Status != "removed" { 114 + t.Errorf("[1] expected removed, got %s", diff[1].Status) 115 + } 116 + if diff[2].Status != "added" { 117 + t.Errorf("[2] expected added, got %s", diff[2].Status) 118 + } 119 + } 120 + 121 + func TestComputeLayerDiff_InsertedLayer(t *testing.T) { 122 + from := []LayerDetail{ 123 + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, 124 + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, 125 + {Index: 3, Digest: "sha256:ccc", Size: 300, Command: "RUN pip install flask"}, 126 + } 127 + to := []LayerDetail{ 128 + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, 129 + {Index: 2, Digest: "sha256:ddd", Size: 210, Command: "RUN apt-get update"}, 130 + {Index: 3, Digest: "sha256:eee", Size: 150, Command: "RUN apt-get install curl"}, 131 + {Index: 4, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, 132 + } 133 + 134 + diff := computeLayerDiff(from, to) 135 + 136 + // Expected: shared, rebuilt, +added, rebuilt 137 + expected := []string{"shared", "rebuilt", "added", "rebuilt"} 138 + if len(diff) != len(expected) { 139 + t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) 140 + } 141 + for i, e := range expected { 142 + if diff[i].Status != e { 143 + t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) 144 + } 145 + } 146 + } 147 + 148 + func TestComputeLayerDiff_RemovedLayer(t *testing.T) { 149 + from := []LayerDetail{ 150 + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, 151 + {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, 152 + {Index: 3, Digest: "sha256:ccc", Size: 150, Command: "RUN apt-get install curl"}, 153 + {Index: 4, Digest: "sha256:ddd", Size: 300, Command: "RUN pip install flask"}, 154 + } 155 + to := []LayerDetail{ 156 + {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, 157 + {Index: 2, Digest: "sha256:eee", Size: 210, Command: "RUN apt-get update"}, 158 + {Index: 3, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, 159 + } 160 + 161 + diff := computeLayerDiff(from, to) 162 + 163 + // Expected: shared, rebuilt, -removed, rebuilt 164 + expected := []string{"shared", "rebuilt", "removed", "rebuilt"} 165 + if len(diff) != len(expected) { 166 + t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) 167 + } 168 + for i, e := range expected { 169 + if diff[i].Status != e { 170 + t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) 171 + } 172 + } 173 + } 174 + 175 + // helper for test error messages 176 + func diffStatuses(diff []LayerDiffEntry) []string { 177 + var s []string 178 + for _, d := range diff { 179 + s = append(s, d.Status) 180 + } 181 + return s 182 + } 183 + 184 + func TestComputeLayerDiff_EmptyLayersMatchByCommand(t *testing.T) { 185 + from := []LayerDetail{ 186 + {Index: 1, Digest: "sha256:base", Size: 100}, 187 + {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, 188 + {Index: 2, Digest: "sha256:old", Size: 200}, 189 + } 190 + to := []LayerDetail{ 191 + {Index: 1, Digest: "sha256:base", Size: 100}, 192 + {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, 193 + {Index: 2, Digest: "sha256:new", Size: 300}, 194 + } 195 + 196 + diff := computeLayerDiff(from, to) 197 + 198 + if len(diff) != 4 { 199 + t.Fatalf("expected 4 entries, got %d", len(diff)) 200 + } 201 + if diff[0].Status != "shared" || diff[1].Status != "shared" { 202 + t.Error("first two entries should be shared (base layer + empty layer)") 203 + } 204 + // Different digests, no command → -/+ pair 205 + if diff[2].Status != "removed" { 206 + t.Errorf("[2] expected removed, got %s", diff[2].Status) 207 + } 208 + if diff[3].Status != "added" { 209 + t.Errorf("[3] expected added, got %s", diff[3].Status) 210 + } 211 + } 212 + 213 + func TestComputeLayerDiff_CompletelyDifferent(t *testing.T) { 214 + from := []LayerDetail{ 215 + {Index: 1, Digest: "sha256:old1", Size: 100}, 216 + } 217 + to := []LayerDetail{ 218 + {Index: 1, Digest: "sha256:new1", Size: 200}, 219 + {Index: 2, Digest: "sha256:new2", Size: 300}, 220 + } 221 + 222 + diff := computeLayerDiff(from, to) 223 + 224 + // Lockstep: -/+ pair for position 1, then +1 added 225 + if len(diff) != 3 { 226 + t.Fatalf("expected 3 entries, got %d", len(diff)) 227 + } 228 + if diff[0].Status != "removed" { 229 + t.Errorf("[0] expected removed, got %s", diff[0].Status) 230 + } 231 + if diff[1].Status != "added" { 232 + t.Errorf("[1] expected added, got %s", diff[1].Status) 233 + } 234 + if diff[2].Status != "added" { 235 + t.Errorf("[2] expected added, got %s", diff[2].Status) 236 + } 237 + } 238 + 239 + func TestComputeLayerDiff_EmptyInputs(t *testing.T) { 240 + diff := computeLayerDiff(nil, nil) 241 + if len(diff) != 0 { 242 + t.Fatalf("expected 0 entries, got %d", len(diff)) 243 + } 244 + 245 + diff = computeLayerDiff(nil, []LayerDetail{{Index: 1, Digest: "sha256:a"}}) 246 + if len(diff) != 1 || diff[0].Status != "added" { 247 + t.Error("expected 1 added entry") 248 + } 249 + 250 + diff = computeLayerDiff([]LayerDetail{{Index: 1, Digest: "sha256:a"}}, nil) 251 + if len(diff) != 1 || diff[0].Status != "removed" { 252 + t.Error("expected 1 removed entry") 253 + } 254 + } 255 + 256 + func TestComputeVulnDiff_FixedAndNew(t *testing.T) { 257 + from := []vulnMatch{ 258 + {CVEID: "CVE-2024-001", Severity: "Critical", Package: "openssl", Version: "1.1.0"}, 259 + {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, 260 + {CVEID: "CVE-2024-003", Severity: "Medium", Package: "zlib", Version: "1.2.11"}, 261 + } 262 + to := []vulnMatch{ 263 + {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, 264 + {CVEID: "CVE-2025-001", Severity: "High", Package: "requests", Version: "2.31"}, 265 + } 266 + 267 + diff := computeVulnDiff(from, to) 268 + 269 + counts := map[string]int{} 270 + for _, e := range diff { 271 + counts[e.Status]++ 272 + } 273 + 274 + if counts["fixed"] != 2 { 275 + t.Errorf("expected 2 fixed, got %d", counts["fixed"]) 276 + } 277 + if counts["new"] != 1 { 278 + t.Errorf("expected 1 new, got %d", counts["new"]) 279 + } 280 + if counts["unchanged"] != 1 { 281 + t.Errorf("expected 1 unchanged, got %d", counts["unchanged"]) 282 + } 283 + } 284 + 285 + func TestComputeVulnDiff_AllFixed(t *testing.T) { 286 + from := []vulnMatch{ 287 + {CVEID: "CVE-2024-001", Severity: "Critical"}, 288 + {CVEID: "CVE-2024-002", Severity: "High"}, 289 + } 290 + 291 + diff := computeVulnDiff(from, nil) 292 + 293 + for _, e := range diff { 294 + if e.Status != "fixed" { 295 + t.Errorf("expected fixed, got %s", e.Status) 296 + } 297 + } 298 + if len(diff) != 2 { 299 + t.Errorf("expected 2, got %d", len(diff)) 300 + } 301 + } 302 + 303 + func TestComputeVulnDiff_AllNew(t *testing.T) { 304 + to := []vulnMatch{ 305 + {CVEID: "CVE-2025-001", Severity: "Critical"}, 306 + } 307 + 308 + diff := computeVulnDiff(nil, to) 309 + 310 + if len(diff) != 1 || diff[0].Status != "new" { 311 + t.Error("expected 1 new entry") 312 + } 313 + } 314 + 315 + func TestComputeVulnDiff_Empty(t *testing.T) { 316 + diff := computeVulnDiff(nil, nil) 317 + if len(diff) != 0 { 318 + t.Errorf("expected 0, got %d", len(diff)) 319 + } 320 + } 321 + 322 + func TestComputeDiffSummary(t *testing.T) { 323 + fromLayers := []LayerDetail{ 324 + {Index: 1, Size: 1000}, 325 + {Index: 2, Size: 2000}, 326 + } 327 + toLayers := []LayerDetail{ 328 + {Index: 1, Size: 1000}, 329 + {Index: 2, Size: 2500}, 330 + {Index: 3, Size: 500}, 331 + } 332 + 333 + vulnDiff := []VulnDiffEntry{ 334 + {Status: "fixed", Vuln: vulnMatch{Severity: "Critical"}}, 335 + {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, 336 + {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, 337 + {Status: "new", Vuln: vulnMatch{Severity: "Medium"}}, 338 + {Status: "unchanged", Vuln: vulnMatch{Severity: "Low"}}, 339 + } 340 + 341 + summary := computeDiffSummary(fromLayers, toLayers, vulnDiff, true) 342 + 343 + if summary.SizeDelta != 1000 { 344 + t.Errorf("expected size delta 1000, got %d", summary.SizeDelta) 345 + } 346 + if summary.LayerCountFrom != 2 { 347 + t.Errorf("expected from count 2, got %d", summary.LayerCountFrom) 348 + } 349 + if summary.LayerCountTo != 3 { 350 + t.Errorf("expected to count 3, got %d", summary.LayerCountTo) 351 + } 352 + if summary.VulnFixedCount != 3 { 353 + t.Errorf("expected 3 fixed, got %d", summary.VulnFixedCount) 354 + } 355 + if summary.VulnNewCount != 1 { 356 + t.Errorf("expected 1 new, got %d", summary.VulnNewCount) 357 + } 358 + if summary.VulnFixedBySev.Critical != 1 { 359 + t.Errorf("expected 1 fixed critical, got %d", summary.VulnFixedBySev.Critical) 360 + } 361 + if summary.VulnFixedBySev.High != 2 { 362 + t.Errorf("expected 2 fixed high, got %d", summary.VulnFixedBySev.High) 363 + } 364 + if summary.VulnNewBySev.Medium != 1 { 365 + t.Errorf("expected 1 new medium, got %d", summary.VulnNewBySev.Medium) 366 + } 367 + if !summary.HasVulnData { 368 + t.Error("expected HasVulnData to be true") 369 + } 370 + } 371 + 372 + func TestComputeDiffSummary_NoVulnData(t *testing.T) { 373 + summary := computeDiffSummary( 374 + []LayerDetail{{Size: 100}}, 375 + []LayerDetail{{Size: 200}}, 376 + nil, 377 + false, 378 + ) 379 + 380 + if summary.HasVulnData { 381 + t.Error("expected HasVulnData to be false") 382 + } 383 + if summary.SizeDelta != 100 { 384 + t.Errorf("expected size delta 100, got %d", summary.SizeDelta) 385 + } 386 + } 387 + 388 + func TestComputeDiffSummary_SmallerImage(t *testing.T) { 389 + summary := computeDiffSummary( 390 + []LayerDetail{{Size: 5000}, {Size: 3000}}, 391 + []LayerDetail{{Size: 2000}}, 392 + nil, 393 + false, 394 + ) 395 + 396 + if summary.SizeDelta != -6000 { 397 + t.Errorf("expected size delta -6000, got %d", summary.SizeDelta) 398 + } 399 + if summary.LayerCountFrom != 2 || summary.LayerCountTo != 1 { 400 + t.Error("unexpected layer counts") 401 + } 402 + }
+235
pkg/appview/handlers/upgrade_banner.go
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + 9 + "atcr.io/pkg/appview/db" 10 + "atcr.io/pkg/appview/holdclient" 11 + "atcr.io/pkg/atproto" 12 + "github.com/go-chi/chi/v5" 13 + ) 14 + 15 + // UpgradeBannerHandler returns an HTMX fragment with an upgrade nudge 16 + // when the user is viewing an older tagged manifest. 17 + type UpgradeBannerHandler struct { 18 + BaseUIHandler 19 + } 20 + 21 + func (h *UpgradeBannerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 + identifier := chi.URLParam(r, "handle") 23 + pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2) 24 + if len(pathParts) < 1 { 25 + slog.Debug("Upgrade banner: no path parts") 26 + w.WriteHeader(http.StatusNoContent) 27 + return 28 + } 29 + repository := pathParts[0] 30 + 31 + currentDigest := r.URL.Query().Get("digest") 32 + holdEndpoint := r.URL.Query().Get("holdEndpoint") 33 + if currentDigest == "" { 34 + slog.Debug("Upgrade banner: no digest param") 35 + w.WriteHeader(http.StatusNoContent) 36 + return 37 + } 38 + 39 + slog.Debug("Upgrade banner request", "handle", identifier, "repo", repository, "digest", currentDigest, "holdEndpoint", holdEndpoint) 40 + 41 + // Resolve identity 42 + did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier) 43 + if err != nil { 44 + slog.Debug("Upgrade banner: identity resolution failed", "error", err) 45 + w.WriteHeader(http.StatusNoContent) 46 + return 47 + } 48 + 49 + // Get most recent tag 50 + newest, err := db.GetMostRecentTag(h.ReadOnlyDB, did, repository) 51 + if err != nil || newest == nil { 52 + slog.Debug("Upgrade banner: no tags found", "did", did, "repo", repository) 53 + w.WriteHeader(http.StatusNoContent) 54 + return 55 + } 56 + if newest.Digest == currentDigest { 57 + slog.Debug("Upgrade banner: already viewing newest tag", "tag", newest.Tag) 58 + w.WriteHeader(http.StatusNoContent) 59 + return 60 + } 61 + slog.Debug("Upgrade banner: newer tag found", "newerTag", newest.Tag, "newerDigest", newest.Digest) 62 + 63 + // Fetch layers for both to compute size/layer delta 64 + currentManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, currentDigest) 65 + if err != nil { 66 + slog.Debug("Upgrade banner: failed to get current manifest", "error", err) 67 + w.WriteHeader(http.StatusNoContent) 68 + return 69 + } 70 + 71 + newerManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, newest.Digest) 72 + if err != nil { 73 + slog.Debug("Upgrade banner: failed to get newer manifest", "error", err) 74 + w.WriteHeader(http.StatusNoContent) 75 + return 76 + } 77 + slog.Debug("Upgrade banner: fetched both manifests", 78 + "currentID", currentManifest.ID, "newerID", newerManifest.ID, 79 + "currentIsManifestList", currentManifest.IsManifestList, "newerIsManifestList", newerManifest.IsManifestList) 80 + 81 + // For multi-arch manifests, resolve to a common platform child 82 + currentDigestForLayers := currentDigest 83 + newerDigestForLayers := newest.Digest 84 + currentHoldEndpoint := holdEndpoint 85 + newerHoldEndpoint := newest.HoldEndpoint 86 + if newerHoldEndpoint == "" { 87 + newerHoldEndpoint = newerManifest.HoldEndpoint 88 + } 89 + 90 + currentManifestForLayers := currentManifest 91 + newerManifestForLayers := newerManifest 92 + 93 + if currentManifest.IsManifestList && newerManifest.IsManifestList { 94 + // Find first common platform 95 + found := false 96 + for _, cp := range currentManifest.Platforms { 97 + for _, np := range newerManifest.Platforms { 98 + if cp.OS == np.OS && cp.Architecture == np.Architecture && cp.Variant == np.Variant { 99 + // Resolve child manifests 100 + cm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, cp.Digest) 101 + if err != nil { 102 + continue 103 + } 104 + nm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, np.Digest) 105 + if err != nil { 106 + continue 107 + } 108 + currentManifestForLayers = cm 109 + newerManifestForLayers = nm 110 + currentDigestForLayers = cp.Digest 111 + newerDigestForLayers = np.Digest 112 + if cp.HoldEndpoint != "" { 113 + currentHoldEndpoint = cp.HoldEndpoint 114 + } 115 + if np.HoldEndpoint != "" { 116 + newerHoldEndpoint = np.HoldEndpoint 117 + } 118 + found = true 119 + slog.Debug("Upgrade banner: using common platform", "os", cp.OS, "arch", cp.Architecture) 120 + break 121 + } 122 + } 123 + if found { 124 + break 125 + } 126 + } 127 + if !found { 128 + slog.Debug("Upgrade banner: no common platform found") 129 + w.WriteHeader(http.StatusNoContent) 130 + return 131 + } 132 + } else if currentManifest.IsManifestList || newerManifest.IsManifestList { 133 + // One is multi-arch, the other isn't — can't compare meaningfully 134 + // Still show a basic banner without layer/vuln details 135 + } 136 + 137 + // Fetch layers for both 138 + currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID) 139 + newerDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, newerManifestForLayers.ID) 140 + 141 + // Build layer details (try to get config history for richer diff) 142 + var currentLayers, newerLayers []LayerDetail 143 + 144 + // Resolve hold for current manifest 145 + if currentHoldEndpoint != "" { 146 + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint) 147 + if holdErr == nil { 148 + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, currentDigestForLayers) 149 + if err == nil { 150 + currentLayers = buildLayerDetails(config.History, currentDBLayers) 151 + } 152 + } 153 + } 154 + if currentLayers == nil { 155 + currentLayers = buildLayerDetails(nil, currentDBLayers) 156 + } 157 + 158 + // Resolve hold for newer manifest 159 + if newerHoldEndpoint != "" { 160 + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint) 161 + if holdErr == nil { 162 + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, newerDigestForLayers) 163 + if err == nil { 164 + newerLayers = buildLayerDetails(config.History, newerDBLayers) 165 + } 166 + } 167 + } 168 + if newerLayers == nil { 169 + newerLayers = buildLayerDetails(nil, newerDBLayers) 170 + } 171 + 172 + // Fetch vuln summaries for both 173 + var vulnDiff []VulnDiffEntry 174 + hasVulnData := false 175 + 176 + if currentHoldEndpoint != "" && newerHoldEndpoint != "" { 177 + currentHold, err1 := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint) 178 + newerHold, err2 := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint) 179 + if err1 == nil && err2 == nil { 180 + currentVuln := FetchVulnDetails(r.Context(), currentHold.DID, currentDigestForLayers) 181 + newerVuln := FetchVulnDetails(r.Context(), newerHold.DID, newerDigestForLayers) 182 + if currentVuln.Error == "" && newerVuln.Error == "" { 183 + hasVulnData = true 184 + vulnDiff = computeVulnDiff(currentVuln.Matches, newerVuln.Matches) 185 + } 186 + } 187 + } 188 + 189 + summary := computeDiffSummary(currentLayers, newerLayers, vulnDiff, hasVulnData) 190 + 191 + slog.Debug("Upgrade banner: computed summary", "hasVulnData", hasVulnData, 192 + "layersFrom", summary.LayerCountFrom, "layersTo", summary.LayerCountTo, "sizeDelta", summary.SizeDelta) 193 + 194 + // Don't show banner if nothing meaningful changed 195 + if !hasVulnData && summary.LayerCountFrom == summary.LayerCountTo && summary.SizeDelta == 0 { 196 + slog.Debug("Upgrade banner: no meaningful changes, skipping") 197 + w.WriteHeader(http.StatusNoContent) 198 + return 199 + } 200 + 201 + owner, _ := db.GetUserByDID(h.ReadOnlyDB, did) 202 + if owner == nil { 203 + slog.Debug("Upgrade banner: owner not found", "did", did) 204 + w.WriteHeader(http.StatusNoContent) 205 + return 206 + } 207 + 208 + data := struct { 209 + NewerTag string 210 + NewerDigest string 211 + FromDigest string 212 + Summary DiffSummary 213 + DiffURL string 214 + OwnerHandle string 215 + Repository string 216 + }{ 217 + NewerTag: newest.Tag, 218 + NewerDigest: newest.Digest, 219 + FromDigest: currentDigest, 220 + Summary: summary, 221 + DiffURL: fmt.Sprintf("/diff/%s/%s?from=%s&to=%s", owner.Handle, repository, currentDigest, newest.Digest), 222 + OwnerHandle: owner.Handle, 223 + Repository: repository, 224 + } 225 + 226 + slog.Debug("Upgrade banner: rendering template", "newerTag", data.NewerTag, "hasVulnData", data.Summary.HasVulnData, 227 + "fixedCount", data.Summary.VulnFixedCount, "newCount", data.Summary.VulnNewCount, 228 + "layersFrom", data.Summary.LayerCountFrom, "layersTo", data.Summary.LayerCountTo, 229 + "sizeDelta", data.Summary.SizeDelta) 230 + 231 + w.Header().Set("Content-Type", "text/html") 232 + if err := h.Templates.ExecuteTemplate(w, "upgrade-banner", data); err != nil { 233 + slog.Error("Failed to render upgrade banner", "error", err) 234 + } 235 + }
+6
pkg/appview/routes/routes.go
··· 157 157 ).ServeHTTP) 158 158 159 159 router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP) 160 + router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP) 161 + 162 + // Diff page: /diff/{handle}/{repo}?from=...&to=... 163 + router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 164 + &uihandlers.ManifestDiffHandler{BaseUIHandler: base}, 165 + ).ServeHTTP) 160 166 161 167 router.Get("/d/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 162 168 &uihandlers.DigestDetailHandler{BaseUIHandler: base},
+103
pkg/appview/templates/pages/diff.html
··· 1 + {{ define "diff" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + {{ template "head" . }} 6 + {{ template "meta" .Meta }} 7 + </head> 8 + <body> 9 + {{ template "nav" . }} 10 + 11 + <main class="container mx-auto px-4 py-8"> 12 + <div class="space-y-6"> 13 + <!-- Breadcrumb --> 14 + <div class="text-sm breadcrumbs"> 15 + <ul> 16 + <li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li> 17 + <li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li> 18 + <li>Comparing {{ .FromTag }} to {{ .ToTag }}</li> 19 + </ul> 20 + </div> 21 + 22 + <!-- Summary Card --> 23 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6"> 24 + <div class="flex flex-wrap items-center gap-2 mb-4"> 25 + <h1 class="text-xl font-bold"> 26 + <span class="font-mono">{{ .FromTag }}</span> 27 + <span class="text-base-content/40 mx-1">→</span> 28 + <span class="font-mono">{{ .ToTag }}</span> 29 + </h1> 30 + </div> 31 + 32 + <div class="flex flex-wrap gap-4"> 33 + <!-- Size delta --> 34 + <div class="stat bg-base-200/50 rounded-lg p-3"> 35 + <div class="stat-title text-xs">Size</div> 36 + <div class="stat-value text-sm">{{ humanizeByteDelta .Summary.SizeDelta }}</div> 37 + </div> 38 + 39 + <!-- Layer count --> 40 + <div class="stat bg-base-200/50 rounded-lg p-3"> 41 + <div class="stat-title text-xs">Layers</div> 42 + <div class="stat-value text-sm">{{ .Summary.LayerCountFrom }} → {{ .Summary.LayerCountTo }}</div> 43 + </div> 44 + 45 + {{ if .HasVulnData }} 46 + <!-- Vulns fixed --> 47 + {{ if gt .Summary.VulnFixedCount 0 }} 48 + <div class="stat bg-success/10 rounded-lg p-3"> 49 + <div class="stat-title text-xs">Fixed</div> 50 + <div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}</div> 51 + <div class="stat-desc text-xs"> 52 + {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}C {{ end }} 53 + {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}H {{ end }} 54 + {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}M {{ end }} 55 + {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}L{{ end }} 56 + </div> 57 + </div> 58 + {{ end }} 59 + 60 + <!-- Vulns new --> 61 + {{ if gt .Summary.VulnNewCount 0 }} 62 + <div class="stat bg-error/10 rounded-lg p-3"> 63 + <div class="stat-title text-xs">New</div> 64 + <div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}</div> 65 + <div class="stat-desc text-xs"> 66 + {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}C {{ end }} 67 + {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}H {{ end }} 68 + {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}M {{ end }} 69 + {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}L{{ end }} 70 + </div> 71 + </div> 72 + {{ end }} 73 + {{ end }} 74 + </div> 75 + 76 + {{ if .IsMultiArch }} 77 + <div class="flex items-center gap-3 pt-4 border-t border-base-200"> 78 + <label for="diff-arch-select" class="text-sm font-medium whitespace-nowrap">{{ icon "cpu" "size-4" }} Platform</label> 79 + <select id="diff-arch-select" class="select select-sm select-bordered" 80 + onchange="switchDiffPlatform(this.value)"> 81 + {{ range .CommonPlatforms }} 82 + {{ $platKey := printf "%s/%s" .OS .Architecture }}{{ if .Variant }}{{ $platKey = printf "%s/%s/%s" .OS .Architecture .Variant }}{{ end }} 83 + <option value="{{ $platKey }}"{{ if eq $platKey $.SelectedPlatform }} selected{{ end }}>{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</option> 84 + {{ end }} 85 + </select> 86 + </div> 87 + <script> 88 + function switchDiffPlatform(platform) { 89 + var url = '/diff/{{ .Owner.Handle }}/{{ .Repository }}?from={{ .FromDigest }}&to={{ .ToDigest }}&platform=' + encodeURIComponent(platform); 90 + window.location.href = url; 91 + } 92 + </script> 93 + {{ end }} 94 + </div> 95 + 96 + {{ template "diff-content" . }} 97 + </div> 98 + </main> 99 + 100 + {{ template "footer" . }} 101 + </body> 102 + </html> 103 + {{ end }}
+7
pkg/appview/templates/pages/digest.html
··· 77 77 {{ end }} 78 78 </div> 79 79 80 + <!-- Upgrade Banner (HTMX lazy-loaded) --> 81 + <div id="upgrade-banner" 82 + hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ .Manifest.HoldEndpoint }}{{ end }}" 83 + hx-trigger="load" 84 + hx-swap="innerHTML"> 85 + </div> 86 + 80 87 <!-- Content: Layers + Vulnerabilities --> 81 88 <div id="digest-content"> 82 89 {{ if .Manifest.IsManifestList }}
+184
pkg/appview/templates/partials/diff-content.html
··· 1 + {{ define "diff-content" }} 2 + <!-- Layers + Vulnerabilities Diff --> 3 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> 4 + <!-- Layer Diff (Left) --> 5 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 6 + <h2 class="text-lg font-semibold">Layers</h2> 7 + {{ if .LayerDiff }} 8 + <div class="overflow-x-auto"> 9 + <table class="table table-xs w-full"> 10 + <thead> 11 + <tr class="text-xs"> 12 + <th class="w-6"></th> 13 + <th class="w-8">#</th> 14 + <th>Command</th> 15 + <th class="text-right w-24">Size</th> 16 + </tr> 17 + </thead> 18 + <tbody> 19 + {{ range .LayerDiff }} 20 + <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 }}"> 21 + <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> 22 + <td class="font-mono text-xs">{{ .Layer.Index }}</td> 23 + <td> 24 + {{ if .Layer.Command }} 25 + <code class="font-mono text-xs break-all line-clamp-2" title="{{ .Layer.Command }}">{{ .Layer.Command }}</code> 26 + {{ end }} 27 + </td> 28 + <td class="text-right text-sm whitespace-nowrap"> 29 + {{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }} 30 + {{ if and (eq .Status "rebuilt") .PrevLayer }} 31 + {{ if ne .Layer.Size .PrevLayer.Size }} 32 + <span class="text-xs text-base-content/50">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span> 33 + {{ end }} 34 + {{ end }} 35 + </td> 36 + </tr> 37 + {{ end }} 38 + </tbody> 39 + </table> 40 + </div> 41 + {{ else }} 42 + <p class="text-base-content/60">No layer information available</p> 43 + {{ end }} 44 + </div> 45 + 46 + <!-- Vulnerability Diff (Right) --> 47 + <div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 48 + <h2 class="text-lg font-semibold">Vulnerabilities</h2> 49 + 50 + {{ if not .HasVulnData }} 51 + <p class="text-base-content/60">Vulnerability scan data not available for both manifests</p> 52 + {{ else }} 53 + 54 + <!-- Fixed Vulns --> 55 + {{ if .FixedVulns }} 56 + <div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg"> 57 + <input type="checkbox" checked /> 58 + <div class="collapse-title font-medium text-sm flex items-center gap-2"> 59 + {{ icon "shield-check" "size-4 text-success" }} 60 + Fixed ({{ len .FixedVulns }}) 61 + </div> 62 + <div class="collapse-content"> 63 + <div class="overflow-x-auto"> 64 + <table class="table table-xs w-full"> 65 + <thead> 66 + <tr class="text-xs"> 67 + <th>CVE</th> 68 + <th>Severity</th> 69 + <th>Package</th> 70 + <th>Was</th> 71 + </tr> 72 + </thead> 73 + <tbody> 74 + {{ range .FixedVulns }} 75 + <tr> 76 + <td> 77 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 78 + {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 79 + </td> 80 + <td> 81 + <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> 82 + </td> 83 + <td class="text-xs">{{ .Package }}</td> 84 + <td class="text-xs font-mono">{{ .Version }}</td> 85 + </tr> 86 + {{ end }} 87 + </tbody> 88 + </table> 89 + </div> 90 + </div> 91 + </div> 92 + {{ end }} 93 + 94 + <!-- New Vulns --> 95 + {{ if .NewVulns }} 96 + <div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg"> 97 + <input type="checkbox" checked /> 98 + <div class="collapse-title font-medium text-sm flex items-center gap-2"> 99 + {{ icon "alert-triangle" "size-4 text-error" }} 100 + New ({{ len .NewVulns }}) 101 + </div> 102 + <div class="collapse-content"> 103 + <div class="overflow-x-auto"> 104 + <table class="table table-xs w-full"> 105 + <thead> 106 + <tr class="text-xs"> 107 + <th>CVE</th> 108 + <th>Severity</th> 109 + <th>Package</th> 110 + <th>Version</th> 111 + <th>Fix</th> 112 + </tr> 113 + </thead> 114 + <tbody> 115 + {{ range .NewVulns }} 116 + <tr> 117 + <td> 118 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 119 + {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 120 + </td> 121 + <td> 122 + <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> 123 + </td> 124 + <td class="text-xs">{{ .Package }}</td> 125 + <td class="text-xs font-mono">{{ .Version }}</td> 126 + <td class="text-xs font-mono">{{ .FixedIn }}</td> 127 + </tr> 128 + {{ end }} 129 + </tbody> 130 + </table> 131 + </div> 132 + </div> 133 + </div> 134 + {{ end }} 135 + 136 + <!-- Unchanged Vulns --> 137 + {{ if .UnchangedVulns }} 138 + <div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg"> 139 + <input type="checkbox" /> 140 + <div class="collapse-title font-medium text-sm text-base-content/60"> 141 + Unchanged ({{ len .UnchangedVulns }}) 142 + </div> 143 + <div class="collapse-content"> 144 + <div class="overflow-x-auto"> 145 + <table class="table table-xs w-full"> 146 + <thead> 147 + <tr class="text-xs"> 148 + <th>CVE</th> 149 + <th>Severity</th> 150 + <th>Package</th> 151 + <th>Version</th> 152 + <th>Fix</th> 153 + </tr> 154 + </thead> 155 + <tbody> 156 + {{ range .UnchangedVulns }} 157 + <tr> 158 + <td> 159 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 160 + {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 161 + </td> 162 + <td> 163 + <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> 164 + </td> 165 + <td class="text-xs">{{ .Package }}</td> 166 + <td class="text-xs font-mono">{{ .Version }}</td> 167 + <td class="text-xs font-mono">{{ .FixedIn }}</td> 168 + </tr> 169 + {{ end }} 170 + </tbody> 171 + </table> 172 + </div> 173 + </div> 174 + </div> 175 + {{ end }} 176 + 177 + {{ if and (not .FixedVulns) (not .NewVulns) (not .UnchangedVulns) }} 178 + <p class="text-base-content/60">No vulnerabilities found in either manifest</p> 179 + {{ end }} 180 + 181 + {{ end }} 182 + </div> 183 + </div> 184 + {{ end }}
+34
pkg/appview/templates/partials/upgrade-banner.html
··· 1 + {{ define "upgrade-banner" }} 2 + <div class="alert shadow-sm border border-info/30 bg-info/5"> 3 + <div class="flex items-center gap-3 w-full"> 4 + {{ icon "info" "size-5 text-info shrink-0" }} 5 + <div class="flex-1 text-sm"> 6 + <span class="font-semibold">{{ .NewerTag }}</span> 7 + {{ if .Summary.HasVulnData }} 8 + {{ if gt .Summary.VulnFixedCount 0 }} 9 + fixes 10 + {{ if gt .Summary.VulnFixedBySev.Critical 0 }}<span class="font-semibold text-error">{{ .Summary.VulnFixedBySev.Critical }} Critical</span>{{ end }} 11 + {{ 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 }} 12 + {{ 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 }} 13 + {{ 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 }} 14 + vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }} 15 + {{ else }} 16 + is available 17 + {{ end }} 18 + {{ if gt .Summary.VulnNewCount 0 }} 19 + <span class="text-base-content/60">(+{{ .Summary.VulnNewCount }} new)</span> 20 + {{ end }} 21 + {{ else }} 22 + is available 23 + {{ end }} 24 + {{ if ne .Summary.LayerCountFrom .Summary.LayerCountTo }} 25 + · {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} layer{{ if ne (sub .Summary.LayerCountTo .Summary.LayerCountFrom) 1 }}s{{ end }} 26 + {{ end }} 27 + {{ if ne .Summary.SizeDelta 0 }} 28 + ({{ humanizeByteDelta .Summary.SizeDelta }}) 29 + {{ end }} 30 + </div> 31 + <a href="{{ .DiffURL }}" class="btn btn-sm btn-info btn-outline shrink-0">View diff</a> 32 + </div> 33 + </div> 34 + {{ end }}
+31
pkg/appview/ui.go
··· 226 226 return a - b 227 227 }, 228 228 229 + "sub64": func(a, b int64) int64 { 230 + return a - b 231 + }, 232 + 233 + "absInt": func(n int) int { 234 + if n < 0 { 235 + return -n 236 + } 237 + return n 238 + }, 239 + 240 + "humanizeByteDelta": func(bytes int64) string { 241 + prefix := "+" 242 + if bytes < 0 { 243 + prefix = "-" 244 + bytes = -bytes 245 + } else if bytes == 0 { 246 + return "no change" 247 + } 248 + const unit = 1024 249 + if bytes < unit { 250 + return fmt.Sprintf("%s%d B", prefix, bytes) 251 + } 252 + div, exp := int64(unit), 0 253 + for n := bytes / unit; n >= unit; n /= unit { 254 + div *= unit 255 + exp++ 256 + } 257 + return fmt.Sprintf("%s%.1f %cB", prefix, float64(bytes)/float64(div), "KMGTPE"[exp]) 258 + }, 259 + 229 260 "dict": func(values ...any) map[string]any { 230 261 dict := make(map[string]any, len(values)/2) 231 262 for i := 0; i < len(values); i += 2 {