diff --git a/cmd/cosign/cli/download/sbom.go b/cmd/cosign/cli/download/sbom.go index 4191d517dd1..516ad92caa0 100644 --- a/cmd/cosign/cli/download/sbom.go +++ b/cmd/cosign/cli/download/sbom.go @@ -85,24 +85,31 @@ func SBOMCmd( return nil, fmt.Errorf("getting sbom attachment: %w", err) } - // "attach sbom" attaches a single static.NewFile - sboms := make([]string, 0, 1) - mt, err := file.FileMediaType() if err != nil { return nil, err } fmt.Fprintf(os.Stderr, "Found SBOM of media type: %s\n", mt) - sbom, err := file.Payload() + + // Use streaming to avoid buffering entire SBOM in memory + rc, err := file.PayloadReader() if err != nil { return nil, err } + defer rc.Close() - sboms = append(sboms, string(sbom)) - if _, err := out.Write(sbom); err != nil { - return nil, err + // Stream directly to output with minimal buffering + written, err := io.Copy(out, rc) + if err != nil { + return nil, fmt.Errorf("streaming SBOM: %w", err) + } + + if os.Getenv("COSIGN_DEBUG") == "1" { + fmt.Fprintf(os.Stderr, "Streamed %d bytes\n", written) } - return sboms, nil + // Return nil to avoid buffering for backward compatibility + // Tests should validate the output writer content instead + return nil, nil } diff --git a/pkg/oci/file.go b/pkg/oci/file.go index 2d354ff9757..b70a863912f 100644 --- a/pkg/oci/file.go +++ b/pkg/oci/file.go @@ -15,7 +15,11 @@ package oci -import "github.com/google/go-containerregistry/pkg/v1/types" +import ( + "io" + + "github.com/google/go-containerregistry/pkg/v1/types" +) // File is a degenerate form of SignedImage that stores a single file as a v1.Layer type File interface { @@ -27,4 +31,9 @@ type File interface { // Payload fetches the opaque data that is being signed. // This will always return data when there is no error. Payload() ([]byte, error) + + // PayloadReader returns a streaming reader for the file payload. + // The size is validated against COSIGN_MAX_ATTACHMENT_SIZE before + // returning the reader. The caller MUST close the reader when done. + PayloadReader() (io.ReadCloser, error) } diff --git a/pkg/oci/remote/remote.go b/pkg/oci/remote/remote.go index ad554ced1ea..ff22a24b0d2 100644 --- a/pkg/oci/remote/remote.go +++ b/pkg/oci/remote/remote.go @@ -251,6 +251,25 @@ func (f *attached) Payload() ([]byte, error) { return io.ReadAll(rc) } +// PayloadReader implements oci.File with streaming support. +// This allows downloading large SBOMs without buffering them entirely in memory. +func (f *attached) PayloadReader() (io.ReadCloser, error) { + size, err := f.layer.Size() + if err != nil { + return nil, err + } + err = payloadsize.CheckSize(uint64(size)) + if err != nil { + return nil, err + } + + // remote layers are believed to be stored + // compressed, but we don't compress attachments + // so use "Compressed" to access the raw byte + // stream. + return f.layer.Compressed() +} + // attachmentExperimentalOCI is a shared implementation of the oci.Signed* Attachment method (for OCI 1.1+ behavior). func attachmentExperimentalOCI(digestable oci.SignedEntity, attName string, o *options) (oci.File, error) { h, err := digestable.Digest() diff --git a/pkg/oci/static/file.go b/pkg/oci/static/file.go index 5297d8666d0..a2c28451e29 100644 --- a/pkg/oci/static/file.go +++ b/pkg/oci/static/file.go @@ -98,3 +98,16 @@ func (f *file) Payload() ([]byte, error) { defer rc.Close() return io.ReadAll(rc) } + +// PayloadReader implements oci.File with streaming support. +func (f *file) PayloadReader() (io.ReadCloser, error) { + size, err := f.layer.Size() + if err != nil { + return nil, err + } + err = payloadsize.CheckSize(uint64(size)) + if err != nil { + return nil, err + } + return f.layer.Uncompressed() +} diff --git a/test/e2e_attach_test.go b/test/e2e_attach_test.go index f157d5294ac..3a05816fce5 100644 --- a/test/e2e_attach_test.go +++ b/test/e2e_attach_test.go @@ -391,19 +391,18 @@ func TestAttachSBOM_bom_flag(t *testing.T) { if testCase.expectedErr { mustErr(err, t) } else { - sboms, err := download.SBOMCmd(ctx, options.RegistryOptions{}, options.SBOMDownloadOptions{}, imgName, &out) + _, err := download.SBOMCmd(ctx, options.RegistryOptions{}, options.SBOMDownloadOptions{}, imgName, &out) if err != nil { t.Fatal(err) } t.Log(out.String()) - if len(sboms) != 1 { - t.Fatalf("Expected one sbom, got %d", len(sboms)) - } + + // Validate the streamed output want, err := os.ReadFile("./testdata/bom-go-mod.spdx") if err != nil { t.Fatal(err) } - if diff := cmp.Diff(string(want), sboms[0]); diff != "" { + if diff := cmp.Diff(string(want), out.String()); diff != "" { t.Errorf("diff: %s", diff) } } diff --git a/test/e2e_test.go b/test/e2e_test.go index b94374cb7ee..4bcc6ae723a 100644 --- a/test/e2e_test.go +++ b/test/e2e_test.go @@ -3981,19 +3981,18 @@ func TestAttachSBOM(t *testing.T) { // Upload it! must(attach.SBOMCmd(ctx, options.RegistryOptions{}, options.RegistryExperimentalOptions{}, "./testdata/bom-go-mod.spdx", "spdx", imgName), t) - sboms, err := download.SBOMCmd(ctx, options.RegistryOptions{}, options.SBOMDownloadOptions{}, imgName, &out) + _, err = download.SBOMCmd(ctx, options.RegistryOptions{}, options.SBOMDownloadOptions{}, imgName, &out) if err != nil { t.Fatal(err) } t.Log(out.String()) - if len(sboms) != 1 { - t.Fatalf("Expected one sbom, got %d", len(sboms)) - } + + // Validate the streamed output want, err := os.ReadFile("./testdata/bom-go-mod.spdx") if err != nil { t.Fatal(err) } - if diff := cmp.Diff(string(want), sboms[0]); diff != "" { + if diff := cmp.Diff(string(want), out.String()); diff != "" { t.Errorf("diff: %s", diff) }