A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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 {