Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions blob/blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions blob/blob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions blob/driver/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions blob/s3blob/s3blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions blob/s3blob/s3blob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
Expand Down Expand Up @@ -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)
}
}
})
}
}
Loading