diff --git a/pkg/sif/testdata/TestRemoveBlob/Valid.golden b/pkg/sif/testdata/TestRemoveBlob/Valid.golden new file mode 100644 index 0000000..e9d6803 Binary files /dev/null and b/pkg/sif/testdata/TestRemoveBlob/Valid.golden differ diff --git a/pkg/sif/testdata/TestRemoveDescriptors/NoMatch.golden b/pkg/sif/testdata/TestRemoveDescriptors/NoMatch.golden new file mode 100644 index 0000000..23dcc45 Binary files /dev/null and b/pkg/sif/testdata/TestRemoveDescriptors/NoMatch.golden differ diff --git a/pkg/sif/testdata/TestRemoveDescriptors/Valid.golden b/pkg/sif/testdata/TestRemoveDescriptors/Valid.golden new file mode 100644 index 0000000..c21d6f5 Binary files /dev/null and b/pkg/sif/testdata/TestRemoveDescriptors/Valid.golden differ diff --git a/pkg/sif/testdata/TestUpdate/RemoveOKNoTemp.golden b/pkg/sif/testdata/TestUpdate/RemoveOKNoTemp.golden new file mode 100644 index 0000000..e642de7 Binary files /dev/null and b/pkg/sif/testdata/TestUpdate/RemoveOKNoTemp.golden differ diff --git a/pkg/sif/update.go b/pkg/sif/update.go index 0668c1a..37d6d48 100644 --- a/pkg/sif/update.go +++ b/pkg/sif/update.go @@ -30,6 +30,7 @@ const ( // updateOpts accumulates update options. type updateOpts struct { tempDir string + noTemp bool } // UpdateOpt are used to specify options to apply when updating a SIF. @@ -44,6 +45,16 @@ func OptUpdateTempDir(d string) UpdateOpt { } } +// OptUpdateNoTemp enforces that no tempDir will be created, and no blobs will +// be cached to disk, during the update operation. Any update that requires +// adding blobs to the SIF will fail with errUpdateRequiresTemp. +func OptUpdateNoTemp(n bool) UpdateOpt { + return func(c *updateOpts) error { + c.noTemp = n + return nil + } +} + // UpdateRootIndex modifies the SIF file associated with f so that it holds the // content of ImageIndex ii. The RootIndex of the SIF is replaced with ii. Any // blobs in the SIF that are not referenced in ii are removed from the SIF. Any @@ -52,7 +63,8 @@ func OptUpdateTempDir(d string) UpdateOpt { // // UpdateRootIndex may create one or more temporary files during the update // process. By default, the directory returned by os.TempDir is used. To -// override this, consider using OptUpdateTmpDir. +// override this, consider using OptUpdateTmpDir. Alternatively, updates which +// do not add blobs to the SIF may use OptUpdateNoTemp. func (f *OCIFileImage) UpdateRootIndex(ii v1.ImageIndex, opts ...UpdateOpt) error { uo := updateOpts{ tempDir: os.TempDir(), @@ -89,11 +101,14 @@ func (f *OCIFileImage) UpdateRootIndex(ii v1.ImageIndex, opts ...UpdateOpt) erro // Cache all new blobs referenced by the new ImageIndex and its child // indices / images, which aren't already in the SIF. cachedblobs are new // things to add. keepBlobs already exist in the SIF and should be kept. - blobCache, err := os.MkdirTemp(uo.tempDir, "") - if err != nil { - return err + blobCache := "" + if !uo.noTemp { + blobCache, err = os.MkdirTemp(uo.tempDir, "") + if err != nil { + return err + } + defer os.RemoveAll(blobCache) } - defer os.RemoveAll(blobCache) cachedBlobs, keepBlobs, err := cacheIndexBlobs(ii, sifBlobs, blobCache) if err != nil { return err @@ -160,6 +175,8 @@ func sifBlobs(fi *sif.FileImage) ([]v1.Hash, error) { return sifBlobs, nil } +var errUpdateRequiresTemp = errors.New("update operation requires a temporary directory to cache blobs") + // cacheIndexBlobs will cache all blobs referenced by ii, except those with // digests specified in skip. The blobs will be cached to files in cacheDir, // with filenames equal to their digest. The function returns two lists of blobs @@ -302,6 +319,9 @@ func cacheImageBlobs(im v1.Image, skip []v1.Hash, cacheDir string) ([]v1.Hash, [ // writeCacheBlob writes blob content from rc into tmpDir with filename equal to // specified digest. func writeCacheBlob(rc io.ReadCloser, digest v1.Hash, cacheDir string) error { + if cacheDir == "" { + return errUpdateRequiresTemp + } path := filepath.Join(cacheDir, digest.String()) f, err := os.Create(path) if err != nil { @@ -470,3 +490,21 @@ func removeRefAnnotation(ii v1.ImageIndex, ref name.Reference) (v1.ImageIndex, e ii = mutate.RemoveManifests(ii, m) return mutate.AppendManifests(ii, ia), nil } + +// RemoveBlob removes a blob from the SIF f, without modifying the rootIndex. +func (f *OCIFileImage) RemoveBlob(hash v1.Hash) error { + return f.sif.DeleteObjects(sif.WithOCIBlobDigest(hash), + sif.OptDeleteZero(true), + sif.OptDeleteCompact(true)) +} + +// RemoveDescriptors modifies the SIF file associated with f so that its +// RootIndex no longer holds descriptors selected by matcher. Any blobs in the +// SIF that are no longer referenced are removed from the SIF. +func (f *OCIFileImage) RemoveDescriptors(matcher match.Matcher) error { + ri, err := f.RootIndex() + if err != nil { + return err + } + return f.UpdateRootIndex(mutate.RemoveManifests(ri, matcher), OptUpdateNoTemp(true)) +} diff --git a/pkg/sif/update_test.go b/pkg/sif/update_test.go index 8b75c4c..42bd82e 100644 --- a/pkg/sif/update_test.go +++ b/pkg/sif/update_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" + match "github.com/google/go-containerregistry/pkg/v1/match" v1mutate "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/types" @@ -32,6 +33,7 @@ func TestUpdate(t *testing.T) { base string updater func(*testing.T, v1.ImageIndex) v1.ImageIndex opts []sif.UpdateOpt + wantErr bool }{ { name: "AddLayer", @@ -111,6 +113,39 @@ func TestUpdate(t *testing.T) { return v1mutate.AppendManifests(ii, v1mutate.IndexAddendum{Add: addIdx}) }, }, + // Can't update to a root index with *new* blobs without a temp directory. + { + name: "AddFailNoTemp", + base: "hello-world-docker-v2-manifest", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + addIdx, err := random.Index(64, 1, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + return v1mutate.AppendManifests(ii, v1mutate.IndexAddendum{Add: addIdx}) + }, + opts: []sif.UpdateOpt{sif.OptUpdateNoTemp(true)}, + wantErr: true, + }, + // Can update to a root index without new blobs without a temp directory. + { + name: "RemoveOKNoTemp", + base: "hello-world-docker-v2-manifest", + updater: func(t *testing.T, ii v1.ImageIndex) v1.ImageIndex { + t.Helper() + ih, err := v1.NewHash("sha256:432f982638b3aefab73cc58ab28f5c16e96fdb504e8c134fc58dff4bae8bf338") + if err != nil { + t.Fatal(err) + } + return v1mutate.RemoveManifests(ii, match.Digests(ih)) + }, + opts: []sif.UpdateOpt{sif.OptUpdateNoTemp(true)}, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -132,7 +167,14 @@ func TestUpdate(t *testing.T) { ii = tt.updater(t, ii) - if err := sif.Update(fi, ii, tt.opts...); err != nil { + err = sif.Update(fi, ii, tt.opts...) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, but got nil") + } + return + } + if err != nil { t.Fatal(err) } @@ -314,3 +356,127 @@ func TestAppendMultiple(t *testing.T) { ) g.Assert(t, "image", b) } + +func TestRemoveBlob(t *testing.T) { + validDigest, err := v1.NewHash("sha256:7050e35b49f5e348c4809f5eff915842962cb813f32062d3bbdd35c750dd7d01") + if err != nil { + t.Fatal(err) + } + + otherDigest, err := v1.NewHash("sha256:e66fc843f1291ede94f0ecb3dbd8d277d4b05a8a4ceba1e211365dae9adb17da") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + base string + digest v1.Hash + wantErr bool + }{ + { + name: "Valid", + base: "hello-world-docker-v2-manifest", + digest: validDigest, + wantErr: false, + }, + { + name: "NotFound", + base: "hello-world-docker-v2-manifest", + digest: otherDigest, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sifPath := corpus.SIF(t, tt.base, sif.OptWriteWithSpareDescriptorCapacity(8)) + fi, err := ssif.LoadContainerFromPath(sifPath) + if err != nil { + t.Fatal(err) + } + + ofi, err := sif.FromFileImage(fi) + if err != nil { + t.Fatal(err) + } + + err = ofi.RemoveBlob(tt.digest) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, but nil returned") + } + return + } + if err != nil { + t.Fatal(err) + } + + if err := fi.UnloadContainer(); err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(sifPath) + if err != nil { + t.Fatal(err) + } + + g := goldie.New(t, + goldie.WithTestNameForDir(true), + ) + + g.Assert(t, tt.name, b) + }) + } +} + +func TestRemoveDescriptors(t *testing.T) { + tests := []struct { + name string + matcher match.Matcher + base string + }{ + { + name: "Valid", + base: "hello-world-docker-v2-manifest-list", + matcher: match.Platforms(v1.Platform{OS: "linux", Architecture: "ppc64le"}), + }, + { + name: "NoMatch", + base: "hello-world-docker-v2-manifest-list", + matcher: match.Platforms(v1.Platform{OS: "linux", Architecture: "m68k"}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sifPath := corpus.SIF(t, tt.base, sif.OptWriteWithSpareDescriptorCapacity(8)) + fi, err := ssif.LoadContainerFromPath(sifPath) + if err != nil { + t.Fatal(err) + } + + ofi, err := sif.FromFileImage(fi) + if err != nil { + t.Fatal(err) + } + + if err := ofi.RemoveDescriptors(tt.matcher); err != nil { + t.Fatal(err) + } + + if err := fi.UnloadContainer(); err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(sifPath) + if err != nil { + t.Fatal(err) + } + + g := goldie.New(t, + goldie.WithTestNameForDir(true), + ) + + g.Assert(t, tt.name, b) + }) + } +}