From 92abe8b1cf2a05c5011e5f7e7f93e13e4848f372 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 22 Jul 2026 13:34:33 +0200 Subject: [PATCH 1/8] feat: add HTTP timeout and interrupt cancellation --- README.md | 1 + internal/bifrost/api.go | 7 ++++++- internal/bifrost/cli.go | 10 +++++++++- internal/bifrost/options.go | 5 +++++ internal/bifrost/sbom_upload.go | 1 + 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 21ca167..76534f8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ gh api \ | `--server-url` | No | `SERVER_URL`, `BIFROST_SERVER_URL` | URL to the bifrost server. | | `--retry-attempts` | No | | Number of retry attempts for transient upload failures. | | `--retry-delay` | No | | Delay between upload retry attempts. | +| `--http-timeout` | No | | Maximum duration to wait for an HTTP upload request. Defaults to 30 seconds. | | `--git-branch` | No | | Git branch name to attach to the upload. | | `--git-commit-sha` | No | | Git commit SHA to attach to the upload. | | `--git-origin` | No | | Git origin URL to attach to the upload. | diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 0510b19..097f48a 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -18,6 +18,7 @@ const ( DefaultServerURL = "https://portal.bifrostsec.com" DefaultRetryAttempts = 3 DefaultRetryDelay = 2 * time.Second + DefaultHTTPTimeout = 30 * time.Second userAgentProduct = "bifrost-cli" ) @@ -30,6 +31,7 @@ type APIConfig struct { Token string RetryAttempts int RetryDelay time.Duration + HTTPTimeout time.Duration RetryOutput io.Writer GitBranch string GitCommitSHA string @@ -50,11 +52,14 @@ func NewAPI(cfg APIConfig) API { if cfg.RetryDelay < 0 { cfg.RetryDelay = 0 } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = DefaultHTTPTimeout + } if cfg.RetryOutput == nil { cfg.RetryOutput = os.Stderr } return &api{ - client: http.Client{}, + client: http.Client{Timeout: cfg.HTTPTimeout}, cfg: cfg, } } diff --git a/internal/bifrost/cli.go b/internal/bifrost/cli.go index 85837f4..2e1d0d7 100644 --- a/internal/bifrost/cli.go +++ b/internal/bifrost/cli.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "os" + "os/signal" "runtime" ) @@ -16,6 +17,13 @@ type Task interface { } func CLI(version, gitCommit string, args []string) int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + return runCLI(ctx, version, gitCommit, args) +} + +func runCLI(ctx context.Context, version, gitCommit string, args []string) int { fl := flag.NewFlagSet("", flag.ContinueOnError) showHelp := fl.Bool("help", false, "show this help and exit") fl.Usage = func() { @@ -55,7 +63,7 @@ func CLI(version, gitCommit string, args []string) int { return 2 } - err = task.Run(context.Background()) + err = task.Run(ctx) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err) return 2 diff --git a/internal/bifrost/options.go b/internal/bifrost/options.go index 102d1a8..b22522e 100644 --- a/internal/bifrost/options.go +++ b/internal/bifrost/options.go @@ -22,6 +22,7 @@ type Options struct { image string retryAttempts int retryDelay time.Duration + httpTimeout time.Duration gitBranch string gitCommitSHA string gitOrigin string @@ -37,6 +38,7 @@ func RegisterOptions(fl *flag.FlagSet, opts *Options) { fl.StringVar(&opts.image, "image", "", "Container image reference for the uploaded SBOM (or IMAGE environment variable); required unless a service version is provided") fl.IntVar(&opts.retryAttempts, "retry-attempts", DefaultRetryAttempts, "Number of retry attempts for transient upload failures") fl.DurationVar(&opts.retryDelay, "retry-delay", DefaultRetryDelay, "Delay between upload retry attempts") + fl.DurationVar(&opts.httpTimeout, "http-timeout", DefaultHTTPTimeout, "Maximum duration to wait for an HTTP upload request") fl.StringVar(&opts.gitBranch, "git-branch", "", "Optional Git branch name for the uploaded SBOM") fl.StringVar(&opts.gitCommitSHA, "git-commit-sha", "", "Optional Git commit SHA for the uploaded SBOM") fl.StringVar(&opts.gitOrigin, "git-origin", "", "Optional Git origin URL for the uploaded SBOM") @@ -68,6 +70,9 @@ func ValidateBaseOptions(fl *flag.FlagSet, opts *Options) error { if opts.retryDelay < 0 { return fmt.Errorf("retry delay must be zero or greater") } + if opts.httpTimeout <= 0 { + return fmt.Errorf("HTTP timeout must be greater than zero") + } // An explicitly passed flag takes precedence over the environment variable. if !isFlagSet(fl, gitAutoDetectFlag) { if value := os.Getenv("BIFROST_GIT_AUTO_DETECT"); value != "" { diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index 3223951..ac4cc10 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -62,6 +62,7 @@ func (t sbomUploadTask) Run(ctx context.Context) error { Token: t.apiKey, RetryAttempts: t.retryAttempts, RetryDelay: t.retryDelay, + HTTPTimeout: t.httpTimeout, GitBranch: t.gitBranch, GitCommitSHA: t.gitCommitSHA, GitOrigin: t.gitOrigin, From 1a4f7c1ca8e3743aed72dde9669e71c4056a3cc8 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Wed, 22 Jul 2026 14:17:49 +0200 Subject: [PATCH 2/8] fix: cancel stdin uploads on termination --- internal/bifrost/cli.go | 3 ++- internal/bifrost/sbom_upload.go | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/bifrost/cli.go b/internal/bifrost/cli.go index 2e1d0d7..971526a 100644 --- a/internal/bifrost/cli.go +++ b/internal/bifrost/cli.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "runtime" + "syscall" ) type Task interface { @@ -17,7 +18,7 @@ type Task interface { } func CLI(version, gitCommit string, args []string) int { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return runCLI(ctx, version, gitCommit, args) diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index ac4cc10..a55d605 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -144,7 +144,7 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { _ = os.Remove(tmpPath) }() - if _, err := io.Copy(tmpFile, os.Stdin); err != nil { + if err := cancelableCopyStdin(ctx, tmpFile, os.Stdin); err != nil { _ = tmpFile.Close() return fmt.Errorf("failed to read SBOM from stdin: %w", err) } @@ -154,3 +154,20 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { return api.UploadSBOMFile(ctx, t.service, t.serviceVersion, tmpPath) } + +func cancelableCopyStdin(ctx context.Context, destination io.Writer, stdin *os.File) error { + copyDone := make(chan error, 1) + go func() { + _, err := io.Copy(destination, stdin) + copyDone <- err + }() + + select { + case err := <-copyDone: + return err + case <-ctx.Done(): + _ = stdin.Close() + <-copyDone + return ctx.Err() + } +} From 8cf0bceac1600a2d2324aef772249c3559e43c9f Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 23 Jul 2026 12:04:13 +0200 Subject: [PATCH 3/8] fix: handle context cancellation in retry logic --- internal/bifrost/api.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/bifrost/api.go b/internal/bifrost/api.go index 097f48a..30dec34 100644 --- a/internal/bifrost/api.go +++ b/internal/bifrost/api.go @@ -96,6 +96,9 @@ func (a *api) uploadSBOM(ctx context.Context, service string, serviceVersion str if err == nil { return nil } + if err := ctx.Err(); err != nil { + return err + } if attempt == a.cfg.RetryAttempts || !shouldRetry(err) { return err } @@ -219,7 +222,7 @@ func (e *requestError) Unwrap() error { } func shouldRetry(err error) bool { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.Canceled) { return false } var uploadErr *uploadError From c35d5bca5ed5d714caae76a6cee5bff69c6a9d51 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 23 Jul 2026 12:04:29 +0200 Subject: [PATCH 4/8] test: add retry behavior tests for UploadSBOMFile --- internal/bifrost/api_test.go | 81 +++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/internal/bifrost/api_test.go b/internal/bifrost/api_test.go index 9fc3e56..10d6831 100644 --- a/internal/bifrost/api_test.go +++ b/internal/bifrost/api_test.go @@ -301,6 +301,70 @@ func TestAPI_UploadSBOM_DoesNotRetryClientFailure(t *testing.T) { assert.EqualValues(t, 1, attempts.Load()) } +func TestAPI_UploadSBOM_RetriesHTTPTimeout(t *testing.T) { + var attempts atomic.Int32 + releaseFirstRequest := make(chan struct{}) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) == 1 { + select { + case <-r.Context().Done(): + case <-releaseFirstRequest: + } + return + } + w.WriteHeader(http.StatusOK) + })) + defer httpServer.Close() + defer close(releaseFirstRequest) + + path := "test-sbom.json" + assert.NoError(t, os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644)) + defer func() { + _ = os.Remove(path) + }() + + cfg := newTestAPIConfig(httpServer.URL) + cfg.HTTPTimeout = 100 * time.Millisecond + cfg.RetryAttempts = 1 + cfg.RetryDelay = time.Millisecond + api := NewAPI(cfg) + + err := api.UploadSBOMFile(context.Background(), "test-service", "test-version", path) + assert.NoError(t, err) + assert.EqualValues(t, 2, attempts.Load()) +} + +func TestAPI_UploadSBOM_DoesNotRetryExpiredContext(t *testing.T) { + var attempts atomic.Int32 + releaseRequest := make(chan struct{}) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + select { + case <-r.Context().Done(): + case <-releaseRequest: + } + })) + defer httpServer.Close() + defer close(releaseRequest) + + path := "test-sbom.json" + assert.NoError(t, os.WriteFile(path, []byte(`{"name":"test","version":"1.0"}`), 0644)) + defer func() { + _ = os.Remove(path) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + cfg := newTestAPIConfig(httpServer.URL) + cfg.HTTPTimeout = time.Second + cfg.RetryAttempts = 1 + api := NewAPI(cfg) + + err := api.UploadSBOMFile(ctx, "test-service", "test-version", path) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.EqualValues(t, 1, attempts.Load()) +} + func TestAPI_NewAPI_NormalizesNegativeRetryConfiguration(t *testing.T) { client := NewAPI(APIConfig{ ServerURL: "https://example.com", @@ -312,16 +376,29 @@ func TestAPI_NewAPI_NormalizesNegativeRetryConfiguration(t *testing.T) { assert.True(t, ok) assert.Equal(t, 0, internalAPI.cfg.RetryAttempts) assert.Equal(t, time.Duration(0), internalAPI.cfg.RetryDelay) + assert.Equal(t, DefaultHTTPTimeout, internalAPI.cfg.HTTPTimeout) + assert.Equal(t, DefaultHTTPTimeout, internalAPI.client.Timeout) +} + +func TestAPI_NewAPI_UsesConfiguredHTTPTimeout(t *testing.T) { + client := NewAPI(APIConfig{ + ServerURL: "https://example.com", + Token: "test-token", + HTTPTimeout: 5 * time.Second, + }) + internalAPI, ok := client.(*api) + assert.True(t, ok) + assert.Equal(t, 5*time.Second, internalAPI.client.Timeout) } func TestShouldRetry_ContextCancellationIsNotRetryable(t *testing.T) { assert.False(t, shouldRetry(&requestError{cause: context.Canceled})) - assert.False(t, shouldRetry(&requestError{cause: context.DeadlineExceeded})) + assert.True(t, shouldRetry(&requestError{cause: context.DeadlineExceeded})) } func TestShouldRetry_WrappedContextCancellationIsNotRetryable(t *testing.T) { assert.False(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.Canceled)})) - assert.False(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.DeadlineExceeded)})) + assert.True(t, shouldRetry(&requestError{cause: fmt.Errorf("request failed: %w", context.DeadlineExceeded)})) } func TestShouldRetry_NonContextRequestErrorIsRetryable(t *testing.T) { From 41c7ead41bdc43613be09a23650daec6dc4478d2 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 23 Jul 2026 12:04:45 +0200 Subject: [PATCH 5/8] test: add tests for HTTP timeout validation and parsing --- internal/bifrost/cli_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/bifrost/cli_test.go b/internal/bifrost/cli_test.go index 5063c37..ab076dc 100644 --- a/internal/bifrost/cli_test.go +++ b/internal/bifrost/cli_test.go @@ -5,6 +5,7 @@ package bifrost import ( "errors" + "flag" "fmt" "io" "net/http" @@ -677,3 +678,30 @@ func TestCLI_InvalidRetryDelay(t *testing.T) { exitCode := CLI("1.0", "commit", args) assert.Equal(t, 2, exitCode) } + +func TestCLI_InvalidHTTPTimeout(t *testing.T) { + for _, timeout := range []string{"0s", "-1s"} { + t.Run(timeout, func(t *testing.T) { + args := []string{ + "--server-url=https://portal.bifrostsec.com", + "--service=test-service", + "--service-version=1.0", + "--api-key=test-token", + "--http-timeout=" + timeout, + "sbom", "upload", "test-sbom.json", + } + + exitCode := CLI("1.0", "commit", args) + assert.Equal(t, 2, exitCode) + }) + } +} + +func TestRegisterOptions_ParsesHTTPTimeout(t *testing.T) { + fl := flag.NewFlagSet("", flag.ContinueOnError) + options := Options{} + RegisterOptions(fl, &options) + + assert.NoError(t, fl.Parse([]string{"--http-timeout=5s"})) + assert.Equal(t, 5*time.Second, options.httpTimeout) +} From a1bd422143592e6f6f7ca2bd1f0be265505b565a Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 23 Jul 2026 12:05:01 +0200 Subject: [PATCH 6/8] test: add unit test for stdin cancellation and temporary file cleanup --- internal/bifrost/sbom_upload_test.go | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 internal/bifrost/sbom_upload_test.go diff --git a/internal/bifrost/sbom_upload_test.go b/internal/bifrost/sbom_upload_test.go new file mode 100644 index 0000000..420c02b --- /dev/null +++ b/internal/bifrost/sbom_upload_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 bifrost security +// SPDX-License-Identifier: Apache-2.0 + +package bifrost + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type unexpectedUploadAPI struct{} + +func (unexpectedUploadAPI) UploadSBOMFile(context.Context, string, string, string) error { + return errors.New("upload should not be reached") +} + +func TestSBOMUploadTask_StdinCancellationCleansUpTemporaryFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + + stdin, writer, err := os.Pipe() + assert.NoError(t, err) + defer func() { + _ = writer.Close() + }() + originalStdin := os.Stdin + os.Stdin = stdin + defer func() { + os.Stdin = originalStdin + _ = stdin.Close() + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + task := sbomUploadTask{ + Options: Options{ + service: "test-service", + serviceVersion: "test-version", + }, + } + errCh := make(chan error, 1) + go func() { + errCh <- task.uploadStdinSBOM(ctx, unexpectedUploadAPI{}) + }() + + waitForTemporaryStdinSBOM(t, tempDir) + cancel() + + select { + case err := <-errCh: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("stdin upload did not stop after cancellation") + } + matches, err := filepath.Glob(filepath.Join(tempDir, "bifrost-stdin-sbom-*.json")) + assert.NoError(t, err) + assert.Empty(t, matches) +} + +func waitForTemporaryStdinSBOM(t *testing.T, dir string) { + t.Helper() + deadline := time.After(time.Second) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + for { + matches, err := filepath.Glob(filepath.Join(dir, "bifrost-stdin-sbom-*.json")) + if err != nil { + t.Fatalf("failed to find temporary stdin SBOM: %v", err) + } + if len(matches) > 0 { + return + } + select { + case <-deadline: + t.Fatal("temporary stdin SBOM was not created") + case <-ticker.C: + } + } +} From 508b4a8885ad39f694b20dcb21dd9cb4f4b756c4 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Thu, 6 Aug 2026 17:23:30 +0200 Subject: [PATCH 7/8] fix: refactor stdin upload cancellation and improve test coverage --- internal/bifrost/sbom_upload.go | 17 +++--- internal/bifrost/sbom_upload_test.go | 81 +++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index a55d605..789a2cc 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -144,7 +144,7 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { _ = os.Remove(tmpPath) }() - if err := cancelableCopyStdin(ctx, tmpFile, os.Stdin); err != nil { + if err := copyStdinWithContext(ctx, tmpFile, os.Stdin); err != nil { _ = tmpFile.Close() return fmt.Errorf("failed to read SBOM from stdin: %w", err) } @@ -155,19 +155,20 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { return api.UploadSBOMFile(ctx, t.service, t.serviceVersion, tmpPath) } -func cancelableCopyStdin(ctx context.Context, destination io.Writer, stdin *os.File) error { - copyDone := make(chan error, 1) +// copyStdinWithContext copies stdin into destination and returns promptly when +// ctx is cancelled. ctx is the process signal context (Ctrl+C or SIGTERM), so a +// cancelled copy always means the process is terminating; the io.Copy goroutine +// outlives cancellation only until the process exits moments later. +func copyStdinWithContext(ctx context.Context, destination io.Writer, stdin *os.File) error { + done := make(chan error, 1) go func() { _, err := io.Copy(destination, stdin) - copyDone <- err + done <- err }() - select { - case err := <-copyDone: + case err := <-done: return err case <-ctx.Done(): - _ = stdin.Close() - <-copyDone return ctx.Err() } } diff --git a/internal/bifrost/sbom_upload_test.go b/internal/bifrost/sbom_upload_test.go index 420c02b..fa7c6e8 100644 --- a/internal/bifrost/sbom_upload_test.go +++ b/internal/bifrost/sbom_upload_test.go @@ -1,6 +1,8 @@ // Copyright 2026 bifrost security // SPDX-License-Identifier: Apache-2.0 +//go:build unix + package bifrost import ( @@ -8,10 +10,12 @@ import ( "errors" "os" "path/filepath" + "syscall" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type unexpectedUploadAPI struct{} @@ -20,15 +24,36 @@ func (unexpectedUploadAPI) UploadSBOMFile(context.Context, string, string, strin return errors.New("upload should not be reached") } +func newBlockingStdinFIFO(t *testing.T) (stdin *os.File, writer *os.File) { + t.Helper() + path := filepath.Join(t.TempDir(), "stdin.fifo") + require.NoError(t, syscall.Mkfifo(path, 0o600)) + + type openResult struct { + file *os.File + err error + } + writerResult := make(chan openResult, 1) + go func() { + file, err := os.OpenFile(path, os.O_WRONLY, 0) + writerResult <- openResult{file: file, err: err} + }() + + stdin, err := os.Open(path) + require.NoError(t, err) + result := <-writerResult + require.NoError(t, result.err) + return stdin, result.file +} + func TestSBOMUploadTask_StdinCancellationCleansUpTemporaryFile(t *testing.T) { + // This test replaces os.Stdin and must remain serial. tempDir := t.TempDir() t.Setenv("TMPDIR", tempDir) - stdin, writer, err := os.Pipe() - assert.NoError(t, err) - defer func() { - _ = writer.Close() - }() + stdin, writer := newBlockingStdinFIFO(t) + defer func() { _ = writer.Close() }() + originalStdin := os.Stdin os.Stdin = stdin defer func() { @@ -38,18 +63,21 @@ func TestSBOMUploadTask_StdinCancellationCleansUpTemporaryFile(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - task := sbomUploadTask{ - Options: Options{ - service: "test-service", - serviceVersion: "test-version", - }, - } + task := sbomUploadTask{Options: Options{service: "test-service", serviceVersion: "test-version"}} errCh := make(chan error, 1) go func() { errCh <- task.uploadStdinSBOM(ctx, unexpectedUploadAPI{}) }() waitForTemporaryStdinSBOM(t, tempDir) + _, err := writer.Write([]byte("{")) + require.NoError(t, err) + waitForTemporaryStdinSBOMContent(t, tempDir) + select { + case err := <-errCh: + t.Fatalf("stdin upload finished before cancellation: %v", err) + default: + } cancel() select { @@ -58,9 +86,10 @@ func TestSBOMUploadTask_StdinCancellationCleansUpTemporaryFile(t *testing.T) { case <-time.After(time.Second): t.Fatal("stdin upload did not stop after cancellation") } + matches, err := filepath.Glob(filepath.Join(tempDir, "bifrost-stdin-sbom-*.json")) assert.NoError(t, err) - assert.Empty(t, matches) + assert.Empty(t, matches, "temporary stdin SBOM was not removed") } func waitForTemporaryStdinSBOM(t *testing.T, dir string) { @@ -84,3 +113,31 @@ func waitForTemporaryStdinSBOM(t *testing.T, dir string) { } } } + +func waitForTemporaryStdinSBOMContent(t *testing.T, dir string) { + t.Helper() + deadline := time.After(time.Second) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + for { + matches, err := filepath.Glob(filepath.Join(dir, "bifrost-stdin-sbom-*.json")) + if err != nil { + t.Fatalf("failed to find temporary stdin SBOM: %v", err) + } + for _, path := range matches { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat temporary stdin SBOM: %v", err) + } + if info.Size() > 0 { + return + } + } + select { + case <-deadline: + t.Fatal("stdin data was not copied to the temporary file") + case <-ticker.C: + } + } +} From 4f8c1c6293b9fd237892ef00184bae22117383f7 Mon Sep 17 00:00:00 2001 From: alexanderbsingh Date: Fri, 7 Aug 2026 17:06:16 +0200 Subject: [PATCH 8/8] docs: remove redundant comment --- internal/bifrost/sbom_upload.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/bifrost/sbom_upload.go b/internal/bifrost/sbom_upload.go index ca3a1e5..dcb9cc3 100644 --- a/internal/bifrost/sbom_upload.go +++ b/internal/bifrost/sbom_upload.go @@ -146,10 +146,6 @@ func (t sbomUploadTask) uploadStdinSBOM(ctx context.Context, api API) error { return api.UploadSBOMFile(ctx, t.service, t.serviceVersion, tmpPath) } -// copyStdinWithContext copies stdin into destination and returns promptly when -// ctx is cancelled. ctx is the process signal context (Ctrl+C or SIGTERM), so a -// cancelled copy always means the process is terminating; the io.Copy goroutine -// outlives cancellation only until the process exits moments later. func copyStdinWithContext(ctx context.Context, destination io.Writer, stdin *os.File) error { done := make(chan error, 1) go func() {