@@ -10,6 +10,8 @@ import (
1010 "errors"
1111 "fmt"
1212 "io"
13+ "net/http"
14+ "os"
1315 "path/filepath"
1416
1517 "github.com/adrg/xdg"
@@ -18,6 +20,8 @@ import (
1820 "oras.land/oras-go/v2"
1921 "oras.land/oras-go/v2/content/oci"
2022 "oras.land/oras-go/v2/errdef"
23+
24+ "github.com/stacklok/toolhive-core/httperr"
2125)
2226
2327// Store provides local OCI artifact storage backed by an OCI Image Layout.
@@ -129,6 +133,47 @@ func (s *Store) Tag(ctx context.Context, d digest.Digest, tag string) error {
129133 return nil
130134}
131135
136+ // DeleteTag removes a tag from the store index without deleting the underlying blobs.
137+ func (s * Store ) DeleteTag (ctx context.Context , tag string ) error {
138+ if err := s .inner .Untag (ctx , tag ); err != nil {
139+ if errors .Is (err , errdef .ErrNotFound ) {
140+ return httperr .WithCode (
141+ fmt .Errorf ("tag not found: %s: %w" , tag , err ),
142+ http .StatusNotFound ,
143+ )
144+ }
145+ return fmt .Errorf ("removing tag: %w" , err )
146+ }
147+ return nil
148+ }
149+
150+ // DeleteBuild removes a tag and, if no other tag shares the same digest,
151+ // deletes all associated blobs (config, layers, manifest, and index if applicable).
152+ // Use DeleteTag when tag-only removal is desired and blob cleanup is not needed.
153+ func (s * Store ) DeleteBuild (ctx context.Context , tag string ) error {
154+ d , err := s .Resolve (ctx , tag )
155+ if err != nil {
156+ return httperr .WithCode (
157+ fmt .Errorf ("tag not found: %s: %w" , tag , err ),
158+ http .StatusNotFound ,
159+ )
160+ }
161+
162+ if err := s .DeleteTag (ctx , tag ); err != nil {
163+ return err
164+ }
165+
166+ shared , err := s .isDigestReferenced (ctx , d )
167+ if err != nil {
168+ return fmt .Errorf ("checking remaining references: %w" , err )
169+ }
170+ if shared {
171+ return nil
172+ }
173+
174+ return s .deleteOrphanedBlobs (ctx , d )
175+ }
176+
132177// Resolve resolves a tag to a manifest digest.
133178func (s * Store ) Resolve (ctx context.Context , tag string ) (digest.Digest , error ) {
134179 desc , err := s .inner .Resolve (ctx , tag )
@@ -208,3 +253,84 @@ func (s *Store) fetchContent(ctx context.Context, d digest.Digest) ([]byte, erro
208253
209254 return data , nil
210255}
256+
257+ // isDigestReferenced checks whether any remaining tag still resolves to d.
258+ func (s * Store ) isDigestReferenced (ctx context.Context , d digest.Digest ) (bool , error ) {
259+ tags , err := s .ListTags (ctx )
260+ if err != nil {
261+ return false , err
262+ }
263+ for _ , tag := range tags {
264+ resolved , err := s .Resolve (ctx , tag )
265+ if err != nil {
266+ continue
267+ }
268+ if resolved == d {
269+ return true , nil
270+ }
271+ }
272+ return false , nil
273+ }
274+
275+ // deleteOrphanedBlobs removes all blobs reachable from d (index or manifest),
276+ // including d itself. Callers must ensure no remaining tag references d.
277+ func (s * Store ) deleteOrphanedBlobs (ctx context.Context , d digest.Digest ) error {
278+ isIdx , err := s .IsIndex (ctx , d )
279+ if err != nil {
280+ return fmt .Errorf ("inspecting orphaned digest: %w" , err )
281+ }
282+
283+ if isIdx {
284+ idx , err := s .GetIndex (ctx , d )
285+ if err != nil {
286+ return fmt .Errorf ("fetching orphaned index: %w" , err )
287+ }
288+ for _ , m := range idx .Manifests {
289+ if err := s .deleteManifestBlobs (ctx , m .Digest ); err != nil {
290+ return err
291+ }
292+ }
293+ } else {
294+ if err := s .deleteManifestBlobs (ctx , d ); err != nil {
295+ return err
296+ }
297+ // deleteManifestBlobs already deletes d when it's a plain manifest.
298+ return nil
299+ }
300+
301+ return s .deleteBlob (d )
302+ }
303+
304+ // deleteManifestBlobs fetches the manifest at d, deletes its config and layer
305+ // blobs, then deletes the manifest blob itself.
306+ func (s * Store ) deleteManifestBlobs (ctx context.Context , d digest.Digest ) error {
307+ data , err := s .fetchContent (ctx , d )
308+ if err != nil {
309+ return fmt .Errorf ("fetching manifest %s: %w" , d , err )
310+ }
311+
312+ var m ocispec.Manifest
313+ if err := json .Unmarshal (data , & m ); err != nil {
314+ return fmt .Errorf ("parsing manifest %s: %w" , d , err )
315+ }
316+
317+ if err := s .deleteBlob (m .Config .Digest ); err != nil {
318+ return err
319+ }
320+ for _ , layer := range m .Layers {
321+ if err := s .deleteBlob (layer .Digest ); err != nil {
322+ return err
323+ }
324+ }
325+ return s .deleteBlob (d )
326+ }
327+
328+ // deleteBlob removes the blob file for d from the local OCI layout.
329+ // A missing file is treated as success (idempotent).
330+ func (s * Store ) deleteBlob (d digest.Digest ) error {
331+ path := filepath .Join (s .root , "blobs" , d .Algorithm ().String (), d .Encoded ())
332+ if err := os .Remove (path ); err != nil && ! errors .Is (err , os .ErrNotExist ) {
333+ return fmt .Errorf ("deleting blob %s: %w" , d , err )
334+ }
335+ return nil
336+ }
0 commit comments