diff --git a/blob/blob.go b/blob/blob.go index b6af691b0..9d652ffad 100644 --- a/blob/blob.go +++ b/blob/blob.go @@ -1123,6 +1123,21 @@ func (b *Bucket) NewWriter(ctx context.Context, key string, opts *WriterOptions) } dopts.Metadata = md } + + if len(opts.Tags) > 0 { + tags := make(map[string]string, len(opts.Tags)) + for k, v := range opts.Tags { + if k == "" { + return nil, gcerr.Newf(gcerr.InvalidArgument, nil, "blob: WriterOptions.Tags keys may not be empty strings") + } + if v == "" { + return nil, gcerr.Newf(gcerr.InvalidArgument, nil, "blob: WriterOptions.Tags values may not be empty strings") + } + tags[k] = v + } + dopts.Tags = tags + } + b.mu.RLock() defer b.mu.RUnlock() if b.closed { @@ -1430,6 +1445,10 @@ type WriterOptions struct { // be left untouched. An error for which gcerrors.Code will return // gcerrors.PreconditionFailed will be returned by Write or Close. IfNotExist bool + + // Tags holds key/value tags to be associated with the blob, or nil. + // Keys and values must be not empty if specified. + Tags map[string]string } // CopyOptions sets options for Copy. diff --git a/blob/blob_test.go b/blob/blob_test.go index bc3fccb82..50805995b 100644 --- a/blob/blob_test.go +++ b/blob/blob_test.go @@ -485,6 +485,58 @@ func TestErrorsAreWrapped(t *testing.T) { verifyWrap("Close", err) } +func TestChecksAndSetsTags(t *testing.T) { + ctx := context.Background() + buf := bytes.Repeat([]byte{'A'}, sniffLen) + b := NewBucket(&erroringBucket{}) + + assertError := func(t *testing.T, err error, message string) { + if err == nil { + t.Errorf("expected an error, but got nil") + } + + if err.Error() != message { + t.Errorf("unexpected error: got %s, want %s", err, message) + } + } + + // Empty tag key here + _, err := b.NewWriter(ctx, "", &WriterOptions{ + ContentType: "foo", + Tags: map[string]string{ + "": "empty", + }, + }) + assertError(t, err, "blob: WriterOptions.Tags keys may not be empty strings (code=InvalidArgument)") + + // Empty tag value here + _, err = b.NewWriter(ctx, "", &WriterOptions{ + ContentType: "foo", + Tags: map[string]string{ + "empty": "", + }, + }) + assertError(t, err, "blob: WriterOptions.Tags values may not be empty strings (code=InvalidArgument)") + + // Should get fake bucket error + err = b.WriteAll(ctx, "", buf, &WriterOptions{ + ContentType: "foo", + Tags: map[string]string{ + "foo": "bar", + }, + }) + assertError(t, err, "blob (code=Unknown): fake") + + // Should get fake bucket error + _, err = b.NewWriter(ctx, "", &WriterOptions{ + ContentType: "foo", + Tags: map[string]string{ + "foo": "bar", + }, + }) + assertError(t, err, "blob (code=Unknown): fake") +} + var ( testOpenOnce sync.Once testOpenGot *url.URL diff --git a/blob/driver/driver.go b/blob/driver/driver.go index 9675a5bec..69d9cd7c7 100644 --- a/blob/driver/driver.go +++ b/blob/driver/driver.go @@ -115,6 +115,10 @@ type WriterOptions struct { // When set to true, if a blob exists for the same key in the bucket, the write operation // won't take place. IfNotExist bool + + // Tags holds key/value tags to be associated with the blob, or nil. + // Keys and values must be not empty if specified. + Tags map[string]string } // CopyOptions controls options for Copy. diff --git a/blob/s3blob/s3blob.go b/blob/s3blob/s3blob.go index cbdcc4401..f0c496909 100644 --- a/blob/s3blob/s3blob.go +++ b/blob/s3blob/s3blob.go @@ -723,6 +723,23 @@ func unescapeKey(key string) string { return escape.HexUnescape(key) } +// encodeTags encodes a map of S3 object tags into a URL-encoded query string. +// Each key-value pair in the map becomes a "Key=Value" query parameter. +// This format is suitable for S3 APIs that expect tag sets in query string form, +// such as in PUT or GET Object tagging operations. +// +// For example: +// +// Input: map[string]string{"Key1": "Value1", "Key2": "Value2"} +// Output: "Key1=Value1&Key2=Value2" (URL-encoded as needed) +func encodeTags(tags map[string]string) string { + values := url.Values{} + for k, v := range tags { + values.Set(k, v) + } + return values.Encode() +} + // NewTypedWriter implements driver.NewTypedWriter. func (b *bucket) NewTypedWriter(ctx context.Context, key, contentType string, opts *driver.WriterOptions) (driver.Writer, error) { key = escapeKey(key) @@ -744,11 +761,15 @@ func (b *bucket) NewTypedWriter(ctx context.Context, key, contentType string, op }) md[k] = url.PathEscape(v) } + + encodedTags := encodeTags(opts.Tags) + req := &s3.PutObjectInput{ Bucket: aws.String(b.name), ContentType: aws.String(contentType), Key: aws.String(key), Metadata: md, + Tagging: aws.String(encodedTags), } if opts.IfNotExist { diff --git a/blob/s3blob/s3blob_test.go b/blob/s3blob/s3blob_test.go index 06d31db13..38db0f715 100644 --- a/blob/s3blob/s3blob_test.go +++ b/blob/s3blob/s3blob_test.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -382,3 +383,48 @@ func TestToServerSideEncryptionType(t *testing.T) { } } } + +func TestEncodeTags(t *testing.T) { + tests := []struct { + name string + input map[string]string + expected url.Values // We compare as parsed query values for robustness + }{ + { + name: "basic tags", + input: map[string]string{"Key1": "Value1", "Key2": "Value2"}, + expected: url.Values{"Key1": {"Value1"}, "Key2": {"Value2"}}, + }, + { + name: "special characters", + input: map[string]string{"Key With Space": "Val+1&2"}, + expected: url.Values{"Key With Space": {"Val+1&2"}}, + }, + { + name: "empty map", + input: map[string]string{}, + expected: url.Values{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded := encodeTags(tt.input) + + got, err := url.ParseQuery(encoded) + if err != nil { + t.Fatalf("failed to parse encoded string: %v", err) + } + + if len(got) != len(tt.expected) { + t.Fatalf("expected %d keys, got %d", len(tt.expected), len(got)) + } + + for k, v := range tt.expected { + if gv, ok := got[k]; !ok || gv[0] != v[0] { + t.Errorf("expected %q=%q, got %q", k, v[0], gv) + } + } + }) + } +}