Skip to content

Commit 5a4e857

Browse files
frristclaude
andcommitted
feat(s3): implement object tagging (VersionState second tenant)
Implements docs/s3-object-tagging.md. Tags live in the per-key version-state tree the object-lock design built: the three per-version methods run the shared check order with no bucket gate (the state-target and state-write helpers gain a require-lock flag), the merge rule carries lock fields across tag writes and tags across lock writes, and DeleteObjectTagging exercises the empty-block elision rule for real. A version without tags answers the empty set, never a sentinel. Creation-time stamping covers the x-amz-tagging header on PutObject (parsed via backend.ParseObjectTags before ingest, so an invalid header uploads nothing), the copy tagging directive (COPY inherits the resolved source version's tags, REPLACE takes the request header), and the multipart carry (the raw header validated at create, stored on the session, stamped at Complete). GET/HEAD gain the x-amz-tagging-count echo from the same state-block fetch as the lock headers. Tags on unversioned buckets make the write-rule discard cleanup live: a null-replacing PUT prunes the old version's tag entry in the same commit. itest gains the three tagging categories and the eight Versioning_* tagging rows, and promotes PutObject_tagging, CreateMultipartUpload_with_tagging, CopyObject_should_copy_tagging, CopyObject_should_replace_tagging, GetObject_success, and HeadObject_success (the last two XFailed on the TagCount echo). DeleteObjectTagging_expected_bucket_owner is XFail: the hilt flow substitutes the root access key as the ACL owner, so the tenant's correct-owner request 403s — the existing *_expected_owner surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4e3b30f commit 5a4e857

14 files changed

Lines changed: 563 additions & 63 deletions

itest/versity_multipart_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import (
88

99
// Multipart groups of the S3 conformance partition, partitioned empirically
1010
// against the forge-mode stack (see the curation note in README.md). The
11-
// remaining xfail surface: tagging/ACL on create (FIL-534/FIL-525), the
12-
// UploadPartCopy group (FIL-586), and one load-sensitive concurrency case.
11+
// remaining xfail surface: ACL on create (FIL-525), the UploadPartCopy group
12+
// (FIL-586), and one load-sensitive concurrency case.
1313

1414
var createMultipartPass = []forgeCase{
1515
{name: "non_existing_bucket", fn: integration.CreateMultipartUpload_non_existing_bucket},
1616
{name: "dir_obj", fn: integration.CreateMultipartUpload_dir_obj},
1717
{name: "long_metadata", fn: integration.CreateMultipartUpload_long_metadata},
1818
{name: "with_metadata", fn: integration.CreateMultipartUpload_with_metadata},
19+
{name: "with_tagging", fn: integration.CreateMultipartUpload_with_tagging},
1920
{name: "with_object_lock_invalid_retention", fn: integration.CreateMultipartUpload_with_object_lock_invalid_retention},
2021
{name: "with_object_lock_not_enabled", fn: integration.CreateMultipartUpload_with_object_lock_not_enabled},
2122
{name: "past_retain_until_date", fn: integration.CreateMultipartUpload_past_retain_until_date},
@@ -31,7 +32,6 @@ var createMultipartPass = []forgeCase{
3132
}
3233

3334
var createMultipartXFail = []forgeCase{
34-
{name: "with_tagging", fn: integration.CreateMultipartUpload_with_tagging},
3535
{name: "object_acl_not_supported", fn: integration.CreateMultipartUpload_object_acl_not_supported},
3636
// with_object_lock passes but needs the versioned teardown: it runs as
3737
// LockCreation/CreateMultipartUpload_with_object_lock (versity_lock_test.go).

itest/versity_object_test.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ var putObjectPass = []forgeCase{
4646
{name: "default_checksum", fn: integration.PutObject_default_checksum},
4747
{name: "default_content_type", fn: integration.PutObject_default_content_type},
4848
{name: "success", fn: integration.PutObject_success},
49+
{name: "tagging", fn: integration.PutObject_tagging},
4950
{name: "with_metadata", fn: integration.PutObject_with_metadata},
5051
}
5152

@@ -56,7 +57,6 @@ var putObjectXFail = []forgeCase{
5657
// flow.
5758
{name: "should_combine_metadata", fn: integration.PutObject_should_combine_metadata},
5859
{name: "object_acl_not_supported", fn: integration.PutObject_object_acl_not_supported},
59-
{name: "tagging", fn: integration.PutObject_tagging},
6060
// with_object_lock passes but needs the versioned teardown: it runs as
6161
// LockCreation/PutObject_with_object_lock (versity_lock_test.go).
6262
}
@@ -78,6 +78,7 @@ var getObjectPass = []forgeCase{
7878
{name: "mp_part_number_resp_status", fn: integration.GetObject_mp_part_number_resp_status},
7979
{name: "mp_part_number_success", fn: integration.GetObject_mp_part_number_success},
8080
{name: "non_mp_part_number_1_success", fn: integration.GetObject_non_mp_part_number_1_success},
81+
{name: "success", fn: integration.GetObject_success},
8182
{name: "large_object", fn: integration.GetObject_large_object},
8283
{name: "non_existing_dir_object", fn: integration.GetObject_non_existing_dir_object},
8384
{name: "not_enabled_checksum_mode", fn: integration.GetObject_not_enabled_checksum_mode},
@@ -94,8 +95,6 @@ var getObjectXFail = []forgeCase{
9495
{name: "directory_success", fn: integration.GetObject_directory_success},
9596
// Requires PutBucketPolicy, which ingot 501s (NotImplemented).
9697
{name: "overrides_fail_public", fn: integration.GetObject_overrides_fail_public},
97-
// Asserts object tagging (TagCount), which is unimplemented.
98-
{name: "success", fn: integration.GetObject_success},
9998
}
10099

101100
var headObjectPass = []forgeCase{
@@ -115,6 +114,7 @@ var headObjectPass = []forgeCase{
115114
{name: "mp_part_number_resp_status", fn: integration.HeadObject_mp_part_number_resp_status},
116115
{name: "mp_part_number_success", fn: integration.HeadObject_mp_part_number_success},
117116
{name: "non_mp_part_number_1_success", fn: integration.HeadObject_non_mp_part_number_1_success},
117+
{name: "success", fn: integration.HeadObject_success},
118118
{name: "non_existing_dir_object", fn: integration.HeadObject_non_existing_dir_object},
119119
{name: "not_enabled_checksum_mode", fn: integration.HeadObject_not_enabled_checksum_mode},
120120
{name: "range_and_part_number", fn: integration.HeadObject_range_and_part_number},
@@ -126,8 +126,6 @@ var headObjectPass = []forgeCase{
126126
var headObjectXFail = []forgeCase{
127127
// Requires PutBucketPolicy, which ingot 501s (NotImplemented).
128128
{name: "overrides_fail_public", fn: integration.HeadObject_overrides_fail_public},
129-
// Asserts object tagging (TagCount), which is unimplemented.
130-
{name: "success", fn: integration.HeadObject_success},
131129
}
132130

133131
var deleteObjectPass = []forgeCase{
@@ -173,6 +171,8 @@ var copyObjectPass = []forgeCase{
173171
{name: "should_copy_meta_props", fn: integration.CopyObject_should_copy_meta_props},
174172
{name: "should_replace_meta_props", fn: integration.CopyObject_should_replace_meta_props},
175173
{name: "missing_bucket_lock", fn: integration.CopyObject_missing_bucket_lock},
174+
{name: "should_copy_tagging", fn: integration.CopyObject_should_copy_tagging},
175+
{name: "should_replace_tagging", fn: integration.CopyObject_should_replace_tagging},
176176
// invalid_legal_hold / invalid_object_lock_mode put a source object into
177177
// a withLock() bucket and need the versioned teardown: they run under
178178
// LockCreation (versity_lock_test.go).
@@ -183,12 +183,10 @@ var copyObjectPass = []forgeCase{
183183
{name: "to_itself_by_replacing_the_checksum", fn: integration.CopyObject_to_itself_by_replacing_the_checksum},
184184
}
185185

186-
// Observed failing against the forge stack: multi-account semantics, tagging,
187-
// and ACLs are unimplemented surface.
186+
// Observed failing against the forge stack: multi-account semantics and
187+
// ACLs are unimplemented surface.
188188
var copyObjectXFail = []forgeCase{
189189
{name: "not_owned_source_bucket", fn: integration.CopyObject_not_owned_source_bucket},
190-
{name: "should_replace_tagging", fn: integration.CopyObject_should_replace_tagging},
191-
{name: "should_copy_tagging", fn: integration.CopyObject_should_copy_tagging},
192190
{name: "object_acl_not_supported", fn: integration.CopyObject_object_acl_not_supported},
193191
{name: "incorrect_source_bucket_expected_owner", fn: integration.CopyObject_incorrect_source_bucket_expected_owner},
194192
// with_legal_hold / with_retention_lock pass but need the versioned

itest/versity_tagging_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//go:build itest
2+
3+
package itest
4+
5+
import (
6+
"github.com/fil-forge/versitygw/tests/integration"
7+
)
8+
9+
// Object-tagging groups of the S3 conformance partition (upstream
10+
// TestPutObjectTagging, TestGetObjectTagging, TestDeleteObjectTagging),
11+
// curated per docs/s3-object-tagging.md §6. These categories run with the
12+
// plain conf: their buckets are neither lock-enabled nor versioned, so the
13+
// plain teardown applies. The versioned tagging behaviors are the
14+
// Versioning_* rows in versity_versioning_test.go.
15+
16+
var putObjectTaggingPass = []forgeCase{
17+
{name: "non_existing_object", fn: integration.PutObjectTagging_non_existing_object},
18+
{name: "long_tags", fn: integration.PutObjectTagging_long_tags},
19+
{name: "duplicate_keys", fn: integration.PutObjectTagging_duplicate_keys},
20+
{name: "tag_count_limit", fn: integration.PutObjectTagging_tag_count_limit},
21+
{name: "invalid_tags", fn: integration.PutObjectTagging_invalid_tags},
22+
{name: "success", fn: integration.PutObjectTagging_success},
23+
}
24+
25+
var getObjectTaggingPass = []forgeCase{
26+
{name: "non_existing_object", fn: integration.GetObjectTagging_non_existing_object},
27+
{name: "unset_tags", fn: integration.GetObjectTagging_unset_tags},
28+
{name: "invalid_parent", fn: integration.GetObjectTagging_invalid_parent},
29+
{name: "success", fn: integration.GetObjectTagging_success},
30+
}
31+
32+
var deleteObjectTaggingPass = []forgeCase{
33+
{name: "non_existing_object", fn: integration.DeleteObjectTagging_non_existing_object},
34+
{name: "success_status", fn: integration.DeleteObjectTagging_success_status},
35+
{name: "success", fn: integration.DeleteObjectTagging_success},
36+
}
37+
38+
var deleteObjectTaggingXFail = []forgeCase{
39+
// Sends x-amz-expected-bucket-owner with the tenant's own access key,
40+
// but the hilt flow substitutes the configured root access key as the
41+
// ACL owner (s3frontend/bucket.go GetBucketAcl), so the correct-owner
42+
// request 403s — the same owner-substitution surface as the
43+
// *_expected_owner xfails in the object tables.
44+
{name: "expected_bucket_owner", fn: integration.DeleteObjectTagging_expected_bucket_owner},
45+
}

itest/versity_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ func TestForgeVersity(t *testing.T) {
6666
{"CopyObject", copyObjectPass, copyObjectXFail, false},
6767
{"DeleteObjects", deleteObjectsPass, nil, false},
6868
{"CreateMultipartUpload", createMultipartPass, createMultipartXFail, false},
69+
// The object-tagging partition (docs/s3-object-tagging.md §6):
70+
// plain buckets, plain teardown.
71+
{"PutObjectTagging", putObjectTaggingPass, nil, false},
72+
{"GetObjectTagging", getObjectTaggingPass, nil, false},
73+
{"DeleteObjectTagging", deleteObjectTaggingPass, deleteObjectTaggingXFail, false},
6974
{"UploadPart", uploadPartPass, uploadPartXFail, false},
7075
{"UploadPartCopy", uploadPartCopyPass, uploadPartCopyXFail, false},
7176
{"ListParts", listPartsPass, listPartsXFail, false},

itest/versity_versioning_test.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@ import (
1919
// /blob/release), which needs hilt ≥ #37 (blob.Remove in the write set) and
2020
// smelt ≥ #19 (the piri blob/release + blob/reject seed delegations).
2121
//
22-
// Excluded entirely (feature not modeled, mirroring the absent Tagging
23-
// category): the Versioning_* object-tagging, GetObjectAttributes,
24-
// UploadPartCopy, and AccessControl (admin-API users) cases. Also excluded:
25-
// the upstream VersioningDisabled_{Get,Put}BucketVersioning_not_configured
26-
// pair, which asserts versitygw's versioning-disabled deployment mode
22+
// Excluded entirely (feature not modeled): the Versioning_*
23+
// GetObjectAttributes, UploadPartCopy, and AccessControl (admin-API users)
24+
// cases. Also excluded: the upstream
25+
// VersioningDisabled_{Get,Put}BucketVersioning_not_configured pair, which
26+
// asserts versitygw's versioning-disabled deployment mode
2727
// (ErrVersioningNotConfigured) — ingot always implements versioning, so that
2828
// mode never exists here. The object-lock / retention / legal-hold /
29-
// versioned-WORM rows live in the tables below (docs/s3-object-lock.md §11);
30-
// the standalone lock groups are curated in versity_lock_test.go.
29+
// versioned-WORM rows live in the tables below (docs/s3-object-lock.md §11),
30+
// as do the versioned tagging rows (docs/s3-object-tagging.md §6); the
31+
// standalone lock and tagging groups are curated in versity_lock_test.go and
32+
// versity_tagging_test.go.
3133

3234
var putBucketVersioningPass = []forgeCase{
3335
{name: "non_existing_bucket", fn: integration.PutBucketVersioning_non_existing_bucket},
@@ -136,6 +138,15 @@ var versioningPass = []forgeCase{
136138
{name: "WORM_CopyObject_overwrite_locked_object", fn: integration.Versioning_WORM_CopyObject_overwrite_locked_object},
137139
{name: "WORM_CompleteMultipartUpload_overwrite_locked_object", fn: integration.Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object},
138140
{name: "WORM_remove_delete_marker_under_bucket_default_retention", fn: integration.Versioning_WORM_remove_delete_marker_under_bucket_default_retention},
141+
// Tagging (docs/s3-object-tagging.md §6)
142+
{name: "PutObjectTagging_invalid_versionId", fn: integration.Versioning_PutObjectTagging_invalid_versionId},
143+
{name: "PutObjectTagging_non_existing_object_version", fn: integration.Versioning_PutObjectTagging_non_existing_object_version},
144+
{name: "GetObjectTagging_invalid_versionId", fn: integration.Versioning_GetObjectTagging_invalid_versionId},
145+
{name: "GetObjectTagging_non_existing_object_version", fn: integration.Versioning_GetObjectTagging_non_existing_object_version},
146+
{name: "DeleteObjectTagging_invalid_versionId", fn: integration.Versioning_DeleteObjectTagging_invalid_versionId},
147+
{name: "DeleteObjectTagging_non_existing_object_version", fn: integration.Versioning_DeleteObjectTagging_non_existing_object_version},
148+
{name: "PutGetDeleteObjectTagging_delete_marker", fn: integration.Versioning_PutGetDeleteObjectTagging_delete_marker},
149+
{name: "PutGetDeleteObjectTagging_success", fn: integration.Versioning_PutGetDeleteObjectTagging_success},
139150
}
140151

141152
// versioningXFail is empty: DeleteObject_non_existing_objects moved to the
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- +goose Up
2+
-- Object tagging (docs/s3-object-tagging.md §4). Tag sets live in the
3+
-- catalog (the per-key version-state tree); the registry carries only
4+
-- CreateMultipartUpload's raw x-amz-tagging header on its way to Complete,
5+
-- which stamps the parsed set onto the version it commits.
6+
ALTER TABLE ingot.multipart_sessions
7+
ADD COLUMN tagging text;
8+
9+
-- +goose Down
10+
ALTER TABLE ingot.multipart_sessions
11+
DROP COLUMN tagging;

registry/stores.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,12 @@ type MultipartSession struct {
120120
LockMode string
121121
LockRetainUntil *time.Time
122122
LockLegalHold string
123-
Metadata map[string]string
124-
CreatedAt time.Time
123+
// Tagging carries CreateMultipartUpload's raw x-amz-tagging header
124+
// (validated at create) to Complete, which stamps the parsed set
125+
// (docs/s3-object-tagging.md §4). Empty when the header was absent.
126+
Tagging string
127+
Metadata map[string]string
128+
CreatedAt time.Time
125129
}
126130

127131
// MultipartPart is one row of ingot.multipart_parts. BlobDigests is the

registry/stores_postgres.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,13 @@ func (r *Postgres) CreateSession(ctx context.Context, s MultipartSession) error
275275
(upload_id, bucket, object_key, state, content_type, metadata,
276276
content_encoding, content_disposition, content_language, cache_control, expires,
277277
website_redirect_location, checksum_algorithm, checksum_type,
278-
lock_mode, lock_retain_until, lock_legal_hold)
279-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
278+
lock_mode, lock_retain_until, lock_legal_hold, tagging)
279+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
280280
s.UploadID, s.Bucket, s.ObjectKey, state, nullString(s.ContentType), meta,
281281
nullString(s.ContentEncoding), nullString(s.ContentDisposition),
282282
nullString(s.ContentLanguage), nullString(s.CacheControl), nullString(s.Expires),
283283
nullString(s.WebsiteRedirectLocation), nullString(s.ChecksumAlgorithm), nullString(s.ChecksumType),
284-
nullString(s.LockMode), s.LockRetainUntil, nullString(s.LockLegalHold))
284+
nullString(s.LockMode), s.LockRetainUntil, nullString(s.LockLegalHold), nullString(s.Tagging))
285285
if err != nil {
286286
var pgErr *pgconn.PgError
287287
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation {
@@ -297,7 +297,7 @@ func (r *Postgres) GetSession(ctx context.Context, uploadID string) (*MultipartS
297297
`SELECT upload_id, bucket, object_key, state, content_type, metadata, created_at,
298298
content_encoding, content_disposition, content_language, cache_control, expires,
299299
website_redirect_location, checksum_algorithm, checksum_type,
300-
lock_mode, lock_retain_until, lock_legal_hold
300+
lock_mode, lock_retain_until, lock_legal_hold, tagging
301301
FROM ingot.multipart_sessions WHERE upload_id = $1`,
302302
uploadID)
303303
s, err := scanSession(row)
@@ -314,11 +314,11 @@ func (r *Postgres) GetSession(ctx context.Context, uploadID string) (*MultipartS
314314
// (see GetSession/ListSessions selects).
315315
func scanSession(row pgx.Row) (*MultipartSession, error) {
316316
s := &MultipartSession{}
317-
var contentType, ce, cd, cl, cc, exp, wrl, ckAlgo, ckType, lockMode, lockHold *string
317+
var contentType, ce, cd, cl, cc, exp, wrl, ckAlgo, ckType, lockMode, lockHold, tagging *string
318318
var meta []byte
319319
err := row.Scan(&s.UploadID, &s.Bucket, &s.ObjectKey, &s.State, &contentType, &meta, &s.CreatedAt,
320320
&ce, &cd, &cl, &cc, &exp, &wrl, &ckAlgo, &ckType,
321-
&lockMode, &s.LockRetainUntil, &lockHold)
321+
&lockMode, &s.LockRetainUntil, &lockHold, &tagging)
322322
if err != nil {
323323
return nil, err
324324
}
@@ -338,6 +338,7 @@ func scanSession(row pgx.Row) (*MultipartSession, error) {
338338
setIfNotNil(&s.ChecksumType, ckType)
339339
setIfNotNil(&s.LockMode, lockMode)
340340
setIfNotNil(&s.LockLegalHold, lockHold)
341+
setIfNotNil(&s.Tagging, tagging)
341342
if s.Metadata, err = unmarshalMetadata(meta); err != nil {
342343
return nil, err
343344
}
@@ -409,7 +410,7 @@ func (r *Postgres) ListSessions(ctx context.Context, bucket string) ([]Multipart
409410
`SELECT upload_id, bucket, object_key, state, content_type, metadata, created_at,
410411
content_encoding, content_disposition, content_language, cache_control, expires,
411412
website_redirect_location, checksum_algorithm, checksum_type,
412-
lock_mode, lock_retain_until, lock_legal_hold
413+
lock_mode, lock_retain_until, lock_legal_hold, tagging
413414
FROM ingot.multipart_sessions WHERE bucket = $1
414415
ORDER BY object_key ASC, created_at ASC, upload_id ASC`,
415416
bucket)
@@ -437,7 +438,7 @@ func (r *Postgres) ListStaleSessions(ctx context.Context, state string, cutoff t
437438
`SELECT upload_id, bucket, object_key, state, content_type, metadata, created_at,
438439
content_encoding, content_disposition, content_language, cache_control, expires,
439440
website_redirect_location, checksum_algorithm, checksum_type,
440-
lock_mode, lock_retain_until, lock_legal_hold
441+
lock_mode, lock_retain_until, lock_legal_hold, tagging
441442
FROM ingot.multipart_sessions WHERE state = $1 AND created_at < $2
442443
ORDER BY created_at ASC`,
443444
state, cutoff)

s3frontend/copy.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,21 @@ func (b *Backend) CopyObject(ctx context.Context, input s3response.CopyObjectInp
5757

5858
// x-amz-object-lock-* headers stamp the DESTINATION version; lock state
5959
// is never inherited from the source (docs/s3-object-lock.md §7).
60-
lockState, err := lockStateFromHeaders(bucketState, input.ObjectLockMode, input.ObjectLockRetainUntilDate, input.ObjectLockLegalHoldStatus)
60+
initState, err := lockStateFromHeaders(bucketState, input.ObjectLockMode, input.ObjectLockRetainUntilDate, input.ObjectLockLegalHoldStatus)
6161
if err != nil {
6262
return s3response.CopyObjectOutput{}, err
6363
}
64+
// Destination tags per x-amz-tagging-directive (the controller defaults
65+
// an absent header to COPY, so only COPY or REPLACE arrives): REPLACE
66+
// parses the request's own header, failing before any further work; COPY
67+
// inherits the source version's tags once it resolves below
68+
// (docs/s3-object-tagging.md §4).
69+
var dstTags map[string]string
70+
if input.TaggingDirective == types.TaggingDirectiveReplace {
71+
if dstTags, err = backend.ParseObjectTags(backend.GetStringFromPtr(input.Tagging)); err != nil {
72+
return s3response.CopyObjectOutput{}, err
73+
}
74+
}
6475

6576
// Resolve the source version (NoSuchBucket / NoSuchKey / NoSuchVersion /
6677
// InvalidArgument map from resolution). A delete marker cannot be a copy
@@ -91,6 +102,15 @@ func (b *Backend) CopyObject(ctx context.Context, input s3response.CopyObjectInp
91102
}); err != nil {
92103
return s3response.CopyObjectOutput{}, err
93104
}
105+
if input.TaggingDirective == types.TaggingDirectiveCopy {
106+
srcVs, err := b.versionStateOf(ctx, srcRv)
107+
if err != nil {
108+
return s3response.CopyObjectOutput{}, err
109+
}
110+
if srcVs != nil {
111+
dstTags = srcVs.Tags
112+
}
113+
}
94114

95115
// Destination checksum: same bytes → the source's checksum (and type)
96116
// carries over. A request naming a DIFFERENT x-amz-checksum-algorithm
@@ -158,7 +178,7 @@ func (b *Backend) CopyObject(ctx context.Context, input s3response.CopyObjectInp
158178
// Commit to the destination via the write rule: splice + reference index.
159179
// The new claims use the DESTINATION bucket/space; the same digests gain
160180
// another reference.
161-
node, effState, err := b.commitVersion(ctx, bucketState, dstKey, dstMf, lockState, nil)
181+
node, effState, err := b.commitVersion(ctx, bucketState, dstKey, dstMf, withTags(initState, dstTags), nil)
162182
if err != nil {
163183
return s3response.CopyObjectOutput{}, err
164184
}

0 commit comments

Comments
 (0)