package handlers import ( "fmt" "log/slog" "net/http" "sort" "strings" "sync" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdclient" "atcr.io/pkg/atproto" "github.com/go-chi/chi/v5" ) // LayerDiffEntry represents one row in the layer diff table. type LayerDiffEntry struct { Status string // "shared", "rebuilt", "added", "removed" Layer LayerDetail // the "to" layer (or from-layer for "removed") PrevLayer *LayerDetail // set for "rebuilt" — the old layer } // VulnDiffEntry represents a vulnerability categorized by diff status. type VulnDiffEntry struct { Status string // "fixed", "new", "unchanged" Vuln vulnMatch } // SbomDiffEntry represents one package categorized by diff status. type SbomDiffEntry struct { Status string // "added", "removed", "changed", "unchanged" Package sbomPackage // the "to" package (or the "from" package for "removed") PrevVersion string // set for "changed" — the old version } // DiffSummary is the top-line summary for the banner and diff page. type DiffSummary struct { SizeDelta int64 // bytes, positive = "to" is larger LayerCountFrom int LayerCountTo int VulnFixedCount int VulnNewCount int VulnFixedBySev vulnSummary VulnNewBySev vulnSummary HasVulnData bool PkgAddedCount int PkgRemovedCount int PkgChangedCount int HasSbomData bool } // layerKey returns the matching key for a layer — digest for real layers, command for empty layers. func layerKey(l LayerDetail) string { if l.Command != "" { return l.Command } return l.Digest } // computeLayerDiff compares two ordered LayerDetail slices using LCS on commands (git diff style). // Handles insertions and deletions in the middle, not just prefix divergence. func computeLayerDiff(fromLayers, toLayers []LayerDetail) []LayerDiffEntry { n := len(fromLayers) m := len(toLayers) // Build LCS table on layer keys (command or digest) dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, m+1) } for i := 1; i <= n; i++ { for j := 1; j <= m; j++ { if layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) { dp[i][j] = dp[i-1][j-1] + 1 } else if dp[i-1][j] >= dp[i][j-1] { dp[i][j] = dp[i-1][j] } else { dp[i][j] = dp[i][j-1] } } } // Backtrack to produce the diff var result []LayerDiffEntry i, j := n, m // Build in reverse, then flip var rev []LayerDiffEntry for i > 0 || j > 0 { if i > 0 && j > 0 && layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) { fl := fromLayers[i-1] tl := toLayers[j-1] // Same key — check if digest also matches sameDigest := false if fl.EmptyLayer && tl.EmptyLayer { sameDigest = true // empty layers matched by command } else if !fl.EmptyLayer && !tl.EmptyLayer { sameDigest = fl.Digest == tl.Digest } if sameDigest { rev = append(rev, LayerDiffEntry{Status: "shared", Layer: tl}) } else { prevLayer := fl rev = append(rev, LayerDiffEntry{Status: "rebuilt", Layer: tl, PrevLayer: &prevLayer}) } i-- j-- } else if j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]) { rev = append(rev, LayerDiffEntry{Status: "added", Layer: toLayers[j-1]}) j-- } else { rev = append(rev, LayerDiffEntry{Status: "removed", Layer: fromLayers[i-1]}) i-- } } // Reverse result = make([]LayerDiffEntry, len(rev)) for k, v := range rev { result[len(rev)-1-k] = v } return result } // computeVulnDiff compares two vulnerability match slices by CVE ID. func computeVulnDiff(fromMatches, toMatches []vulnMatch) []VulnDiffEntry { fromSet := make(map[string]vulnMatch, len(fromMatches)) for _, m := range fromMatches { fromSet[m.CVEID] = m } toSet := make(map[string]vulnMatch, len(toMatches)) for _, m := range toMatches { toSet[m.CVEID] = m } var result []VulnDiffEntry // Fixed: in from but not to for id, m := range fromSet { if _, ok := toSet[id]; !ok { result = append(result, VulnDiffEntry{Status: "fixed", Vuln: m}) } } // New: in to but not from for id, m := range toSet { if _, ok := fromSet[id]; !ok { result = append(result, VulnDiffEntry{Status: "new", Vuln: m}) } } // Unchanged: in both for id, m := range toSet { if _, ok := fromSet[id]; ok { result = append(result, VulnDiffEntry{Status: "unchanged", Vuln: m}) } } return result } // computeSbomDiff compares two package lists. Identical entries // (name+type+version) match first as "unchanged"; leftovers pair by // name+type as "changed" (version bump); the rest are "added"/"removed". // Multiset counting keeps duplicate identical entries on one side from all // matching a single entry on the other, and the name+type grouping keeps a // deb package from pairing against a binary of the same name. func computeSbomDiff(fromPkgs, toPkgs []sbomPackage) []SbomDiffEntry { exactKey := func(p sbomPackage) string { return p.Name + "\x00" + p.Type + "\x00" + p.Version } nameKey := func(p sbomPackage) string { return p.Name + "\x00" + p.Type } // Exact pass: count from-side entries, consume per to-side match. fromCounts := make(map[string]int, len(fromPkgs)) for _, p := range fromPkgs { fromCounts[exactKey(p)]++ } var result []SbomDiffEntry var toLeft []sbomPackage for _, p := range toPkgs { if k := exactKey(p); fromCounts[k] > 0 { fromCounts[k]-- result = append(result, SbomDiffEntry{Status: "unchanged", Package: p}) } else { toLeft = append(toLeft, p) } } fromGroups := make(map[string][]sbomPackage) for _, p := range fromPkgs { if k := exactKey(p); fromCounts[k] > 0 { fromCounts[k]-- fromGroups[nameKey(p)] = append(fromGroups[nameKey(p)], p) } } // Version-change pass: pair leftovers that share name+type. Versions are // sorted lexically per group — determinism matters more than semver // correctness for pairing multiple installed versions. toGroups := make(map[string][]sbomPackage) for _, p := range toLeft { toGroups[nameKey(p)] = append(toGroups[nameKey(p)], p) } byVersion := func(pkgs []sbomPackage) { sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Version < pkgs[j].Version }) } for k, toGroup := range toGroups { fromGroup := fromGroups[k] byVersion(fromGroup) byVersion(toGroup) paired := min(len(fromGroup), len(toGroup)) for i := range paired { result = append(result, SbomDiffEntry{Status: "changed", Package: toGroup[i], PrevVersion: fromGroup[i].Version}) } for _, p := range toGroup[paired:] { result = append(result, SbomDiffEntry{Status: "added", Package: p}) } for _, p := range fromGroup[paired:] { result = append(result, SbomDiffEntry{Status: "removed", Package: p}) } delete(fromGroups, k) } for _, fromGroup := range fromGroups { for _, p := range fromGroup { result = append(result, SbomDiffEntry{Status: "removed", Package: p}) } } // Map iteration above is unordered — sort for stable rendering. sort.Slice(result, func(i, j int) bool { a, b := result[i].Package, result[j].Package if a.Name != b.Name { return a.Name < b.Name } if a.Type != b.Type { return a.Type < b.Type } if a.Version != b.Version { return a.Version < b.Version } return result[i].Status < result[j].Status }) return result } // computeDiffSummary derives the top-line summary from layer and vuln diffs. func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData bool, sbomDiff []SbomDiffEntry, hasSbomData bool) DiffSummary { var fromSize, toSize int64 for _, l := range fromLayers { fromSize += l.Size } for _, l := range toLayers { toSize += l.Size } summary := DiffSummary{ SizeDelta: toSize - fromSize, LayerCountFrom: len(fromLayers), LayerCountTo: len(toLayers), HasVulnData: hasVulnData, HasSbomData: hasSbomData, } for _, entry := range vulnDiff { switch entry.Status { case "fixed": summary.VulnFixedCount++ addToSevCount(&summary.VulnFixedBySev, entry.Vuln.Severity) case "new": summary.VulnNewCount++ addToSevCount(&summary.VulnNewBySev, entry.Vuln.Severity) } } for _, entry := range sbomDiff { switch entry.Status { case "added": summary.PkgAddedCount++ case "removed": summary.PkgRemovedCount++ case "changed": summary.PkgChangedCount++ } } return summary } func addToSevCount(s *vulnSummary, severity string) { // Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land // in the same bucket. Unknown severities count toward the total but don't // bump any bucket — the template renders them as "Unknown" via the // severityLabel helper. switch strings.ToLower(strings.TrimSpace(severity)) { case "critical", "crit", "c": s.Critical++ case "high", "h": s.High++ case "medium", "med", "m": s.Medium++ case "low", "l": s.Low++ } s.Total++ } // sbomDiffStatus maps one side's SBOM fetch result to a status the template // can branch on: "ok", "no-data" (no scan record or SBOM blob), // "not-applicable" (scanner skipped this artifact type), or // "hold-unreachable" (couldn't resolve or reach the hold). func sbomDiffStatus(d *sbomDetailsData) string { switch { case d == nil: return "hold-unreachable" case d.Status == atproto.ScanStatusSkipped: return "not-applicable" case d.Error != "": return "no-data" default: return "ok" } } // ManifestDiffHandler renders the full diff page comparing two manifests. type ManifestDiffHandler struct { BaseUIHandler } func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { identifier := chi.URLParam(r, "handle") // Route: /diff/{handle}/* — wildcard captures the repo name repo := strings.TrimPrefix(chi.URLParam(r, "*"), "/") if repo == "" { RenderNotFound(w, r, &h.BaseUIHandler) return } fromParam := r.URL.Query().Get("from") toParam := r.URL.Query().Get("to") if fromParam == "" || toParam == "" { RenderNotFound(w, r, &h.BaseUIHandler) return } // Resolve identity did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) if err != nil { RenderNotFound(w, r, &h.BaseUIHandler) return } owner, err := db.GetUserByDID(h.ReadOnlyDB, did) if err != nil || owner == nil { RenderNotFound(w, r, &h.BaseUIHandler) return } if owner.Handle != resolvedHandle { _ = db.UpdateUserHandle(h.DB, did, resolvedHandle) owner.Handle = resolvedHandle } // Resolve from/to params — accept either digests (sha256:...) or tag names fromDigest := fromParam toDigest := toParam if !strings.HasPrefix(fromDigest, "sha256:") { tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam) if err != nil || tag == nil { RenderNotFound(w, r, &h.BaseUIHandler) return } fromDigest = tag.Digest } if !strings.HasPrefix(toDigest, "sha256:") { tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam) if err != nil || tag == nil { RenderNotFound(w, r, &h.BaseUIHandler) return } toDigest = tag.Digest } // Fetch both manifests type manifestData struct { manifest *db.ManifestWithMetadata layers []LayerDetail vulnData *vulnDetailsData sbomData *sbomDetailsData err error } // fetchManifest fetches layers and vulns for a digest. // For manifest lists, it uses the provided platform child digest instead. fetchManifest := func(digest, platformDigest string) manifestData { m, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, digest) if err != nil { return manifestData{err: err} } // For multi-arch, resolve to the platform child layerManifest := m layerDigest := digest holdEndpoint := m.HoldEndpoint if m.IsManifestList && platformDigest != "" { child, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, platformDigest) if err == nil { layerManifest = child layerDigest = platformDigest if child.HoldEndpoint != "" { holdEndpoint = child.HoldEndpoint } } } dbLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, layerManifest.ID) var layers []LayerDetail var vulnData *vulnDetailsData var sbomData *sbomDetailsData hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint) if holdErr == nil { // Parallelize the three hold fetches. They're independent and // each takes a network round-trip; serial runs add up on slow links. var fwg sync.WaitGroup fwg.Add(3) go func() { defer fwg.Done() config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest) if err == nil { layers = buildLayerDetails(config.History, dbLayers) } else { layers = buildLayerDetails(nil, dbLayers) } }() go func() { defer fwg.Done() vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest) vulnData = &vd }() go func() { defer fwg.Done() sd := FetchSbomDetails(r.Context(), hold.DID, layerDigest) sbomData = &sd }() fwg.Wait() } else { layers = buildLayerDetails(nil, dbLayers) } return manifestData{manifest: m, layers: layers, vulnData: vulnData, sbomData: sbomData} } // First fetch both top-level manifests to check for multi-arch fromManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, fromDigest) if err != nil { RenderNotFound(w, r, &h.BaseUIHandler) return } toManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, toDigest) if err != nil { RenderNotFound(w, r, &h.BaseUIHandler) return } // Find common platforms for multi-arch var commonPlatforms []db.PlatformInfo var selectedPlatform string isMultiArch := fromManifest.IsManifestList && toManifest.IsManifestList fromPlatformDigest := "" toPlatformDigest := "" // platKey returns "os/arch[/variant]" for a platform. platKey := func(os, arch, variant string) string { k := os + "/" + arch if variant != "" { k += "/" + variant } return k } // pickPlatformChild returns the child digest from a manifest list whose // platform matches the given key. Returns "" if no match. pickPlatformChild := func(m *db.ManifestWithMetadata, key string) string { for _, p := range m.Platforms { if platKey(p.OS, p.Architecture, p.Variant) == key { return p.Digest } } return "" } if isMultiArch { // Build intersection of platforms for _, fp := range fromManifest.Platforms { for _, tp := range toManifest.Platforms { if fp.OS == tp.OS && fp.Architecture == tp.Architecture && fp.Variant == tp.Variant { commonPlatforms = append(commonPlatforms, tp) break } } } // Use query param or default to first common platform selectedPlatform = r.URL.Query().Get("platform") if len(commonPlatforms) > 0 { if selectedPlatform == "" { selectedPlatform = platKey(commonPlatforms[0].OS, commonPlatforms[0].Architecture, commonPlatforms[0].Variant) } fromPlatformDigest = pickPlatformChild(fromManifest, selectedPlatform) toPlatformDigest = pickPlatformChild(toManifest, selectedPlatform) } } else if fromManifest.IsManifestList != toManifest.IsManifestList { // Mixed: one side is a manifest list, the other is a platform child. // Match them by looking up the single-arch side's platform via its // parent manifest_references row and picking the matching child from // the manifest list side. var listSide *db.ManifestWithMetadata var childDigest string if fromManifest.IsManifestList { listSide = fromManifest childDigest = toDigest } else { listSide = toManifest childDigest = fromDigest } plat, _ := db.GetChildManifestPlatform(h.ReadOnlyDB, owner.DID, repo, childDigest) var listChildDigest string if plat != nil { listChildDigest = pickPlatformChild(listSide, platKey(plat.OS, plat.Architecture, plat.Variant)) } // Fallback: if we couldn't determine the platform (or no match), // default to the first non-attestation child of the manifest list so // the diff at least shows real layers instead of an empty index. if listChildDigest == "" { for _, p := range listSide.Platforms { if p.Digest != "" { listChildDigest = p.Digest break } } } if fromManifest.IsManifestList { fromPlatformDigest = listChildDigest } else { toPlatformDigest = listChildDigest } } // Fetch layer/vuln data in parallel var fromData, toData manifestData var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() fromData = fetchManifest(fromDigest, fromPlatformDigest) }() go func() { defer wg.Done() toData = fetchManifest(toDigest, toPlatformDigest) }() wg.Wait() // Track per-side fetch failures so we render the page with an inline // alert naming which tag failed, instead of a generic 404 that makes // users guess whether they typoed a tag or hit a transient outage. // fromData.manifest / toData.manifest is nil only when the re-fetch at // the top of fetchManifest hit a DB error (the tag resolution earlier // already ruled out typos). fromFailed := fromData.err != nil || fromData.manifest == nil toFailed := toData.err != nil || toData.manifest == nil // Fall back to the top-level manifest we already fetched so the page // still has something to render for tag labels and metadata. if fromFailed { fromData.manifest = fromManifest } if toFailed { toData.manifest = toManifest } // Compute diffs layerDiff := computeLayerDiff(fromData.layers, toData.layers) // ScanStatus distinguishes why vuln data may be missing: "ok" when both // sides returned clean scan results; "no-data" when a scan was never // recorded; "hold-unreachable" when we couldn't reach the hold to ask. // The template branches on these so users can tell "not scanned yet" // from "hold offline" at a glance. fromScanStatus := "ok" toScanStatus := "ok" if fromData.vulnData == nil { fromScanStatus = "hold-unreachable" } else if fromData.vulnData.Error != "" { fromScanStatus = "no-data" } if toData.vulnData == nil { toScanStatus = "hold-unreachable" } else if toData.vulnData.Error != "" { toScanStatus = "no-data" } var vulnDiff []VulnDiffEntry hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok" if hasVulnData { vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches) } // SBOM status mirrors the scan status, with one extra case: the scanner // records status="skipped" for artifact types it doesn't scan, which the // template surfaces as "not applicable" rather than "not scanned yet". fromSbomStatus := sbomDiffStatus(fromData.sbomData) toSbomStatus := sbomDiffStatus(toData.sbomData) var sbomDiff []SbomDiffEntry hasSbomData := fromSbomStatus == "ok" && toSbomStatus == "ok" if hasSbomData { sbomDiff = computeSbomDiff(fromData.sbomData.Packages, toData.sbomData.Packages) } summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData, sbomDiff, hasSbomData) // Determine tag labels fromTag := fromDigest if len(fromData.manifest.Tags) > 0 { fromTag = fromData.manifest.Tags[0] } toTag := toDigest if len(toData.manifest.Tags) > 0 { toTag = toData.manifest.Tags[0] } // Count vulns by status for template var fixedVulns, newVulns, unchangedVulns []vulnMatch for _, entry := range vulnDiff { switch entry.Status { case "fixed": fixedVulns = append(fixedVulns, entry.Vuln) case "new": newVulns = append(newVulns, entry.Vuln) case "unchanged": unchangedVulns = append(unchangedVulns, entry.Vuln) } } // Split packages by status for template var addedPackages, removedPackages, changedPackages, unchangedPackages []SbomDiffEntry for _, entry := range sbomDiff { switch entry.Status { case "added": addedPackages = append(addedPackages, entry) case "removed": removedPackages = append(removedPackages, entry) case "changed": changedPackages = append(changedPackages, entry) case "unchanged": unchangedPackages = append(unchangedPackages, entry) } } title := fmt.Sprintf("Diff: %s → %s - %s/%s - %s", fromTag, toTag, owner.Handle, repo, h.ClientShortName) description := fmt.Sprintf("Comparing %s to %s in %s/%s", fromTag, toTag, owner.Handle, repo) meta := NewPageMeta(title, description). WithCanonical(fmt.Sprintf("https://%s/diff/%s/%s?from=%s&to=%s", h.SiteURL, owner.Handle, repo, fromDigest, toDigest)). WithSiteName(h.ClientShortName) data := struct { PageData Meta *PageMeta Owner *db.User Repository string FromManifest *db.ManifestWithMetadata ToManifest *db.ManifestWithMetadata FromTag string ToTag string Summary DiffSummary LayerDiff []LayerDiffEntry FixedVulns []vulnMatch NewVulns []vulnMatch UnchangedVulns []vulnMatch HasVulnData bool FromScanStatus string ToScanStatus string AddedPackages []SbomDiffEntry RemovedPackages []SbomDiffEntry ChangedPackages []SbomDiffEntry UnchangedPackages []SbomDiffEntry HasSbomData bool FromSbomStatus string ToSbomStatus string FromFailed bool ToFailed bool IsMultiArch bool CommonPlatforms []db.PlatformInfo SelectedPlatform string FromDigest string ToDigest string }{ PageData: NewPageData(r, &h.BaseUIHandler), Meta: meta, Owner: owner, Repository: repo, FromManifest: fromData.manifest, ToManifest: toData.manifest, FromTag: fromTag, ToTag: toTag, Summary: summary, LayerDiff: layerDiff, FixedVulns: fixedVulns, NewVulns: newVulns, UnchangedVulns: unchangedVulns, HasVulnData: hasVulnData, FromScanStatus: fromScanStatus, ToScanStatus: toScanStatus, AddedPackages: addedPackages, RemovedPackages: removedPackages, ChangedPackages: changedPackages, UnchangedPackages: unchangedPackages, HasSbomData: hasSbomData, FromSbomStatus: fromSbomStatus, ToSbomStatus: toSbomStatus, FromFailed: fromFailed, ToFailed: toFailed, IsMultiArch: isMultiArch, CommonPlatforms: commonPlatforms, SelectedPlatform: selectedPlatform, FromDigest: fromDigest, ToDigest: toDigest, } if err := h.Templates.ExecuteTemplate(w, "diff", data); err != nil { slog.Warn("Failed to render diff page", "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) } }