Skip to content

Commit 36c3490

Browse files
frristclaude
andcommitted
fix(s3frontend): conformance-align multipart validation; abort in-flight uploads on DeleteBucket
Three upstream conformance cases return to the pass tables with behavior fixes instead of xfail rows: - CompleteMultipartUpload: a part entry missing PartNumber or ETag is MalformedXML, and a part number below 1 is InvalidArgument (PartNumber), mirroring versitygw's posix backend; the ETag match is now mandatory and the >10000 special case folds into the membership check (InvalidPart). - ListMultipartUploads: upload-id-marker validation mirrors upstream's MultipartUploadLister — the marker must be a valid UUID naming an upload of the first key group at/after the key marker (else InvalidArgument, upload-id-marker), and listing resumes past it. DeleteBucket now implicitly aborts the bucket's open multipart sessions (upstream's teardown never aborts them and expects the delete to succeed), releasing their parked part blobs before the hilt space delete. CompleteMultipartUpload_conditional_writes stays xfail: its conditional matrix passes, but the implicit abort's /blob/abort goes out proofless — hilt's per-operation grants carry blob.Abort for the S3 Abort operation only. Promote once hilt's s3perm map grants blob.Abort on bucket delete. Validated: full local TestForgeVersity, 219 pass / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e5e04c0 commit 36c3490

4 files changed

Lines changed: 91 additions & 50 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/fxamacker/cbor/v2 v2.9.2
1717
github.com/go-jose/go-jose/v4 v4.1.4
1818
github.com/gofiber/fiber/v3 v3.3.0
19+
github.com/google/uuid v1.6.0
1920
github.com/ipfs/go-block-format v0.2.3
2021
github.com/ipfs/go-cid v0.6.1
2122
github.com/ipfs/go-ipld-cbor v0.2.1
@@ -111,7 +112,6 @@ require (
111112
github.com/golang/protobuf v1.5.4 // indirect
112113
github.com/google/go-cmp v0.7.0 // indirect
113114
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
114-
github.com/google/uuid v1.6.0 // indirect
115115
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
116116
github.com/hashicorp/errwrap v1.1.0 // indirect
117117
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect

itest/versity_multipart_test.go

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ import (
99
// Multipart groups of the S3 conformance partition, partitioned empirically
1010
// against the forge-mode stack (see the curation note in README.md). The
1111
// remaining xfail surface: part-level checksums (FIL-620), tagging/object-lock
12-
// /ACL on create (FIL-534/FIL-525), the UploadPartCopy group (FIL-586),
13-
// upstream error-code alignment (InvalidArgument where ingot returns a more
14-
// specific code), and conditional writes on Complete.
12+
// /ACL on create (FIL-534/FIL-525), and the UploadPartCopy group (FIL-586).
1513

1614
var createMultipartPass = []forgeCase{
1715
{name: "non_existing_bucket", fn: integration.CreateMultipartUpload_non_existing_bucket},
@@ -106,6 +104,7 @@ var listMultipartUploadsPass = []forgeCase{
106104
{name: "max_uploads", fn: integration.ListMultipartUploads_max_uploads},
107105
{name: "exceeding_max_uploads", fn: integration.ListMultipartUploads_exceeding_max_uploads},
108106
{name: "ignore_upload_id_marker", fn: integration.ListMultipartUploads_ignore_upload_id_marker},
107+
{name: "invalid_uploadId_marker", fn: integration.ListMultipartUploads_invalid_uploadId_marker},
109108
{name: "keyMarker_not_from_list", fn: integration.ListMultipartUploads_keyMarker_not_from_list},
110109
{name: "delimiter_truncated", fn: integration.ListMultipartUploads_delimiter_truncated},
111110
{name: "prefix", fn: integration.ListMultipartUploads_prefix},
@@ -114,11 +113,7 @@ var listMultipartUploadsPass = []forgeCase{
114113
{name: "with_checksums", fn: integration.ListMultipartUploads_with_checksums},
115114
}
116115

117-
var listMultipartUploadsXFail = []forgeCase{
118-
// Upstream expects InvalidArgument for a malformed upload-id-marker;
119-
// ingot returns InvalidRequest.
120-
{name: "invalid_uploadId_marker", fn: integration.ListMultipartUploads_invalid_uploadId_marker},
121-
}
116+
var listMultipartUploadsXFail = []forgeCase{}
122117

123118
var abortMultipartPass = []forgeCase{
124119
{name: "non_existing_bucket", fn: integration.AbortMultipartUpload_non_existing_bucket},
@@ -135,6 +130,8 @@ var completeMultipartPass = []forgeCase{
135130
// upstream function name carries a typo (CompletedMultipartUpload_...).
136131
{name: "non_existing_bucket", fn: integration.CompletedMultipartUpload_non_existing_bucket},
137132
{name: "incorrect_part_number", fn: integration.CompleteMultipartUpload_incorrect_part_number},
133+
{name: "missing_part_fields", fn: integration.CompleteMultipartUpload_missing_part_fields},
134+
{name: "invalid_part_number", fn: integration.CompleteMultipartUpload_invalid_part_number},
138135
{name: "default_content_type", fn: integration.CompleteMultipartUpload_default_content_type},
139136
{name: "invalid_ETag", fn: integration.CompleteMultipartUpload_invalid_ETag},
140137
{name: "small_upload_size", fn: integration.CompleteMultipartUpload_small_upload_size},
@@ -160,14 +157,13 @@ var completeMultipartPass = []forgeCase{
160157
// Part-level / composite-checksum verification is FIL-620;
161158
// racey_data_integrity additionally leans on atomic concurrent overwrites.
162159
var completeMultipartXFail = []forgeCase{
163-
// A part entry missing its ETag is accepted (200 with a result body)
164-
// where upstream expects an Error response.
165-
{name: "missing_part_fields", fn: integration.CompleteMultipartUpload_missing_part_fields},
166-
// Upstream expects InvalidArgument; ingot returns InvalidPartNumber.
167-
{name: "invalid_part_number", fn: integration.CompleteMultipartUpload_invalid_part_number},
168-
// Conditional headers on Complete are unenforced; the object written
169-
// past the precondition then fails the case's bucket teardown (409
170-
// BucketNotEmpty).
160+
// The conditional matrix itself passes; the case then fails its bucket
161+
// teardown: upstream's teardown never aborts in-flight uploads, so
162+
// DeleteBucket implicitly aborts them (s3frontend/bucket.go), but the
163+
// /blob/abort goes out proofless — hilt's per-operation proof grants
164+
// carry blob.Abort for the S3 Abort operation only, not for
165+
// DeleteBucket/UploadPart. Promote once hilt's s3perm map grants
166+
// blob.Abort on bucket delete.
171167
{name: "conditional_writes", fn: integration.CompleteMultipartUpload_conditional_writes},
172168
{name: "invalid_checksum_part", fn: integration.CompleteMultipartUpload_invalid_checksum_part},
173169
{name: "multiple_checksum_part", fn: integration.CompleteMultipartUpload_multiple_checksum_part},

s3frontend/bucket.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,27 @@ func (b *Backend) DeleteBucket(ctx context.Context, name string) error {
222222
}
223223
}
224224

225+
// In-flight multipart uploads do not block deletion (the upstream
226+
// conformance contract's teardown deletes buckets without aborting
227+
// them): abort any open sessions, releasing their parked part blobs
228+
// from the space, before asking hilt to delete the space — which
229+
// refuses while the space still holds blob registrations.
230+
sessions, err := b.multipart.ListSessions(ctx, name)
231+
if err != nil {
232+
return fmt.Errorf("s3frontend: delete bucket: list mp sessions: %w", err)
233+
}
234+
aborted := 0
235+
for _, s := range sessions {
236+
if s.State == registry.SessionOpen {
237+
b.abortOpenSession(ctx, st.Space, s)
238+
aborted++
239+
}
240+
}
241+
if aborted > 0 {
242+
b.logger.Info("delete bucket: aborted in-flight multipart sessions",
243+
zap.String("bucket", name), zap.Int("aborted", aborted), zap.Int("total", len(sessions)))
244+
}
245+
225246
req, ok := reqscope.Request(ctx)
226247
if !ok {
227248
return errors.New("s3frontend: delete bucket: no request in context")

s3frontend/multipart.go

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import (
1717
"github.com/fil-forge/versitygw/backend"
1818
"github.com/fil-forge/versitygw/s3err"
1919
"github.com/fil-forge/versitygw/s3response"
20+
"github.com/google/uuid"
2021
"github.com/ipfs/go-cid"
2122
mh "github.com/multiformats/go-multihash"
23+
"go.uber.org/zap"
2224

2325
msbucket "github.com/fil-forge/ingot/bucket"
2426
"github.com/fil-forge/ingot/bucketop"
@@ -267,13 +269,17 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
267269
etagHasher := md5.New()
268270
prev := 0
269271
for _, rp := range input.MultipartUpload.Parts {
270-
if rp.PartNumber == nil {
271-
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrInvalidPart)
272+
// A part entry missing either field is malformed XML; a part number
273+
// below 1 is an InvalidArgument (both per the upstream posix
274+
// backend). Out-of-range numbers fall through to the membership
275+
// check (no stored part can match) and report InvalidPart.
276+
if rp.PartNumber == nil || rp.ETag == nil {
277+
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrMalformedXML)
272278
}
273279
num := int(*rp.PartNumber)
274-
if num < 1 || num > 10000 {
280+
if num < 1 {
275281
return s3response.CompleteMultipartUploadResult{}, "",
276-
s3err.GetAPIError(s3err.ErrInvalidPartNumberRange)
282+
s3err.GetInvalidArgumentErr(s3err.InvalidArgCompleteMpPartNumber, strconv.Itoa(num))
277283
}
278284
if num <= prev {
279285
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrInvalidPartOrder)
@@ -283,7 +289,7 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
283289
if !ok {
284290
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrInvalidPart)
285291
}
286-
if rp.ETag != nil && !etagsEqual(*rp.ETag, hex.EncodeToString(sp.ETagMD5)) {
292+
if !etagsEqual(*rp.ETag, hex.EncodeToString(sp.ETagMD5)) {
287293
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrInvalidPart)
288294
}
289295
requested = append(requested, sp)
@@ -438,6 +444,27 @@ func (b *Backend) AbortMultipartUpload(ctx context.Context, input *s3.AbortMulti
438444
return nil
439445
}
440446

447+
// abortOpenSession force-aborts an open multipart session exactly like a
448+
// client Abort: latch (losing gracefully to a concurrent Complete/Abort),
449+
// drop the session, release its parts' now-unreferenced blobs. Used by
450+
// DeleteBucket's implicit abort of in-flight uploads.
451+
func (b *Backend) abortOpenSession(ctx context.Context, space did.DID, sess registry.MultipartSession) {
452+
won, err := b.multipart.LatchSession(ctx, sess.UploadID, registry.SessionOpen, registry.SessionAborting)
453+
if err != nil || !won {
454+
return
455+
}
456+
var digests [][]byte
457+
if parts, err := b.multipart.ListParts(ctx, sess.UploadID); err == nil {
458+
for _, p := range parts {
459+
digests = append(digests, p.BlobDigests...)
460+
}
461+
}
462+
if err := b.multipart.DeleteSession(ctx, sess.UploadID); err != nil {
463+
return
464+
}
465+
b.cleanupPartBlobs(ctx, space, sess.UploadID, digests)
466+
}
467+
441468
// cleanupPartBlobs removes spooled blobs that belonged to aborted, expired, or
442469
// superseded parts of uploadID — unless the blob is still referenced: by a
443470
// part of another in-flight session (content-addressed dedup), by a part still
@@ -490,7 +517,10 @@ func (b *Backend) cleanupPartBlobs(ctx context.Context, space did.DID, uploadID
490517
if state == registry.IntentParked {
491518
if park, err := b.parks.GetPark(ctx, d); err == nil {
492519
if cause, err := cid.Cast(park.AddTask); err == nil {
493-
_ = b.deferred.AbortBlob(ctx, space, mh.Multihash(d), cause)
520+
if aerr := b.deferred.AbortBlob(ctx, space, mh.Multihash(d), cause); aerr != nil {
521+
b.logger.Warn("abort parked blob failed; provider-side release deferred",
522+
zap.String("digest", hex.EncodeToString(d)), zap.Error(aerr))
523+
}
494524
}
495525
_ = b.parks.DeletePark(ctx, d)
496526
}
@@ -728,43 +758,37 @@ func (b *Backend) ListMultipartUploads(ctx context.Context, input *s3.ListMultip
728758
}
729759
}
730760

731-
// Marker positioning. An upload-id marker is meaningful only alongside a
732-
// key marker: when the key marker names a live key, the id must belong to
733-
// one of that key's uploads (else InvalidArgument); when it doesn't (the
734-
// marker key was completed/aborted meanwhile), the id positions within the
735-
// keys ordered after it.
761+
// Marker positioning, mirroring upstream's MultipartUploadLister: an
762+
// upload-id marker is meaningful only alongside a key marker; it must be
763+
// a valid UUID and must name an upload of the FIRST key group at or
764+
// after the key marker (else InvalidArgument), and the listing resumes
765+
// just past it.
736766
start := 0
737767
if keyMarker != "" {
738768
if uploadIDMarker != "" {
739-
keyLive := false
740-
pos := -1
741-
for i, s := range inflight {
742-
if s.ObjectKey == keyMarker {
743-
keyLive = true
744-
if s.UploadID == uploadIDMarker {
745-
pos = i
746-
}
747-
}
748-
}
749-
if keyLive && pos < 0 {
769+
if _, err := uuid.Parse(uploadIDMarker); err != nil {
750770
return s3response.ListMultipartUploadsResult{},
751-
s3err.GetAPIError(s3err.ErrInvalidRequest)
771+
s3err.GetInvalidArgumentErr(s3err.InvalidArgUploadIdMarker, uploadIDMarker)
752772
}
753-
if pos < 0 {
754-
for i, s := range inflight {
755-
if s.ObjectKey >= keyMarker && s.UploadID == uploadIDMarker {
756-
pos = i
773+
i := 0
774+
for i < len(inflight) && inflight[i].ObjectKey < keyMarker {
775+
i++
776+
}
777+
pos := -1
778+
if i < len(inflight) {
779+
firstKey := inflight[i].ObjectKey
780+
for j := i; j < len(inflight) && inflight[j].ObjectKey == firstKey; j++ {
781+
if inflight[j].UploadID == uploadIDMarker {
782+
pos = j
757783
break
758784
}
759785
}
760786
}
761-
if pos >= 0 {
762-
start = pos + 1
763-
} else {
764-
for start < len(inflight) && inflight[start].ObjectKey <= keyMarker {
765-
start++
766-
}
787+
if pos < 0 {
788+
return s3response.ListMultipartUploadsResult{},
789+
s3err.GetInvalidArgumentErr(s3err.InvalidArgUploadIdMarker, uploadIDMarker)
767790
}
791+
start = pos + 1
768792
} else {
769793
for start < len(inflight) && inflight[start].ObjectKey <= keyMarker {
770794
start++

0 commit comments

Comments
 (0)