···712712 ArtifactType string
713713}
714714715715+// MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint.
716716+type MostRecentTagInfo struct {
717717+ Tag string
718718+ Digest string
719719+ HoldEndpoint string
720720+ CreatedAt time.Time
721721+}
722722+723723+// GetMostRecentTag returns the most recently created tag with its digest and hold endpoint.
724724+// Returns nil, nil if no tags exist.
725725+func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) {
726726+ var info MostRecentTagInfo
727727+ err := db.QueryRow(`
728728+ SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at
729729+ FROM tags t
730730+ LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
731731+ WHERE t.did = ? AND t.repository = ?
732732+ ORDER BY t.created_at DESC LIMIT 1
733733+ `, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt)
734734+ if err != nil {
735735+ return nil, nil // no tags is not an error
736736+ }
737737+ return &info, nil
738738+}
739739+715740// RepositoryExists checks if any manifests exist for a given repository.
716741func RepositoryExists(db DBTX, did, repository string) (bool, error) {
717742 var count int
+462
pkg/appview/handlers/diff.go
···11+package handlers
22+33+import (
44+ "fmt"
55+ "log/slog"
66+ "net/http"
77+ "strings"
88+ "sync"
99+1010+ "atcr.io/pkg/appview/db"
1111+ "atcr.io/pkg/appview/holdclient"
1212+ "atcr.io/pkg/atproto"
1313+ "github.com/go-chi/chi/v5"
1414+)
1515+1616+// LayerDiffEntry represents one row in the layer diff table.
1717+type LayerDiffEntry struct {
1818+ Status string // "shared", "rebuilt", "added", "removed"
1919+ Layer LayerDetail // the "to" layer (or from-layer for "removed")
2020+ PrevLayer *LayerDetail // set for "rebuilt" — the old layer
2121+}
2222+2323+// VulnDiffEntry represents a vulnerability categorized by diff status.
2424+type VulnDiffEntry struct {
2525+ Status string // "fixed", "new", "unchanged"
2626+ Vuln vulnMatch
2727+}
2828+2929+// DiffSummary is the top-line summary for the banner and diff page.
3030+type DiffSummary struct {
3131+ SizeDelta int64 // bytes, positive = "to" is larger
3232+ LayerCountFrom int
3333+ LayerCountTo int
3434+ VulnFixedCount int
3535+ VulnNewCount int
3636+ VulnFixedBySev vulnSummary
3737+ VulnNewBySev vulnSummary
3838+ HasVulnData bool
3939+}
4040+4141+// layerKey returns the matching key for a layer — digest for real layers, command for empty layers.
4242+func layerKey(l LayerDetail) string {
4343+ if l.Command != "" {
4444+ return l.Command
4545+ }
4646+ return l.Digest
4747+}
4848+4949+// computeLayerDiff compares two ordered LayerDetail slices using LCS on commands (git diff style).
5050+// Handles insertions and deletions in the middle, not just prefix divergence.
5151+func computeLayerDiff(fromLayers, toLayers []LayerDetail) []LayerDiffEntry {
5252+ n := len(fromLayers)
5353+ m := len(toLayers)
5454+5555+ // Build LCS table on layer keys (command or digest)
5656+ dp := make([][]int, n+1)
5757+ for i := range dp {
5858+ dp[i] = make([]int, m+1)
5959+ }
6060+ for i := 1; i <= n; i++ {
6161+ for j := 1; j <= m; j++ {
6262+ if layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
6363+ dp[i][j] = dp[i-1][j-1] + 1
6464+ } else if dp[i-1][j] >= dp[i][j-1] {
6565+ dp[i][j] = dp[i-1][j]
6666+ } else {
6767+ dp[i][j] = dp[i][j-1]
6868+ }
6969+ }
7070+ }
7171+7272+ // Backtrack to produce the diff
7373+ var result []LayerDiffEntry
7474+ i, j := n, m
7575+ // Build in reverse, then flip
7676+ var rev []LayerDiffEntry
7777+ for i > 0 || j > 0 {
7878+ if i > 0 && j > 0 && layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
7979+ fl := fromLayers[i-1]
8080+ tl := toLayers[j-1]
8181+8282+ // Same key — check if digest also matches
8383+ sameDigest := false
8484+ if fl.EmptyLayer && tl.EmptyLayer {
8585+ sameDigest = true // empty layers matched by command
8686+ } else if !fl.EmptyLayer && !tl.EmptyLayer {
8787+ sameDigest = fl.Digest == tl.Digest
8888+ }
8989+9090+ if sameDigest {
9191+ rev = append(rev, LayerDiffEntry{Status: "shared", Layer: tl})
9292+ } else {
9393+ prevLayer := fl
9494+ rev = append(rev, LayerDiffEntry{Status: "rebuilt", Layer: tl, PrevLayer: &prevLayer})
9595+ }
9696+ i--
9797+ j--
9898+ } else if j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]) {
9999+ rev = append(rev, LayerDiffEntry{Status: "added", Layer: toLayers[j-1]})
100100+ j--
101101+ } else {
102102+ rev = append(rev, LayerDiffEntry{Status: "removed", Layer: fromLayers[i-1]})
103103+ i--
104104+ }
105105+ }
106106+107107+ // Reverse
108108+ result = make([]LayerDiffEntry, len(rev))
109109+ for k, v := range rev {
110110+ result[len(rev)-1-k] = v
111111+ }
112112+113113+ return result
114114+}
115115+116116+// computeVulnDiff compares two vulnerability match slices by CVE ID.
117117+func computeVulnDiff(fromMatches, toMatches []vulnMatch) []VulnDiffEntry {
118118+ fromSet := make(map[string]vulnMatch, len(fromMatches))
119119+ for _, m := range fromMatches {
120120+ fromSet[m.CVEID] = m
121121+ }
122122+123123+ toSet := make(map[string]vulnMatch, len(toMatches))
124124+ for _, m := range toMatches {
125125+ toSet[m.CVEID] = m
126126+ }
127127+128128+ var result []VulnDiffEntry
129129+130130+ // Fixed: in from but not to
131131+ for id, m := range fromSet {
132132+ if _, ok := toSet[id]; !ok {
133133+ result = append(result, VulnDiffEntry{Status: "fixed", Vuln: m})
134134+ }
135135+ }
136136+137137+ // New: in to but not from
138138+ for id, m := range toSet {
139139+ if _, ok := fromSet[id]; !ok {
140140+ result = append(result, VulnDiffEntry{Status: "new", Vuln: m})
141141+ }
142142+ }
143143+144144+ // Unchanged: in both
145145+ for id, m := range toSet {
146146+ if _, ok := fromSet[id]; ok {
147147+ result = append(result, VulnDiffEntry{Status: "unchanged", Vuln: m})
148148+ }
149149+ }
150150+151151+ return result
152152+}
153153+154154+// computeDiffSummary derives the top-line summary from layer and vuln diffs.
155155+func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData bool) DiffSummary {
156156+ var fromSize, toSize int64
157157+ for _, l := range fromLayers {
158158+ fromSize += l.Size
159159+ }
160160+ for _, l := range toLayers {
161161+ toSize += l.Size
162162+ }
163163+164164+ summary := DiffSummary{
165165+ SizeDelta: toSize - fromSize,
166166+ LayerCountFrom: len(fromLayers),
167167+ LayerCountTo: len(toLayers),
168168+ HasVulnData: hasVulnData,
169169+ }
170170+171171+ for _, entry := range vulnDiff {
172172+ switch entry.Status {
173173+ case "fixed":
174174+ summary.VulnFixedCount++
175175+ addToSevCount(&summary.VulnFixedBySev, entry.Vuln.Severity)
176176+ case "new":
177177+ summary.VulnNewCount++
178178+ addToSevCount(&summary.VulnNewBySev, entry.Vuln.Severity)
179179+ }
180180+ }
181181+182182+ return summary
183183+}
184184+185185+func addToSevCount(s *vulnSummary, severity string) {
186186+ switch severity {
187187+ case "Critical":
188188+ s.Critical++
189189+ case "High":
190190+ s.High++
191191+ case "Medium":
192192+ s.Medium++
193193+ case "Low":
194194+ s.Low++
195195+ }
196196+ s.Total++
197197+}
198198+199199+// ManifestDiffHandler renders the full diff page comparing two manifests.
200200+type ManifestDiffHandler struct {
201201+ BaseUIHandler
202202+}
203203+204204+func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
205205+ identifier := chi.URLParam(r, "handle")
206206+ // Route: /diff/{handle}/* — wildcard captures the repo name
207207+ repo := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
208208+ if repo == "" {
209209+ RenderNotFound(w, r, &h.BaseUIHandler)
210210+ return
211211+ }
212212+213213+ fromDigest := r.URL.Query().Get("from")
214214+ toDigest := r.URL.Query().Get("to")
215215+ if fromDigest == "" || toDigest == "" {
216216+ RenderNotFound(w, r, &h.BaseUIHandler)
217217+ return
218218+ }
219219+220220+ // Resolve identity
221221+ did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
222222+ if err != nil {
223223+ RenderNotFound(w, r, &h.BaseUIHandler)
224224+ return
225225+ }
226226+227227+ owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
228228+ if err != nil || owner == nil {
229229+ RenderNotFound(w, r, &h.BaseUIHandler)
230230+ return
231231+ }
232232+ if owner.Handle != resolvedHandle {
233233+ _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
234234+ owner.Handle = resolvedHandle
235235+ }
236236+237237+ // Fetch both manifests
238238+ type manifestData struct {
239239+ manifest *db.ManifestWithMetadata
240240+ layers []LayerDetail
241241+ vulnData *vulnDetailsData
242242+ err error
243243+ }
244244+245245+ // fetchManifest fetches layers and vulns for a digest.
246246+ // For manifest lists, it uses the provided platform child digest instead.
247247+ fetchManifest := func(digest, platformDigest string) manifestData {
248248+ m, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, digest)
249249+ if err != nil {
250250+ return manifestData{err: err}
251251+ }
252252+253253+ // For multi-arch, resolve to the platform child
254254+ layerManifest := m
255255+ layerDigest := digest
256256+ holdEndpoint := m.HoldEndpoint
257257+258258+ if m.IsManifestList && platformDigest != "" {
259259+ child, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, platformDigest)
260260+ if err == nil {
261261+ layerManifest = child
262262+ layerDigest = platformDigest
263263+ if child.HoldEndpoint != "" {
264264+ holdEndpoint = child.HoldEndpoint
265265+ }
266266+ }
267267+ }
268268+269269+ dbLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, layerManifest.ID)
270270+271271+ var layers []LayerDetail
272272+ var vulnData *vulnDetailsData
273273+274274+ hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
275275+ if holdErr == nil {
276276+ config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest)
277277+ if err == nil {
278278+ layers = buildLayerDetails(config.History, dbLayers)
279279+ } else {
280280+ layers = buildLayerDetails(nil, dbLayers)
281281+ }
282282+283283+ vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest)
284284+ vulnData = &vd
285285+ } else {
286286+ layers = buildLayerDetails(nil, dbLayers)
287287+ }
288288+289289+ return manifestData{manifest: m, layers: layers, vulnData: vulnData}
290290+ }
291291+292292+ // First fetch both top-level manifests to check for multi-arch
293293+ fromManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, fromDigest)
294294+ if err != nil {
295295+ RenderNotFound(w, r, &h.BaseUIHandler)
296296+ return
297297+ }
298298+ toManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, toDigest)
299299+ if err != nil {
300300+ RenderNotFound(w, r, &h.BaseUIHandler)
301301+ return
302302+ }
303303+304304+ // Find common platforms for multi-arch
305305+ var commonPlatforms []db.PlatformInfo
306306+ var selectedPlatform string
307307+ isMultiArch := fromManifest.IsManifestList && toManifest.IsManifestList
308308+309309+ fromPlatformDigest := ""
310310+ toPlatformDigest := ""
311311+312312+ if isMultiArch {
313313+ // Build intersection of platforms
314314+ for _, fp := range fromManifest.Platforms {
315315+ for _, tp := range toManifest.Platforms {
316316+ if fp.OS == tp.OS && fp.Architecture == tp.Architecture && fp.Variant == tp.Variant {
317317+ commonPlatforms = append(commonPlatforms, tp)
318318+ break
319319+ }
320320+ }
321321+ }
322322+323323+ // Use query param or default to first common platform
324324+ selectedPlatform = r.URL.Query().Get("platform")
325325+ if len(commonPlatforms) > 0 {
326326+ if selectedPlatform == "" {
327327+ selectedPlatform = commonPlatforms[0].OS + "/" + commonPlatforms[0].Architecture
328328+ if commonPlatforms[0].Variant != "" {
329329+ selectedPlatform += "/" + commonPlatforms[0].Variant
330330+ }
331331+ }
332332+ // Find matching platform digests
333333+ for _, fp := range fromManifest.Platforms {
334334+ platKey := fp.OS + "/" + fp.Architecture
335335+ if fp.Variant != "" {
336336+ platKey += "/" + fp.Variant
337337+ }
338338+ if platKey == selectedPlatform {
339339+ fromPlatformDigest = fp.Digest
340340+ break
341341+ }
342342+ }
343343+ for _, tp := range toManifest.Platforms {
344344+ platKey := tp.OS + "/" + tp.Architecture
345345+ if tp.Variant != "" {
346346+ platKey += "/" + tp.Variant
347347+ }
348348+ if platKey == selectedPlatform {
349349+ toPlatformDigest = tp.Digest
350350+ break
351351+ }
352352+ }
353353+ }
354354+ }
355355+356356+ // Fetch layer/vuln data in parallel
357357+ var fromData, toData manifestData
358358+ var wg sync.WaitGroup
359359+ wg.Add(2)
360360+ go func() {
361361+ defer wg.Done()
362362+ fromData = fetchManifest(fromDigest, fromPlatformDigest)
363363+ }()
364364+ go func() {
365365+ defer wg.Done()
366366+ toData = fetchManifest(toDigest, toPlatformDigest)
367367+ }()
368368+ wg.Wait()
369369+370370+ if fromData.err != nil || toData.err != nil {
371371+ RenderNotFound(w, r, &h.BaseUIHandler)
372372+ return
373373+ }
374374+375375+ // Compute diffs
376376+ layerDiff := computeLayerDiff(fromData.layers, toData.layers)
377377+378378+ var vulnDiff []VulnDiffEntry
379379+ hasVulnData := fromData.vulnData != nil && toData.vulnData != nil &&
380380+ fromData.vulnData.Error == "" && toData.vulnData.Error == ""
381381+ if hasVulnData {
382382+ vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
383383+ }
384384+385385+ summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData)
386386+387387+ // Determine tag labels
388388+ fromTag := fromDigest
389389+ if len(fromData.manifest.Tags) > 0 {
390390+ fromTag = fromData.manifest.Tags[0]
391391+ }
392392+ toTag := toDigest
393393+ if len(toData.manifest.Tags) > 0 {
394394+ toTag = toData.manifest.Tags[0]
395395+ }
396396+397397+ // Count vulns by status for template
398398+ var fixedVulns, newVulns, unchangedVulns []vulnMatch
399399+ for _, entry := range vulnDiff {
400400+ switch entry.Status {
401401+ case "fixed":
402402+ fixedVulns = append(fixedVulns, entry.Vuln)
403403+ case "new":
404404+ newVulns = append(newVulns, entry.Vuln)
405405+ case "unchanged":
406406+ unchangedVulns = append(unchangedVulns, entry.Vuln)
407407+ }
408408+ }
409409+410410+ title := fmt.Sprintf("Diff: %s → %s - %s/%s - %s", fromTag, toTag, owner.Handle, repo, h.ClientShortName)
411411+ description := fmt.Sprintf("Comparing %s to %s in %s/%s", fromTag, toTag, owner.Handle, repo)
412412+ meta := NewPageMeta(title, description).
413413+ WithCanonical(fmt.Sprintf("https://%s/diff/%s/%s?from=%s&to=%s", h.SiteURL, owner.Handle, repo, fromDigest, toDigest)).
414414+ WithSiteName(h.ClientShortName)
415415+416416+ data := struct {
417417+ PageData
418418+ Meta *PageMeta
419419+ Owner *db.User
420420+ Repository string
421421+ FromManifest *db.ManifestWithMetadata
422422+ ToManifest *db.ManifestWithMetadata
423423+ FromTag string
424424+ ToTag string
425425+ Summary DiffSummary
426426+ LayerDiff []LayerDiffEntry
427427+ FixedVulns []vulnMatch
428428+ NewVulns []vulnMatch
429429+ UnchangedVulns []vulnMatch
430430+ HasVulnData bool
431431+ IsMultiArch bool
432432+ CommonPlatforms []db.PlatformInfo
433433+ SelectedPlatform string
434434+ FromDigest string
435435+ ToDigest string
436436+ }{
437437+ PageData: NewPageData(r, &h.BaseUIHandler),
438438+ Meta: meta,
439439+ Owner: owner,
440440+ Repository: repo,
441441+ FromManifest: fromData.manifest,
442442+ ToManifest: toData.manifest,
443443+ FromTag: fromTag,
444444+ ToTag: toTag,
445445+ Summary: summary,
446446+ LayerDiff: layerDiff,
447447+ FixedVulns: fixedVulns,
448448+ NewVulns: newVulns,
449449+ UnchangedVulns: unchangedVulns,
450450+ HasVulnData: hasVulnData,
451451+ IsMultiArch: isMultiArch,
452452+ CommonPlatforms: commonPlatforms,
453453+ SelectedPlatform: selectedPlatform,
454454+ FromDigest: fromDigest,
455455+ ToDigest: toDigest,
456456+ }
457457+458458+ if err := h.Templates.ExecuteTemplate(w, "diff", data); err != nil {
459459+ slog.Warn("Failed to render diff page", "error", err)
460460+ http.Error(w, err.Error(), http.StatusInternalServerError)
461461+ }
462462+}