From d6816fd00eccf2f007ee6ea711644db118ec6310 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 22 Mar 2026 21:17:28 -0500 Subject: [PATCH] add new files for getting image configs from hold etc --- .../io/atcr/hold/getLayersForManifest.json | 37 +++ lexicons/io/atcr/hold/image/config.json | 32 +++ lexicons/io/atcr/hold/image/getConfig.json | 28 +++ pkg/appview/handlers/digest.go | 178 +++++++++++++++ pkg/appview/handlers/digest_test.go | 143 ++++++++++++ pkg/appview/holdclient/config.go | 69 ++++++ pkg/appview/templates/pages/digest.html | 215 ++++++++++++++++++ pkg/appview/templates/partials/repo-tags.html | 177 ++++++++++++++ pkg/hold/pds/imageconfig.go | 53 +++++ 9 files changed, 932 insertions(+) create mode 100644 lexicons/io/atcr/hold/getLayersForManifest.json create mode 100644 lexicons/io/atcr/hold/image/config.json create mode 100644 lexicons/io/atcr/hold/image/getConfig.json create mode 100644 pkg/appview/handlers/digest.go create mode 100644 pkg/appview/handlers/digest_test.go create mode 100644 pkg/appview/holdclient/config.go create mode 100644 pkg/appview/templates/pages/digest.html create mode 100644 pkg/appview/templates/partials/repo-tags.html create mode 100644 pkg/hold/pds/imageconfig.go diff --git a/lexicons/io/atcr/hold/getLayersForManifest.json b/lexicons/io/atcr/hold/getLayersForManifest.json new file mode 100644 index 0000000..939726d --- /dev/null +++ b/lexicons/io/atcr/hold/getLayersForManifest.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "io.atcr.hold.getLayersForManifest", + "defs": { + "main": { + "type": "query", + "description": "Returns layer records for a specific manifest AT-URI.", + "parameters": { + "type": "params", + "required": ["manifest"], + "properties": { + "manifest": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the manifest to get layers for" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["layers"], + "properties": { + "layers": { + "type": "array", + "items": { + "type": "ref", + "ref": "io.atcr.hold.layer" + } + } + } + } + } + } + } +} diff --git a/lexicons/io/atcr/hold/image/config.json b/lexicons/io/atcr/hold/image/config.json new file mode 100644 index 0000000..bfaf8e7 --- /dev/null +++ b/lexicons/io/atcr/hold/image/config.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "io.atcr.hold.image.config", + "defs": { + "main": { + "type": "record", + "key": "any", + "description": "OCI image configuration for a container manifest. Stored in the hold's embedded PDS. Record key is the manifest digest hex without the 'sha256:' prefix (deterministic, one per manifest). Contains the full OCI config JSON including history (Dockerfile commands), environment variables, entrypoint, labels, etc.", + "record": { + "type": "object", + "required": ["manifest", "configJson", "createdAt"], + "properties": { + "manifest": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the manifest this config belongs to" + }, + "configJson": { + "type": "string", + "description": "Raw OCI image config JSON blob", + "maxLength": 65536 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "RFC3339 timestamp of when the config was stored" + } + } + } + } + } +} diff --git a/lexicons/io/atcr/hold/image/getConfig.json b/lexicons/io/atcr/hold/image/getConfig.json new file mode 100644 index 0000000..d6dde42 --- /dev/null +++ b/lexicons/io/atcr/hold/image/getConfig.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "io.atcr.hold.image.getConfig", + "defs": { + "main": { + "type": "query", + "description": "Returns the OCI image config record for a specific manifest digest.", + "parameters": { + "type": "params", + "required": ["digest"], + "properties": { + "digest": { + "type": "string", + "description": "Manifest digest (e.g., sha256:abc123...)", + "maxLength": 128 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "io.atcr.hold.image.config" + } + } + } + } +} diff --git a/pkg/appview/handlers/digest.go b/pkg/appview/handlers/digest.go new file mode 100644 index 0000000..8851c34 --- /dev/null +++ b/pkg/appview/handlers/digest.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "log/slog" + "net/http" + "strings" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/holdclient" + "atcr.io/pkg/atproto" + "github.com/go-chi/chi/v5" +) + +// LayerDetail combines OCI config history with layer metadata from the DB. +type LayerDetail struct { + Index int + Command string // Dockerfile command (from config history) + Digest string + Size int64 + MediaType string + EmptyLayer bool // ENV, LABEL, etc. — no actual layer blob +} + +// DigestDetailHandler renders the digest detail page with layers + vulnerabilities. +type DigestDetailHandler struct { + BaseUIHandler +} + +func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + identifier := chi.URLParam(r, "handle") + // The route is /d/{handle}/*/* — first * is repo, second * is digest + // chi captures both wildcards, so we need to split the path + pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2) + if len(pathParts) < 2 { + RenderNotFound(w, r, &h.BaseUIHandler) + return + } + repository := pathParts[0] + digest := pathParts[1] + + // Resolve identity + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier) + if err != nil { + RenderNotFound(w, r, &h.BaseUIHandler) + return + } + + owner, err := db.GetUserByDID(h.ReadOnlyDB, did) + if err != nil || owner == nil { + RenderNotFound(w, r, &h.BaseUIHandler) + return + } + if owner.Handle != resolvedHandle { + _ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle) + owner.Handle = resolvedHandle + } + + // Fetch manifest details + manifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repository, digest) + if err != nil { + RenderNotFound(w, r, &h.BaseUIHandler) + return + } + + // Build layer details + var layers []LayerDetail + var vulnData *vulnDetailsData + + if manifest.IsManifestList { + // Manifest list: no layers, show platform picker + // Platforms are already populated by GetManifestDetail + } else { + // Single manifest: fetch layers from DB + dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) + if err != nil { + slog.Warn("Failed to fetch layers", "error", err) + } + + // Resolve hold endpoint (follow successor if migrated) + hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) + + // Fetch OCI image config from hold for layer history (including empty layers) + if holdErr == nil { + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) + if err == nil { + layers = buildLayerDetails(config.History, dbLayers) + } else { + slog.Warn("Failed to fetch image config", "error", err, + "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) + layers = buildLayerDetails(nil, dbLayers) + } + } else { + layers = buildLayerDetails(nil, dbLayers) + } + + // Fetch vulnerability details + if holdErr == nil { + vd := FetchVulnDetails(r.Context(), hold.DID, digest) + vulnData = &vd + } + } + + // Build page meta + title := truncateDigestStr(digest, 16) + " - " + owner.Handle + "/" + repository + " - " + h.ClientShortName + description := "Image digest " + digest + " in " + owner.Handle + "/" + repository + + meta := NewPageMeta(title, description). + WithCanonical("https://" + h.SiteURL + "/d/" + owner.Handle + "/" + repository + "/" + digest). + WithSiteName(h.ClientShortName) + + data := struct { + PageData + Meta *PageMeta + Owner *db.User + Repository string + Manifest *db.ManifestWithMetadata + Layers []LayerDetail + VulnData *vulnDetailsData + }{ + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Owner: owner, + Repository: repository, + Manifest: manifest, + Layers: layers, + VulnData: vulnData, + } + + if err := h.Templates.ExecuteTemplate(w, "digest", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// buildLayerDetails correlates OCI config history entries with layer metadata. +// History entries with empty_layer=true have no corresponding layer blob. +func buildLayerDetails(history []holdclient.OCIHistoryEntry, dbLayers []db.Layer) []LayerDetail { + var details []LayerDetail + layerIdx := 0 + + if len(history) == 0 { + // No config history available — just list layers from DB + for _, l := range dbLayers { + details = append(details, LayerDetail{ + Index: l.LayerIndex + 1, + Digest: l.Digest, + Size: l.Size, + MediaType: l.MediaType, + }) + } + return details + } + + for i, h := range history { + ld := LayerDetail{ + Index: i + 1, + Command: h.CreatedBy, + EmptyLayer: h.EmptyLayer, + } + + if !h.EmptyLayer && layerIdx < len(dbLayers) { + ld.Digest = dbLayers[layerIdx].Digest + ld.Size = dbLayers[layerIdx].Size + ld.MediaType = dbLayers[layerIdx].MediaType + layerIdx++ + } + + details = append(details, ld) + } + + return details +} + +func truncateDigestStr(s string, length int) string { + if len(s) <= length { + return s + } + return s[:length] + "..." +} diff --git a/pkg/appview/handlers/digest_test.go b/pkg/appview/handlers/digest_test.go new file mode 100644 index 0000000..f9146e0 --- /dev/null +++ b/pkg/appview/handlers/digest_test.go @@ -0,0 +1,143 @@ +package handlers + +import ( + "testing" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/holdclient" +) + +func TestBuildLayerDetails_NoHistory(t *testing.T) { + dbLayers := []db.Layer{ + {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, + {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, + } + + details := buildLayerDetails(nil, dbLayers) + + if len(details) != 2 { + t.Fatalf("Expected 2 layer details, got %d", len(details)) + } + if details[0].Command != "" { + t.Errorf("Expected empty command, got %q", details[0].Command) + } + if details[0].Digest != "sha256:aaa" { + t.Errorf("Expected digest sha256:aaa, got %q", details[0].Digest) + } + if details[0].Index != 1 { + t.Errorf("Expected Index 1, got %d", details[0].Index) + } +} + +func TestBuildLayerDetails_WithHistory(t *testing.T) { + history := []holdclient.OCIHistoryEntry{ + {CreatedBy: "ENV PATH=/usr/local/bin", EmptyLayer: true}, + {CreatedBy: "RUN apt-get install curl", EmptyLayer: false}, + {CreatedBy: "COPY . /app", EmptyLayer: false}, + } + + dbLayers := []db.Layer{ + {Digest: "sha256:aaa", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, + {Digest: "sha256:bbb", Size: 2048, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, + } + + details := buildLayerDetails(history, dbLayers) + + if len(details) != 3 { + t.Fatalf("Expected 3 details (1 empty + 2 real), got %d", len(details)) + } + + // First entry is empty layer (ENV) + if !details[0].EmptyLayer { + t.Error("Expected first detail to be empty layer") + } + if details[0].Command != "ENV PATH=/usr/local/bin" { + t.Errorf("Expected ENV command, got %q", details[0].Command) + } + if details[0].Digest != "" { + t.Errorf("Expected empty digest for empty layer, got %q", details[0].Digest) + } + + // Second entry is real layer with digest + if details[1].EmptyLayer { + t.Error("Expected second detail to not be empty layer") + } + if details[1].Digest != "sha256:aaa" { + t.Errorf("Expected digest sha256:aaa, got %q", details[1].Digest) + } + if details[1].Command != "RUN apt-get install curl" { + t.Errorf("Expected RUN command, got %q", details[1].Command) + } + + // Third entry is real layer + if details[2].Digest != "sha256:bbb" { + t.Errorf("Expected digest sha256:bbb, got %q", details[2].Digest) + } + if details[2].Command != "COPY . /app" { + t.Errorf("Expected COPY command, got %q", details[2].Command) + } +} + +func TestBuildLayerDetails_DistrolessWithUserLayers(t *testing.T) { + // Simulates distroless base (many empty layers) + user COPY layers + history := []holdclient.OCIHistoryEntry{ + {CreatedBy: "bazel build ...", EmptyLayer: false}, // distroless base layer + {CreatedBy: "LABEL maintainer=distroless", EmptyLayer: true}, // metadata + {CreatedBy: "ENV SSL_CERT_DIR=/etc/ssl/certs", EmptyLayer: true}, + {CreatedBy: "COPY /app /app # buildkit", EmptyLayer: false}, // user layer + } + + dbLayers := []db.Layer{ + {Digest: "sha256:base", Size: 5000000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0}, + {Digest: "sha256:user", Size: 1024, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1}, + } + + details := buildLayerDetails(history, dbLayers) + + if len(details) != 4 { + t.Fatalf("Expected 4 details (2 real + 2 empty), got %d", len(details)) + } + + // Real layer + if details[0].EmptyLayer || details[0].Digest != "sha256:base" { + t.Errorf("Layer 0: expected real layer with sha256:base, got empty=%v digest=%q", details[0].EmptyLayer, details[0].Digest) + } + + // Empty layers + if !details[1].EmptyLayer || details[1].Command != "LABEL maintainer=distroless" { + t.Errorf("Layer 1: expected empty LABEL layer") + } + if !details[2].EmptyLayer || details[2].Command != "ENV SSL_CERT_DIR=/etc/ssl/certs" { + t.Errorf("Layer 2: expected empty ENV layer") + } + + // User layer + if details[3].EmptyLayer || details[3].Digest != "sha256:user" { + t.Errorf("Layer 3: expected real layer with sha256:user, got empty=%v digest=%q", details[3].EmptyLayer, details[3].Digest) + } + if details[3].Command != "COPY /app /app # buildkit" { + t.Errorf("Layer 3: Command = %q, want COPY command", details[3].Command) + } +} + +func TestBuildLayerDetails_AllEmptyLayers(t *testing.T) { + // Edge case: all history entries are empty layers (scratch-based with only metadata) + history := []holdclient.OCIHistoryEntry{ + {CreatedBy: "ENV FOO=bar", EmptyLayer: true}, + {CreatedBy: "LABEL version=1.0", EmptyLayer: true}, + } + + details := buildLayerDetails(history, nil) + + if len(details) != 2 { + t.Fatalf("Expected 2 details, got %d", len(details)) + } + for i, d := range details { + if !d.EmptyLayer { + t.Errorf("Layer %d: expected empty layer", i) + } + if d.Digest != "" { + t.Errorf("Layer %d: expected empty digest, got %q", i, d.Digest) + } + } +} diff --git a/pkg/appview/holdclient/config.go b/pkg/appview/holdclient/config.go new file mode 100644 index 0000000..bdaba93 --- /dev/null +++ b/pkg/appview/holdclient/config.go @@ -0,0 +1,69 @@ +package holdclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "atcr.io/pkg/atproto" +) + +// OCIHistoryEntry represents a single entry in the OCI config history. +type OCIHistoryEntry struct { + Created string `json:"created"` + CreatedBy string `json:"created_by"` + EmptyLayer bool `json:"empty_layer"` + Comment string `json:"comment"` +} + +// OCIConfig represents the parsed OCI image config with fields useful for display. +type OCIConfig struct { + History []OCIHistoryEntry `json:"history"` +} + +// FetchImageConfig fetches the OCI image config record from the hold's +// getImageConfig XRPC endpoint. holdURL should be a resolved HTTP(S) URL. +// Returns the parsed OCI config with history entries. +func FetchImageConfig(ctx context.Context, holdURL, manifestDigest string) (*OCIConfig, error) { + reqURL := fmt.Sprintf("%s%s?digest=%s", + strings.TrimSuffix(holdURL, "/"), + atproto.HoldGetImageConfig, + url.QueryEscape(manifestDigest), + ) + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch image config: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hold returned status %d for %s", resp.StatusCode, reqURL) + } + + var record struct { + ConfigJSON string `json:"configJson"` + } + if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { + return nil, fmt.Errorf("parse image config response: %w", err) + } + + var config OCIConfig + if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil { + return nil, fmt.Errorf("parse OCI config JSON: %w", err) + } + + return &config, nil +} diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html new file mode 100644 index 0000000..718b118 --- /dev/null +++ b/pkg/appview/templates/pages/digest.html @@ -0,0 +1,215 @@ +{{ define "digest" }} + + + + {{ template "head" . }} + {{ template "meta" .Meta }} + + + {{ template "nav" . }} + +
+
+ + + + +
+
+
+

{{ .Manifest.Digest }}

+
+ {{ if .Manifest.Tags }} + {{ range .Manifest.Tags }} + {{ . }} + {{ end }} + {{ end }} + {{ if .Manifest.IsManifestList }} + Multi-arch + {{ else if eq .Manifest.ArtifactType "helm-chart" }} + {{ icon "helm" "size-3" }} Helm + {{ end }} + {{ if .Manifest.HasAttestations }} + {{ icon "shield-check" "size-3" }} Attested + {{ end }} +
+
+
+ {{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }} + +
+
+
+ + {{ if .Manifest.IsManifestList }} + +
+

Platforms

+

This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.

+ +
+ {{ else }} + +
+ +
+
+

Layers ({{ len .Layers }})

+ +
+ {{ if .Layers }} +
+ + + + + + + + + + {{ range .Layers }} + + + + + + {{ end }} + +
#CommandSize
{{ .Index }} + {{ if .Command }} + {{ .Command }} + {{ end }} + {{ humanizeBytes .Size }}
+
+ + {{ else }} +

No layer information available

+ {{ end }} +
+ + +
+

Vulnerabilities

+ {{ if .VulnData }} + {{ template "vuln-details" .VulnData }} + {{ else }} +

No vulnerability scan data available

+ {{ end }} +
+
+ {{ end }} +
+
+ + {{ template "footer" . }} + + +{{ end }} diff --git a/pkg/appview/templates/partials/repo-tags.html b/pkg/appview/templates/partials/repo-tags.html new file mode 100644 index 0000000..2285e57 --- /dev/null +++ b/pkg/appview/templates/partials/repo-tags.html @@ -0,0 +1,177 @@ +{{ define "artifact-entry-markup" }} +
+ +
+
+ {{ .Entry.Label }} + {{ if eq .Entry.ArtifactType "helm-chart" }} + {{ icon "helm" "size-3" }} Helm + {{ else if .Entry.IsMultiArch }} + Multi-arch + {{ end }} + {{ if .Entry.HasAttestations }} + + {{ end }} +
+
+ {{ if eq .Entry.ArtifactType "helm-chart" }} + {{ if .Entry.IsTagged }} + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Entry.Label) }} + {{ else }} + {{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} + {{ end }} + {{ else }} + {{ if .Entry.IsTagged }} + {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }} + {{ else }} + {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} + {{ end }} + {{ end }} + {{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }} + {{ if .IsOwner }} + {{ if .Entry.IsTagged }} + + {{ else }} + + {{ end }} + {{ end }} +
+
+ + + + + + + + + + + + + + + + + + + {{ range .Entry.Platforms }} + + + + + + + {{ end }} + +
DigestVulnerabilitiesOS/ArchSize
+
+ {{ truncateDigest .Digest 32 }} + +
+
+ {{ if .HoldEndpoint }} + + + + {{ end }} + + {{ if .OS }}{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}{{ else }}-{{ end }} + {{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}
+
+{{ end }} + +{{ define "load-more-button" }} +{{ if .HasMore }} +
+ +
+{{ end }} +{{ end }} + +{{ define "scan-batch-triggers" }} +{{ range .ScanBatchParams }} +
+{{ end }} +{{ end }} + +{{ define "repo-tags" }} +
+ {{ if .Entries }} + +
+
+ Sort by + +
+
+ +
+ {{ if $.IsOwner }} +
+ +
+ {{ end }} +
+ + +
+
+ {{ range .Entries }} + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} + {{ end }} +
+ {{ template "load-more-button" . }} +
+ + {{ template "scan-batch-triggers" . }} + {{ else }} +

No artifacts available

+ {{ end }} +
+{{ end }} + +{{ define "repo-tags-page" }} +{{ range .Entries }} + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} +{{ end }} +{{ template "load-more-button" . }} +{{ template "scan-batch-triggers" . }} +{{ end }} diff --git a/pkg/hold/pds/imageconfig.go b/pkg/hold/pds/imageconfig.go new file mode 100644 index 0000000..e7a3e92 --- /dev/null +++ b/pkg/hold/pds/imageconfig.go @@ -0,0 +1,53 @@ +package pds + +import ( + "context" + "fmt" + + "atcr.io/pkg/atproto" + "github.com/ipfs/go-cid" +) + +// CreateImageConfigRecord creates or updates an OCI image config record in the hold's PDS. +// Uses a deterministic rkey based on the manifest digest, so re-pushes upsert. +func (p *HoldPDS) CreateImageConfigRecord(ctx context.Context, record *atproto.ImageConfigRecord, manifestDigest string) (string, cid.Cid, error) { + if record.Type != atproto.ImageConfigCollection { + return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type) + } + + if record.Manifest == "" { + return "", cid.Undef, fmt.Errorf("manifest AT-URI is required") + } + + rkey := atproto.ScanRecordKey(manifestDigest) + + rpath, recordCID, _, err := p.repomgr.UpsertRecord( + ctx, + p.uid, + atproto.ImageConfigCollection, + rkey, + record, + ) + if err != nil { + return "", cid.Undef, fmt.Errorf("failed to upsert image config record: %w", err) + } + + return rpath, recordCID, nil +} + +// GetImageConfigRecord retrieves an OCI image config record by manifest digest. +func (p *HoldPDS) GetImageConfigRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ImageConfigRecord, error) { + rkey := atproto.ScanRecordKey(manifestDigest) + + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ImageConfigCollection, rkey, cid.Undef) + if err != nil { + return cid.Undef, nil, fmt.Errorf("failed to get image config record: %w", err) + } + + configRecord, ok := val.(*atproto.ImageConfigRecord) + if !ok { + return cid.Undef, nil, fmt.Errorf("unexpected type for image config record: %T", val) + } + + return recordCID, configRecord, nil +}