From 9fd128e479f46e3d1b54be21ff5a5fd5eca89a6b Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Wed, 30 Sep 2026 18:12:08 -0700 Subject: [PATCH 1/3] fix(simplestorage): sign headers passed through WithS3Options on presigned URLs PresignURL accepted WithS3Options but never forwarded them to the presigner, so a header added that way, for example X-Tigris-Regions via WithStaticReplicationRegions, was missing from X-Amz-SignedHeaders. Tigris is starting to reject x-amz-* and x-tigris-* headers that are not covered by the signature, which turned the silently dropped option into a rejected upload. The GET, PUT and DELETE presign paths now pass the per-call S3 options into PresignOptions.ClientOptions, so the same header middleware that runs on a direct call runs inside the presigner and the header lands in the signature. Assisted-by: Claude Fable 5.1 via Claude Code Signed-off-by: Yevgeniy Firsov --- simplestorage/client.go | 23 +++++--- simplestorage/presign_options_test.go | 79 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 simplestorage/presign_options_test.go diff --git a/simplestorage/client.go b/simplestorage/client.go index 3fc93c5..9982905 100644 --- a/simplestorage/client.go +++ b/simplestorage/client.go @@ -538,16 +538,25 @@ func (c *Client) PresignURL(ctx context.Context, method string, key string, expi switch method { case http.MethodGet: - return presignURLGet(ctx, presignClient, o.BucketName, key, expiry) + return presignURLGet(ctx, presignClient, o.BucketName, key, expiry, o) case http.MethodPut: return presignURLPut(ctx, presignClient, o.BucketName, key, expiry, o) case http.MethodDelete: - return presignURLDelete(ctx, presignClient, o.BucketName, key, expiry) + return presignURLDelete(ctx, presignClient, o.BucketName, key, expiry, o) } return "", nil // unreachable } +func presignOptions(expiry time.Duration, opts ClientOptions) []func(*s3.PresignOptions) { + return []func(*s3.PresignOptions){ + s3.WithPresignExpires(expiry), + func(po *s3.PresignOptions) { + po.ClientOptions = append(po.ClientOptions, opts.S3Options...) + }, + } +} + // generateRandomSuffix generates a random hexadecimal string of the specified length. // Uses (length+1)/2 random bytes so odd lengths still produce a hex string at // least length characters long before truncation. @@ -636,11 +645,11 @@ func raise[T comparable](v T) *T { } // presignURLGet generates a presigned URL for GET operations. -func presignURLGet(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration) (string, error) { +func presignURLGet(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration, opts ClientOptions) (string, error) { presignResult, err := client.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), - }, s3.WithPresignExpires(expiry)) + }, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign get: %w", err) } @@ -662,7 +671,7 @@ func presignURLPut(ctx context.Context, client *s3.PresignClient, bucket, key st input.ContentDisposition = opts.ContentDisposition } - presignResult, err := client.PresignPutObject(ctx, input, s3.WithPresignExpires(expiry)) + presignResult, err := client.PresignPutObject(ctx, input, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign put: %w", err) } @@ -671,11 +680,11 @@ func presignURLPut(ctx context.Context, client *s3.PresignClient, bucket, key st } // presignURLDelete generates a presigned URL for DELETE operations. -func presignURLDelete(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration) (string, error) { +func presignURLDelete(ctx context.Context, client *s3.PresignClient, bucket, key string, expiry time.Duration, opts ClientOptions) (string, error) { presignResult, err := client.PresignDeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), - }, s3.WithPresignExpires(expiry)) + }, presignOptions(expiry, opts)...) if err != nil { return "", fmt.Errorf("presign delete: %w", err) } diff --git a/simplestorage/presign_options_test.go b/simplestorage/presign_options_test.go new file mode 100644 index 0000000..9935d67 --- /dev/null +++ b/simplestorage/presign_options_test.go @@ -0,0 +1,79 @@ +package simplestorage + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + storage "github.com/tigrisdata/storage-go" + "github.com/tigrisdata/storage-go/tigrisheaders" +) + +func offlinePresignClient(t *testing.T) *Client { + t.Helper() + + s3cli := s3.New(s3.Options{ + Region: "auto", + BaseEndpoint: aws.String("https://t3.storage.dev"), + Credentials: credentials.NewStaticCredentialsProvider("tid_test", "tsec_test", ""), + }) + + return &Client{cli: &storage.Client{Client: s3cli}, options: Options{BucketName: "presign-test"}} +} + +func signedHeaders(t *testing.T, rawURL string) []string { + t.Helper() + + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse presigned url: %v", err) + } + + return strings.Split(u.Query().Get("X-Amz-SignedHeaders"), ";") +} + +func TestPresignURLSignsS3OptionHeaders(t *testing.T) { + cli := offlinePresignClient(t) + + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + u, err := cli.PresignURL(context.Background(), method, "k", time.Minute, + WithS3Options(tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{tigrisheaders.IAD, tigrisheaders.FRA}))) + if err != nil { + t.Fatalf("PresignURL(%s): %v", method, err) + } + + got := signedHeaders(t, u) + for _, want := range []string{"host", "x-tigris-regions"} { + found := false + for _, h := range got { + if h == want { + found = true + } + } + if !found { + t.Errorf("PresignURL(%s): signed headers %v do not include %q", method, got, want) + } + } + }) + } +} + +func TestPresignURLWithoutS3OptionsSignsHostOnly(t *testing.T) { + cli := offlinePresignClient(t) + + u, err := cli.PresignURL(context.Background(), http.MethodPut, "k", time.Minute) + if err != nil { + t.Fatalf("PresignURL: %v", err) + } + + if got := signedHeaders(t, u); len(got) != 1 || got[0] != "host" { + t.Errorf("signed headers = %v, want [host]", got) + } +} From 547f460a34fc4df0d29735faa2b9681aadd1cc68 Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Wed, 30 Sep 2026 18:15:20 -0700 Subject: [PATCH 2/3] docs(simplestorage): example of a presigned PUT with signed Tigris headers Assisted-by: Claude Fable 5.1 via Claude Code Signed-off-by: Yevgeniy Firsov --- simplestorage/presign_example_test.go | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/simplestorage/presign_example_test.go b/simplestorage/presign_example_test.go index b13c744..b30558f 100644 --- a/simplestorage/presign_example_test.go +++ b/simplestorage/presign_example_test.go @@ -5,9 +5,11 @@ import ( "fmt" "log" "net/http" + "strings" "time" simplestorage "github.com/tigrisdata/storage-go/simplestorage" + "github.com/tigrisdata/storage-go/tigrisheaders" ) func ExampleClient_PresignURL_get() { @@ -52,6 +54,47 @@ func ExampleClient_PresignURL_put() { fmt.Println("Presigned PUT URL:", url) } +func ExampleClient_PresignURL_signedTigrisHeaders() { + ctx := context.Background() + + client, err := simplestorage.New(ctx, + simplestorage.WithBucket("my-default-bucket"), + ) + if err != nil { + log.Fatal(err) + } + + // Headers passed through WithS3Options are part of the signature. The + // client using the URL must send them with the same values, or Tigris + // rejects the upload because the signature will not match. + url, err := client.PresignURL(ctx, http.MethodPut, "uploads/model.bin", 15*time.Minute, + simplestorage.WithContentType("application/octet-stream"), + simplestorage.WithS3Options( + tigrisheaders.WithStaticReplicationRegions([]tigrisheaders.Region{tigrisheaders.IAD, tigrisheaders.FRA}), + tigrisheaders.WithHeader("x-amz-meta-owner", "alice"), + ), + ) + if err != nil { + log.Fatal(err) // handle the error here + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader("model bytes")) + if err != nil { + log.Fatal(err) // handle the error here + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("X-Tigris-Regions", "iad,fra") + req.Header.Set("x-amz-meta-owner", "alice") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatal(err) // handle the error here + } + defer resp.Body.Close() + + fmt.Println("Upload status:", resp.Status) +} + func ExampleClient_PresignURL_delete() { ctx := context.Background() From dde5525c2311280bb77a1d50e7f14105de8035c8 Mon Sep 17 00:00:00 2001 From: Yevgeniy Firsov Date: Wed, 30 Sep 2026 18:19:16 -0700 Subject: [PATCH 3/3] test(simplestorage): run setupEnv before each case and restore after it setupEnv returns the restore function, so "defer cleanup()" called the setup at the end of the case and dropped the restore. staticcheck SA9010 now flags it; "defer cleanup()()" runs the setup first and defers the restore. Assisted-by: Claude Fable 5.1 via Claude Code Signed-off-by: Yevgeniy Firsov --- simplestorage/buckets_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/simplestorage/buckets_test.go b/simplestorage/buckets_test.go index 8901d63..c0e3ca6 100644 --- a/simplestorage/buckets_test.go +++ b/simplestorage/buckets_test.go @@ -75,7 +75,7 @@ func TestCreateBucket(t *testing.T) { if cleanup == nil { cleanup = func() func() { return func() {} } } - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -128,7 +128,7 @@ func TestDeleteBucket(t *testing.T) { if cleanup == nil { cleanup = func() func() { return func() {} } } - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -176,7 +176,7 @@ func TestListBuckets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cleanup := tt.setupEnv - defer cleanup() + defer cleanup()() client, err := New(context.Background(), WithEndpoint("https://test.endpoint.dev"), @@ -219,7 +219,7 @@ func TestGetBucketInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cleanup := tt.setupEnv - defer cleanup() + defer cleanup()() // Create a client os.Setenv("TIGRIS_STORAGE_BUCKET", "dummy-bucket")