@specific.dev/spectest 0.131.8 → 0.131.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,12 @@ one backend snapshot, including closed byte ranges, exact ETag conditions,
27
27
  version selection and SHA256 part receipts. Date-based copy conditions are
28
28
  explicitly unsupported (501), rather than silently ignored.
29
29
 
30
+ Multipart completion stores its opaque ETag on the same in-memory object version
31
+ as the bytes. HEAD/GET, listings, copy conditions and atomic write conditions use
32
+ that tag; ordinary PUT and CopyObject retain their own payload-derived tags.
33
+ Versioned HEAD reads the requested version. The optional backend write port keeps
34
+ completion tags out of client metadata and avoids a separate post-write update.
35
+
30
36
  Run `./test.sh` from this directory to exercise real HTTP requests against the
31
37
  same patched sources used in the image, including corrupt retries, conditional
32
38
  completion races, abort, metadata reconciliation and unchecksummed uploads.
@@ -147,39 +147,54 @@ func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
147
147
  }
148
148
 
149
149
  func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
150
- p := newProbe(t)
151
- paths := []string{p.start("same", true), p.start("same", true)}
152
- parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
153
- statuses := make(chan int, 2)
154
- var wg sync.WaitGroup
155
- for i := range paths {
156
- wg.Add(1)
157
- go func(i int) {
158
- defer wg.Done()
159
- req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
160
- req.Header.Set("If-None-Match", "*")
161
- res, err := p.server.Client().Do(req)
162
- if err != nil {
163
- statuses <- 0
164
- return
150
+ for _, replace := range []bool{false, true} {
151
+ t.Run(fmt.Sprint(replace), func(t *testing.T) {
152
+ p := newProbe(t)
153
+ header, tag := "If-None-Match", "*"
154
+ if replace {
155
+ seed := p.start("same", true)
156
+ part := p.part(seed, 1, []byte("original"), true)
157
+ response := p.request("POST", seed, completion(part), nil, 200)
158
+ var receipt struct{ ETag string }
159
+ if err := xml.Unmarshal(response.body, &receipt); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ header, tag = "If-Match", receipt.ETag
165
163
  }
166
- defer res.Body.Close()
167
- io.Copy(io.Discard, res.Body)
168
- statuses <- res.StatusCode
169
- }(i)
170
- }
171
- wg.Wait()
172
- close(statuses)
173
- counts := map[int]int{}
174
- for s := range statuses {
175
- counts[s]++
176
- }
177
- if counts[200] != 1 || counts[412] != 1 {
178
- t.Fatalf("competing creates: %v", counts)
179
- }
180
- body := p.request("GET", "/uploads/same", nil, nil, 200).body
181
- if string(body) != "first" && string(body) != "second" {
182
- t.Fatalf("bad winner: %s", body)
164
+ paths := []string{p.start("same", true), p.start("same", true)}
165
+ parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
166
+ statuses := make(chan int, 2)
167
+ var wg sync.WaitGroup
168
+ for i := range paths {
169
+ wg.Add(1)
170
+ go func(i int) {
171
+ defer wg.Done()
172
+ req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
173
+ req.Header.Set(header, tag)
174
+ res, err := p.server.Client().Do(req)
175
+ if err != nil {
176
+ statuses <- 0
177
+ return
178
+ }
179
+ defer res.Body.Close()
180
+ io.Copy(io.Discard, res.Body)
181
+ statuses <- res.StatusCode
182
+ }(i)
183
+ }
184
+ wg.Wait()
185
+ close(statuses)
186
+ counts := map[int]int{}
187
+ for s := range statuses {
188
+ counts[s]++
189
+ }
190
+ if counts[200] != 1 || counts[412] != 1 {
191
+ t.Fatalf("competing creates: %v", counts)
192
+ }
193
+ body := p.request("GET", "/uploads/same", nil, nil, 200).body
194
+ if string(body) != "first" && string(body) != "second" {
195
+ t.Fatalf("bad winner: %s", body)
196
+ }
197
+ })
183
198
  }
184
199
  }
185
200
 
@@ -280,3 +295,78 @@ func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
280
295
  p.request("PUT", path+"&partNumber=1", nil, headers, 404)
281
296
  p.request("DELETE", path, nil, nil, 204)
282
297
  }
298
+
299
+ func TestMultipartSourceEntityTagSurvivesCopyVersionsAndOverwrite(t *testing.T) {
300
+ for _, count := range []int{1, 2} {
301
+ t.Run(fmt.Sprint(count), func(t *testing.T) {
302
+ p := newProbe(t)
303
+ p.request("PUT", "/uploads?versioning", []byte("<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"), nil, 200)
304
+ body := bytes.Repeat([]byte("a"), 6*1024*1024)
305
+ path := p.start("staged", false)
306
+ parts := []string{}
307
+ if count == 1 {
308
+ parts = append(parts, p.part(path, 1, body, false))
309
+ } else {
310
+ parts = append(parts, p.part(path, 1, body[:5*1024*1024], false), p.part(path, 2, body[5*1024*1024:], false))
311
+ }
312
+ complete := p.request("POST", path, completion(parts...), nil, 200)
313
+ var receipt struct{ ETag string }
314
+ if err := xml.Unmarshal(complete.body, &receipt); err != nil || !strings.Contains(receipt.ETag, fmt.Sprintf("-%d", count)) {
315
+ t.Fatalf("completion tag: %s: %v", complete.body, err)
316
+ }
317
+ version := complete.header.Get("x-amz-version-id")
318
+ if version == "" {
319
+ t.Fatal("missing completed version")
320
+ }
321
+ destination := p.start("copied-staging", false)
322
+ headers := map[string]string{"x-amz-copy-source": "/uploads/staged", "x-amz-copy-source-if-match": receipt.ETag, "x-amz-copy-source-range": fmt.Sprintf("bytes=0-%d", len(body)-1)}
323
+ copied := p.request("PUT", destination+"&partNumber=1", nil, headers, 200)
324
+ var part struct{ ETag string }
325
+ if err := xml.Unmarshal(copied.body, &part); err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ for _, method := range []string{"HEAD", "GET"} {
329
+ got := p.request(method, "/uploads/staged", nil, nil, 200)
330
+ if got.header.Get("ETag") != receipt.ETag {
331
+ t.Fatalf("%s lost completion tag: %s", method, got.header.Get("ETag"))
332
+ }
333
+ p.request(method, "/uploads/staged", nil, map[string]string{"If-None-Match": receipt.ETag}, 304)
334
+ }
335
+ for _, query := range []string{"?list-type=2", "?versions"} {
336
+ got := p.request("GET", "/uploads"+query, nil, nil, 200)
337
+ var list struct {
338
+ Contents []struct{ ETag string }
339
+ Version []struct{ ETag string }
340
+ }
341
+ if err := xml.Unmarshal(got.body, &list); err != nil {
342
+ t.Fatal(err)
343
+ }
344
+ tags := []string{}
345
+ for _, row := range list.Contents {
346
+ tags = append(tags, row.ETag)
347
+ }
348
+ for _, row := range list.Version {
349
+ tags = append(tags, row.ETag)
350
+ }
351
+ if len(tags) != 1 || tags[0] != receipt.ETag {
352
+ t.Fatalf("list lost tag: %v", tags)
353
+ }
354
+ }
355
+ put := p.request("PUT", "/uploads/staged", []byte("replacement"), map[string]string{"If-Match": receipt.ETag}, 200)
356
+ if put.header.Get("ETag") == receipt.ETag {
357
+ t.Fatal("normal overwrite inherited multipart tag")
358
+ }
359
+ p.request("PUT", destination+"&partNumber=1", nil, headers, 412)
360
+ headers["x-amz-copy-source"] += "?versionId=" + version
361
+ p.request("PUT", destination+"&partNumber=1", nil, headers, 200)
362
+ head := p.request("HEAD", "/uploads/staged?versionId="+version, nil, nil, 200)
363
+ if head.header.Get("ETag") != receipt.ETag {
364
+ t.Fatal("version lost its tag")
365
+ }
366
+ p.request("POST", destination, completion(fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)), nil, 200)
367
+ if got := p.request("GET", "/uploads/copied-staging", nil, nil, 200); !bytes.Equal(got.body, body) {
368
+ t.Fatal("copied multipart staging bytes changed")
369
+ }
370
+ })
371
+ }
372
+ }
@@ -350,3 +350,199 @@
350
350
  + ChecksumSHA256 string `xml:"ChecksumSHA256"`
351
351
  + }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
352
352
  +}
353
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend.go
354
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend.go
355
+ @@ -23,7 +23,9 @@
356
+ Size int64
357
+ Contents io.ReadCloser
358
+ Hash []byte
359
+ - Range *ObjectRange
360
+ + // EntityTag is the stored opaque tag; Hash remains the payload MD5.
361
+ + EntityTag string
362
+ + Range *ObjectRange
363
+
364
+ // VersionID will be empty if bucket versioning has not been enabled.
365
+ VersionID VersionID
366
+ @@ -31,6 +33,21 @@
367
+ // If versioning is enabled for the bucket, this is true if this object version
368
+ // is a delete marker.
369
+ IsDeleteMarker bool
370
+ +}
371
+ +
372
+ +// ETag returns the version-owned tag, retaining compatibility with backends
373
+ +// that only supply payload hashes.
374
+ +func (object *Object) ETag() string {
375
+ + if object.EntityTag != "" {
376
+ + return object.EntityTag
377
+ + }
378
+ + return FormatETag(object.Hash)
379
+ +}
380
+ +
381
+ +// MultipartObjectBackend atomically stores completion bytes, metadata and ETag
382
+ +// under the same conditional-write lock. The tag never enters client metadata.
383
+ +type MultipartObjectBackend interface {
384
+ + PutMultipartObject(bucket, key string, metadata map[string]string, input io.Reader, size int64, tag string, conditions *PutConditions) (PutObjectResult, error)
385
+ }
386
+
387
+ type ObjectList struct {
388
+ @@ -150,7 +167,8 @@
389
+
390
+ // Hash is the MD5 hash of the object content (used for ETag comparison)
391
+ // Only required if Exists is true
392
+ - Hash []byte
393
+ + Hash []byte
394
+ + EntityTag string
395
+ }
396
+
397
+ // ObjectInfo is a deprecated alias for ConditionalObjectInfo.
398
+ @@ -182,7 +200,10 @@
399
+ if len(expectedETag) >= 2 && expectedETag[0] == '"' && expectedETag[len(expectedETag)-1] == '"' {
400
+ expectedETag = expectedETag[1 : len(expectedETag)-1]
401
+ }
402
+ - actualETag := hex.EncodeToString(objectInfo.Hash)
403
+ + actualETag := strings.Trim(objectInfo.EntityTag, "\"")
404
+ + if actualETag == "" {
405
+ + actualETag = hex.EncodeToString(objectInfo.Hash)
406
+ + }
407
+ if expectedETag != actualETag {
408
+ return ErrorMessage(ErrPreconditionFailed, "The ETag does not match")
409
+ }
410
+ --- a/vendor/github.com/johannesboyne/gofakes3/uploader.go
411
+ +++ b/vendor/github.com/johannesboyne/gofakes3/uploader.go
412
+ @@ -496,7 +496,12 @@
413
+ input.ChecksumSHA256 = ""
414
+ input.ChecksumType = ""
415
+ }
416
+ - result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
417
+ + var result PutObjectResult
418
+ + if backend, ok := u.storage.(MultipartObjectBackend); ok {
419
+ + result, err = backend.PutMultipartObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), etag, input.Conditions)
420
+ + } else {
421
+ + result, err = u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
422
+ + }
423
+ if err != nil {
424
+ return "", "", err
425
+ }
426
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
427
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
428
+ @@ -535,7 +535,7 @@
429
+ w.Header().Set("x-amz-version-id", string(obj.VersionID))
430
+ }
431
+
432
+ - etag := `"` + hex.EncodeToString(obj.Hash) + `"`
433
+ + etag := obj.ETag()
434
+ w.Header().Set("ETag", etag)
435
+
436
+ if r.Header.Get("If-None-Match") == etag {
437
+ @@ -576,7 +576,16 @@
438
+ return err
439
+ }
440
+
441
+ - obj, err := g.storage.HeadObject(bucket, object)
442
+ + var obj *Object
443
+ + var err error
444
+ + if versionID == "" {
445
+ + obj, err = g.storage.HeadObject(bucket, object)
446
+ + } else {
447
+ + if g.versioned == nil {
448
+ + return ErrNotImplemented
449
+ + }
450
+ + obj, err = g.versioned.HeadObjectVersion(bucket, object, versionID)
451
+ + }
452
+ if err != nil {
453
+ return err
454
+ }
455
+ --- a/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
456
+ +++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
457
+ @@ -56,7 +56,7 @@
458
+ if src.IsDeleteMarker {
459
+ return ErrInvalidArgument
460
+ }
461
+ - tag := FormatETag(src.Hash)
462
+ + tag := src.ETag()
463
+ if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
464
+ return ErrPreconditionFailed
465
+ }
466
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/backend.go
467
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/backend.go
468
+ @@ -119,7 +119,7 @@
469
+ response.Add(&gofakes3.Content{
470
+ Key: item.data.name,
471
+ LastModified: gofakes3.NewContentTime(item.data.lastModified),
472
+ - ETag: gofakes3.FormatETag(item.data.hash),
473
+ + ETag: item.data.entityTag,
474
+ Size: int64(len(item.data.body)),
475
+ })
476
+ }
477
+ @@ -240,6 +240,16 @@
478
+ size int64,
479
+ conditions *gofakes3.PutConditions,
480
+ ) (result gofakes3.PutObjectResult, err error) {
481
+ + return db.putObject(bucketName, objectName, meta, input, size, "", conditions)
482
+ +}
483
+ +
484
+ +// PutMultipartObject retains the completion tag in the same object version as
485
+ +// its bytes and uses the ordinary atomic conditional-write admission.
486
+ +func (db *Backend) PutMultipartObject(bucketName, objectName string, meta map[string]string, input io.Reader, size int64, tag string, conditions *gofakes3.PutConditions) (gofakes3.PutObjectResult, error) {
487
+ + return db.putObject(bucketName, objectName, meta, input, size, tag, conditions)
488
+ +}
489
+ +
490
+ +func (db *Backend) putObject(bucketName, objectName string, meta map[string]string, input io.Reader, size int64, tag string, conditions *gofakes3.PutConditions) (result gofakes3.PutObjectResult, err error) {
491
+ // No need to lock the backend while we read the data into memory; it holds
492
+ // the write lock open unnecessarily, and could be blocked for an unreasonably
493
+ // long time by a connection timing out:
494
+ @@ -272,11 +282,15 @@
495
+ }
496
+
497
+ hash := md5.Sum(bts)
498
+ + if tag == "" {
499
+ + tag = gofakes3.FormatETag(hash[:])
500
+ + }
501
+
502
+ item := &bucketData{
503
+ name: objectName,
504
+ body: bts,
505
+ hash: hash[:],
506
+ + entityTag: tag,
507
+ metadata: meta,
508
+ lastModified: db.timeSource.Now(),
509
+ }
510
+ @@ -557,7 +571,7 @@
511
+ IsLatest: version == object.data,
512
+ LastModified: gofakes3.NewContentTime(version.lastModified),
513
+ Size: int64(len(version.body)),
514
+ - ETag: gofakes3.FormatETag(version.hash),
515
+ + ETag: version.entityTag,
516
+ }
517
+ if bucket.versioning != gofakes3.VersioningNone { // S300005
518
+ resultVer.VersionID = version.versionID
519
+ @@ -587,8 +601,9 @@
520
+ return &gofakes3.ConditionalObjectInfo{Exists: false}, nil
521
+ }
522
+ return &gofakes3.ConditionalObjectInfo{
523
+ - Exists: true,
524
+ - Hash: existing.data.hash,
525
+ + Exists: true,
526
+ + Hash: existing.data.hash,
527
+ + EntityTag: existing.data.entityTag,
528
+ }, nil
529
+ }
530
+
531
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/bucket.go
532
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/bucket.go
533
+ @@ -118,6 +118,7 @@
534
+ deleteMarker bool
535
+ body []byte
536
+ hash []byte
537
+ + entityTag string
538
+ metadata map[string]string
539
+ }
540
+
541
+ @@ -150,6 +151,7 @@
542
+ return &gofakes3.Object{
543
+ Name: bi.name,
544
+ Hash: bi.hash,
545
+ + EntityTag: bi.entityTag,
546
+ Metadata: bi.metadata,
547
+ Size: sz,
548
+ Range: rnge,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.8",
3
+ "version": "0.131.9",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,6 +27,12 @@ one backend snapshot, including closed byte ranges, exact ETag conditions,
27
27
  version selection and SHA256 part receipts. Date-based copy conditions are
28
28
  explicitly unsupported (501), rather than silently ignored.
29
29
 
30
+ Multipart completion stores its opaque ETag on the same in-memory object version
31
+ as the bytes. HEAD/GET, listings, copy conditions and atomic write conditions use
32
+ that tag; ordinary PUT and CopyObject retain their own payload-derived tags.
33
+ Versioned HEAD reads the requested version. The optional backend write port keeps
34
+ completion tags out of client metadata and avoids a separate post-write update.
35
+
30
36
  Run `./test.sh` from this directory to exercise real HTTP requests against the
31
37
  same patched sources used in the image, including corrupt retries, conditional
32
38
  completion races, abort, metadata reconciliation and unchecksummed uploads.
@@ -147,39 +147,54 @@ func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
147
147
  }
148
148
 
149
149
  func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
150
- p := newProbe(t)
151
- paths := []string{p.start("same", true), p.start("same", true)}
152
- parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
153
- statuses := make(chan int, 2)
154
- var wg sync.WaitGroup
155
- for i := range paths {
156
- wg.Add(1)
157
- go func(i int) {
158
- defer wg.Done()
159
- req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
160
- req.Header.Set("If-None-Match", "*")
161
- res, err := p.server.Client().Do(req)
162
- if err != nil {
163
- statuses <- 0
164
- return
150
+ for _, replace := range []bool{false, true} {
151
+ t.Run(fmt.Sprint(replace), func(t *testing.T) {
152
+ p := newProbe(t)
153
+ header, tag := "If-None-Match", "*"
154
+ if replace {
155
+ seed := p.start("same", true)
156
+ part := p.part(seed, 1, []byte("original"), true)
157
+ response := p.request("POST", seed, completion(part), nil, 200)
158
+ var receipt struct{ ETag string }
159
+ if err := xml.Unmarshal(response.body, &receipt); err != nil {
160
+ t.Fatal(err)
161
+ }
162
+ header, tag = "If-Match", receipt.ETag
165
163
  }
166
- defer res.Body.Close()
167
- io.Copy(io.Discard, res.Body)
168
- statuses <- res.StatusCode
169
- }(i)
170
- }
171
- wg.Wait()
172
- close(statuses)
173
- counts := map[int]int{}
174
- for s := range statuses {
175
- counts[s]++
176
- }
177
- if counts[200] != 1 || counts[412] != 1 {
178
- t.Fatalf("competing creates: %v", counts)
179
- }
180
- body := p.request("GET", "/uploads/same", nil, nil, 200).body
181
- if string(body) != "first" && string(body) != "second" {
182
- t.Fatalf("bad winner: %s", body)
164
+ paths := []string{p.start("same", true), p.start("same", true)}
165
+ parts := []string{p.part(paths[0], 1, []byte("first"), true), p.part(paths[1], 1, []byte("second"), true)}
166
+ statuses := make(chan int, 2)
167
+ var wg sync.WaitGroup
168
+ for i := range paths {
169
+ wg.Add(1)
170
+ go func(i int) {
171
+ defer wg.Done()
172
+ req, _ := http.NewRequest("POST", p.server.URL+paths[i], bytes.NewReader(completion(parts[i])))
173
+ req.Header.Set(header, tag)
174
+ res, err := p.server.Client().Do(req)
175
+ if err != nil {
176
+ statuses <- 0
177
+ return
178
+ }
179
+ defer res.Body.Close()
180
+ io.Copy(io.Discard, res.Body)
181
+ statuses <- res.StatusCode
182
+ }(i)
183
+ }
184
+ wg.Wait()
185
+ close(statuses)
186
+ counts := map[int]int{}
187
+ for s := range statuses {
188
+ counts[s]++
189
+ }
190
+ if counts[200] != 1 || counts[412] != 1 {
191
+ t.Fatalf("competing creates: %v", counts)
192
+ }
193
+ body := p.request("GET", "/uploads/same", nil, nil, 200).body
194
+ if string(body) != "first" && string(body) != "second" {
195
+ t.Fatalf("bad winner: %s", body)
196
+ }
197
+ })
183
198
  }
184
199
  }
185
200
 
@@ -280,3 +295,78 @@ func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
280
295
  p.request("PUT", path+"&partNumber=1", nil, headers, 404)
281
296
  p.request("DELETE", path, nil, nil, 204)
282
297
  }
298
+
299
+ func TestMultipartSourceEntityTagSurvivesCopyVersionsAndOverwrite(t *testing.T) {
300
+ for _, count := range []int{1, 2} {
301
+ t.Run(fmt.Sprint(count), func(t *testing.T) {
302
+ p := newProbe(t)
303
+ p.request("PUT", "/uploads?versioning", []byte("<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"), nil, 200)
304
+ body := bytes.Repeat([]byte("a"), 6*1024*1024)
305
+ path := p.start("staged", false)
306
+ parts := []string{}
307
+ if count == 1 {
308
+ parts = append(parts, p.part(path, 1, body, false))
309
+ } else {
310
+ parts = append(parts, p.part(path, 1, body[:5*1024*1024], false), p.part(path, 2, body[5*1024*1024:], false))
311
+ }
312
+ complete := p.request("POST", path, completion(parts...), nil, 200)
313
+ var receipt struct{ ETag string }
314
+ if err := xml.Unmarshal(complete.body, &receipt); err != nil || !strings.Contains(receipt.ETag, fmt.Sprintf("-%d", count)) {
315
+ t.Fatalf("completion tag: %s: %v", complete.body, err)
316
+ }
317
+ version := complete.header.Get("x-amz-version-id")
318
+ if version == "" {
319
+ t.Fatal("missing completed version")
320
+ }
321
+ destination := p.start("copied-staging", false)
322
+ headers := map[string]string{"x-amz-copy-source": "/uploads/staged", "x-amz-copy-source-if-match": receipt.ETag, "x-amz-copy-source-range": fmt.Sprintf("bytes=0-%d", len(body)-1)}
323
+ copied := p.request("PUT", destination+"&partNumber=1", nil, headers, 200)
324
+ var part struct{ ETag string }
325
+ if err := xml.Unmarshal(copied.body, &part); err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ for _, method := range []string{"HEAD", "GET"} {
329
+ got := p.request(method, "/uploads/staged", nil, nil, 200)
330
+ if got.header.Get("ETag") != receipt.ETag {
331
+ t.Fatalf("%s lost completion tag: %s", method, got.header.Get("ETag"))
332
+ }
333
+ p.request(method, "/uploads/staged", nil, map[string]string{"If-None-Match": receipt.ETag}, 304)
334
+ }
335
+ for _, query := range []string{"?list-type=2", "?versions"} {
336
+ got := p.request("GET", "/uploads"+query, nil, nil, 200)
337
+ var list struct {
338
+ Contents []struct{ ETag string }
339
+ Version []struct{ ETag string }
340
+ }
341
+ if err := xml.Unmarshal(got.body, &list); err != nil {
342
+ t.Fatal(err)
343
+ }
344
+ tags := []string{}
345
+ for _, row := range list.Contents {
346
+ tags = append(tags, row.ETag)
347
+ }
348
+ for _, row := range list.Version {
349
+ tags = append(tags, row.ETag)
350
+ }
351
+ if len(tags) != 1 || tags[0] != receipt.ETag {
352
+ t.Fatalf("list lost tag: %v", tags)
353
+ }
354
+ }
355
+ put := p.request("PUT", "/uploads/staged", []byte("replacement"), map[string]string{"If-Match": receipt.ETag}, 200)
356
+ if put.header.Get("ETag") == receipt.ETag {
357
+ t.Fatal("normal overwrite inherited multipart tag")
358
+ }
359
+ p.request("PUT", destination+"&partNumber=1", nil, headers, 412)
360
+ headers["x-amz-copy-source"] += "?versionId=" + version
361
+ p.request("PUT", destination+"&partNumber=1", nil, headers, 200)
362
+ head := p.request("HEAD", "/uploads/staged?versionId="+version, nil, nil, 200)
363
+ if head.header.Get("ETag") != receipt.ETag {
364
+ t.Fatal("version lost its tag")
365
+ }
366
+ p.request("POST", destination, completion(fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)), nil, 200)
367
+ if got := p.request("GET", "/uploads/copied-staging", nil, nil, 200); !bytes.Equal(got.body, body) {
368
+ t.Fatal("copied multipart staging bytes changed")
369
+ }
370
+ })
371
+ }
372
+ }
@@ -350,3 +350,199 @@
350
350
  + ChecksumSHA256 string `xml:"ChecksumSHA256"`
351
351
  + }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
352
352
  +}
353
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend.go
354
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend.go
355
+ @@ -23,7 +23,9 @@
356
+ Size int64
357
+ Contents io.ReadCloser
358
+ Hash []byte
359
+ - Range *ObjectRange
360
+ + // EntityTag is the stored opaque tag; Hash remains the payload MD5.
361
+ + EntityTag string
362
+ + Range *ObjectRange
363
+
364
+ // VersionID will be empty if bucket versioning has not been enabled.
365
+ VersionID VersionID
366
+ @@ -31,6 +33,21 @@
367
+ // If versioning is enabled for the bucket, this is true if this object version
368
+ // is a delete marker.
369
+ IsDeleteMarker bool
370
+ +}
371
+ +
372
+ +// ETag returns the version-owned tag, retaining compatibility with backends
373
+ +// that only supply payload hashes.
374
+ +func (object *Object) ETag() string {
375
+ + if object.EntityTag != "" {
376
+ + return object.EntityTag
377
+ + }
378
+ + return FormatETag(object.Hash)
379
+ +}
380
+ +
381
+ +// MultipartObjectBackend atomically stores completion bytes, metadata and ETag
382
+ +// under the same conditional-write lock. The tag never enters client metadata.
383
+ +type MultipartObjectBackend interface {
384
+ + PutMultipartObject(bucket, key string, metadata map[string]string, input io.Reader, size int64, tag string, conditions *PutConditions) (PutObjectResult, error)
385
+ }
386
+
387
+ type ObjectList struct {
388
+ @@ -150,7 +167,8 @@
389
+
390
+ // Hash is the MD5 hash of the object content (used for ETag comparison)
391
+ // Only required if Exists is true
392
+ - Hash []byte
393
+ + Hash []byte
394
+ + EntityTag string
395
+ }
396
+
397
+ // ObjectInfo is a deprecated alias for ConditionalObjectInfo.
398
+ @@ -182,7 +200,10 @@
399
+ if len(expectedETag) >= 2 && expectedETag[0] == '"' && expectedETag[len(expectedETag)-1] == '"' {
400
+ expectedETag = expectedETag[1 : len(expectedETag)-1]
401
+ }
402
+ - actualETag := hex.EncodeToString(objectInfo.Hash)
403
+ + actualETag := strings.Trim(objectInfo.EntityTag, "\"")
404
+ + if actualETag == "" {
405
+ + actualETag = hex.EncodeToString(objectInfo.Hash)
406
+ + }
407
+ if expectedETag != actualETag {
408
+ return ErrorMessage(ErrPreconditionFailed, "The ETag does not match")
409
+ }
410
+ --- a/vendor/github.com/johannesboyne/gofakes3/uploader.go
411
+ +++ b/vendor/github.com/johannesboyne/gofakes3/uploader.go
412
+ @@ -496,7 +496,12 @@
413
+ input.ChecksumSHA256 = ""
414
+ input.ChecksumType = ""
415
+ }
416
+ - result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
417
+ + var result PutObjectResult
418
+ + if backend, ok := u.storage.(MultipartObjectBackend); ok {
419
+ + result, err = backend.PutMultipartObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), etag, input.Conditions)
420
+ + } else {
421
+ + result, err = u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
422
+ + }
423
+ if err != nil {
424
+ return "", "", err
425
+ }
426
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
427
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
428
+ @@ -535,7 +535,7 @@
429
+ w.Header().Set("x-amz-version-id", string(obj.VersionID))
430
+ }
431
+
432
+ - etag := `"` + hex.EncodeToString(obj.Hash) + `"`
433
+ + etag := obj.ETag()
434
+ w.Header().Set("ETag", etag)
435
+
436
+ if r.Header.Get("If-None-Match") == etag {
437
+ @@ -576,7 +576,16 @@
438
+ return err
439
+ }
440
+
441
+ - obj, err := g.storage.HeadObject(bucket, object)
442
+ + var obj *Object
443
+ + var err error
444
+ + if versionID == "" {
445
+ + obj, err = g.storage.HeadObject(bucket, object)
446
+ + } else {
447
+ + if g.versioned == nil {
448
+ + return ErrNotImplemented
449
+ + }
450
+ + obj, err = g.versioned.HeadObjectVersion(bucket, object, versionID)
451
+ + }
452
+ if err != nil {
453
+ return err
454
+ }
455
+ --- a/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
456
+ +++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
457
+ @@ -56,7 +56,7 @@
458
+ if src.IsDeleteMarker {
459
+ return ErrInvalidArgument
460
+ }
461
+ - tag := FormatETag(src.Hash)
462
+ + tag := src.ETag()
463
+ if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
464
+ return ErrPreconditionFailed
465
+ }
466
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/backend.go
467
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/backend.go
468
+ @@ -119,7 +119,7 @@
469
+ response.Add(&gofakes3.Content{
470
+ Key: item.data.name,
471
+ LastModified: gofakes3.NewContentTime(item.data.lastModified),
472
+ - ETag: gofakes3.FormatETag(item.data.hash),
473
+ + ETag: item.data.entityTag,
474
+ Size: int64(len(item.data.body)),
475
+ })
476
+ }
477
+ @@ -240,6 +240,16 @@
478
+ size int64,
479
+ conditions *gofakes3.PutConditions,
480
+ ) (result gofakes3.PutObjectResult, err error) {
481
+ + return db.putObject(bucketName, objectName, meta, input, size, "", conditions)
482
+ +}
483
+ +
484
+ +// PutMultipartObject retains the completion tag in the same object version as
485
+ +// its bytes and uses the ordinary atomic conditional-write admission.
486
+ +func (db *Backend) PutMultipartObject(bucketName, objectName string, meta map[string]string, input io.Reader, size int64, tag string, conditions *gofakes3.PutConditions) (gofakes3.PutObjectResult, error) {
487
+ + return db.putObject(bucketName, objectName, meta, input, size, tag, conditions)
488
+ +}
489
+ +
490
+ +func (db *Backend) putObject(bucketName, objectName string, meta map[string]string, input io.Reader, size int64, tag string, conditions *gofakes3.PutConditions) (result gofakes3.PutObjectResult, err error) {
491
+ // No need to lock the backend while we read the data into memory; it holds
492
+ // the write lock open unnecessarily, and could be blocked for an unreasonably
493
+ // long time by a connection timing out:
494
+ @@ -272,11 +282,15 @@
495
+ }
496
+
497
+ hash := md5.Sum(bts)
498
+ + if tag == "" {
499
+ + tag = gofakes3.FormatETag(hash[:])
500
+ + }
501
+
502
+ item := &bucketData{
503
+ name: objectName,
504
+ body: bts,
505
+ hash: hash[:],
506
+ + entityTag: tag,
507
+ metadata: meta,
508
+ lastModified: db.timeSource.Now(),
509
+ }
510
+ @@ -557,7 +571,7 @@
511
+ IsLatest: version == object.data,
512
+ LastModified: gofakes3.NewContentTime(version.lastModified),
513
+ Size: int64(len(version.body)),
514
+ - ETag: gofakes3.FormatETag(version.hash),
515
+ + ETag: version.entityTag,
516
+ }
517
+ if bucket.versioning != gofakes3.VersioningNone { // S300005
518
+ resultVer.VersionID = version.versionID
519
+ @@ -587,8 +601,9 @@
520
+ return &gofakes3.ConditionalObjectInfo{Exists: false}, nil
521
+ }
522
+ return &gofakes3.ConditionalObjectInfo{
523
+ - Exists: true,
524
+ - Hash: existing.data.hash,
525
+ + Exists: true,
526
+ + Hash: existing.data.hash,
527
+ + EntityTag: existing.data.entityTag,
528
+ }, nil
529
+ }
530
+
531
+ --- a/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/bucket.go
532
+ +++ b/vendor/github.com/johannesboyne/gofakes3/backend/s3mem/bucket.go
533
+ @@ -118,6 +118,7 @@
534
+ deleteMarker bool
535
+ body []byte
536
+ hash []byte
537
+ + entityTag string
538
+ metadata map[string]string
539
+ }
540
+
541
+ @@ -150,6 +151,7 @@
542
+ return &gofakes3.Object{
543
+ Name: bi.name,
544
+ Hash: bi.hash,
545
+ + EntityTag: bi.entityTag,
546
+ Metadata: bi.metadata,
547
+ Size: sz,
548
+ Range: rnge,