@specific.dev/spectest 0.131.7 → 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.
- package/dist/components/s3-runtime/README.md +10 -1
- package/dist/components/s3-runtime/main_test.go +205 -32
- package/dist/components/s3-runtime/multipart.patch +307 -0
- package/package.json +1 -1
- package/src/components/s3-runtime/README.md +10 -1
- package/src/components/s3-runtime/main_test.go +205 -32
- package/src/components/s3-runtime/multipart.patch +307 -0
|
@@ -22,7 +22,16 @@ each part and its ordering, and the composite checksum survives HEAD/GET for
|
|
|
22
22
|
reconciliation. Completion conditions use the backend's atomic conditional write.
|
|
23
23
|
Overwriting an object does not inherit the previous object's checksum metadata.
|
|
24
24
|
This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
|
|
25
|
-
algorithm or streaming checksum trailers.
|
|
25
|
+
algorithm or streaming checksum trailers. It also supports UploadPartCopy from
|
|
26
|
+
one backend snapshot, including closed byte ranges, exact ETag conditions,
|
|
27
|
+
version selection and SHA256 part receipts. Date-based copy conditions are
|
|
28
|
+
explicitly unsupported (501), rather than silently ignored.
|
|
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.
|
|
26
35
|
|
|
27
36
|
Run `./test.sh` from this directory to exercise real HTTP requests against the
|
|
28
37
|
same patched sources used in the image, including corrupt retries, conditional
|
|
@@ -147,39 +147,54 @@ func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
statuses
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
|
@@ -197,3 +212,161 @@ func TestMultipartAbortAndLegacyUpload(t *testing.T) {
|
|
|
197
212
|
t.Fatal("legacy multipart changed")
|
|
198
213
|
}
|
|
199
214
|
}
|
|
215
|
+
|
|
216
|
+
func TestUploadPartCopyRetainsExactSourceAndRejectsStaleRetry(t *testing.T) {
|
|
217
|
+
p := newProbe(t)
|
|
218
|
+
source := bytes.Repeat([]byte("0123456789"), 600000)
|
|
219
|
+
put := p.request("PUT", "/uploads/staging%20%2B%25.csv", source, nil, 200)
|
|
220
|
+
path := p.start("copied", false)
|
|
221
|
+
headers := map[string]string{"x-amz-copy-source": "/uploads/staging%20%2B%25.csv", "x-amz-copy-source-if-match": put.header.Get("ETag"), "x-amz-copy-source-range": "bytes=3-5242882"}
|
|
222
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
223
|
+
var part struct {
|
|
224
|
+
XMLName xml.Name
|
|
225
|
+
ETag string
|
|
226
|
+
LastModified string
|
|
227
|
+
}
|
|
228
|
+
if err := xml.Unmarshal(res.body, &part); err != nil || part.XMLName.Local != "CopyPartResult" || part.ETag == "" || part.LastModified == "" {
|
|
229
|
+
t.Fatalf("copy part receipt: %s: %v", res.body, err)
|
|
230
|
+
}
|
|
231
|
+
first := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
232
|
+
headers["x-amz-copy-source-if-match"] = `"stale"`
|
|
233
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
234
|
+
headers["x-amz-copy-source-if-match"] = put.header.Get("ETag")
|
|
235
|
+
for _, invalid := range []string{"bytes=6000000-6000001", "bytes=3-6000000", "bytes=5-3", "bytes=-3", "bytes=3-", "bytes=0-1,3-4"} {
|
|
236
|
+
headers["x-amz-copy-source-range"] = invalid
|
|
237
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 416)
|
|
238
|
+
}
|
|
239
|
+
headers["x-amz-copy-source-range"] = "bytes=5242883-5999999"
|
|
240
|
+
res = p.request("PUT", path+"&partNumber=2", nil, headers, 200)
|
|
241
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
second := fmt.Sprintf("<Part><PartNumber>2</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
245
|
+
p.request("POST", path, completion(first, second), nil, 200)
|
|
246
|
+
got := p.request("GET", "/uploads/copied", nil, nil, 200)
|
|
247
|
+
if !bytes.Equal(got.body, source[3:]) {
|
|
248
|
+
t.Fatal("copy changed the retained byte range")
|
|
249
|
+
}
|
|
250
|
+
p.request("PUT", path+"&partNumber=2", nil, headers, 404)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
|
|
254
|
+
p := newProbe(t)
|
|
255
|
+
p.request("PUT", "/uploads?versioning", []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`), nil, 200)
|
|
256
|
+
original := []byte("retained original version")
|
|
257
|
+
source := p.request("PUT", "/uploads/versioned", original, nil, 200)
|
|
258
|
+
version := source.header.Get("x-amz-version-id")
|
|
259
|
+
if version == "" {
|
|
260
|
+
t.Fatal("source has no version")
|
|
261
|
+
}
|
|
262
|
+
p.request("PUT", "/uploads/versioned", []byte("replacement"), nil, 200)
|
|
263
|
+
path := p.start("version-copy", true)
|
|
264
|
+
headers := map[string]string{"x-amz-copy-source": "/uploads/versioned?versionId=" + version, "x-amz-copy-source-if-match": source.header.Get("ETag")}
|
|
265
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
266
|
+
var part struct {
|
|
267
|
+
ETag string
|
|
268
|
+
ChecksumSHA256 string
|
|
269
|
+
}
|
|
270
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
if part.ChecksumSHA256 != sum(original) {
|
|
274
|
+
t.Fatal("copied version checksum differs")
|
|
275
|
+
}
|
|
276
|
+
headers["x-amz-copy-source-if-none-match"] = source.header.Get("ETag")
|
|
277
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
278
|
+
delete(headers, "x-amz-copy-source-if-none-match")
|
|
279
|
+
headers["x-amz-copy-source-if-modified-since"] = "Sat, 31 Oct 2026 00:00:00 GMT"
|
|
280
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 501)
|
|
281
|
+
delete(headers, "x-amz-copy-source-if-modified-since")
|
|
282
|
+
descriptor := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag><ChecksumSHA256>%s</ChecksumSHA256></Part>", part.ETag, part.ChecksumSHA256)
|
|
283
|
+
digest := sha256.Sum256(original)
|
|
284
|
+
composite := sum(digest[:]) + "-1"
|
|
285
|
+
p.request("POST", path, completion(descriptor), map[string]string{"x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": composite}, 200)
|
|
286
|
+
got := p.request("GET", "/uploads/version-copy", nil, nil, 200)
|
|
287
|
+
if !bytes.Equal(got.body, original) || got.header.Get("x-amz-checksum-sha256") != composite {
|
|
288
|
+
t.Fatal("versioned copy bytes or composite receipt changed")
|
|
289
|
+
}
|
|
290
|
+
path = p.start("aborted-copy", false)
|
|
291
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
292
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
293
|
+
headers["x-amz-copy-source"] = "/uploads/missing"
|
|
294
|
+
path = p.start("missing-copy", false)
|
|
295
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
296
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
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
|
+
}
|
|
@@ -239,3 +239,310 @@
|
|
|
239
239
|
// new metadata overwrites old but keep the rest
|
|
240
240
|
// TODO: check how metadata can be deleted?!
|
|
241
241
|
if _, ok := meta[k]; !ok {
|
|
242
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
243
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
244
|
+
@@ -960,6 +960,10 @@
|
|
245
|
+
return ErrInvalidPart
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
+ if r.Header.Get("x-amz-copy-source") != "" {
|
|
249
|
+
+ return g.copyMultipartUploadPart(bucket, object, uploadID, int(partNumber), w, r)
|
|
250
|
+
+ }
|
|
251
|
+
+
|
|
252
|
+
size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
|
|
253
|
+
if err != nil || size <= 0 {
|
|
254
|
+
return ErrMissingContentLength
|
|
255
|
+
--- /dev/null
|
|
256
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
|
|
257
|
+
@@ -0,0 +1,95 @@
|
|
258
|
+
+package gofakes3
|
|
259
|
+
+
|
|
260
|
+
+import (
|
|
261
|
+
+ "crypto/sha256"
|
|
262
|
+
+ "encoding/base64"
|
|
263
|
+
+ "encoding/xml"
|
|
264
|
+
+ "io"
|
|
265
|
+
+ "net/http"
|
|
266
|
+
+ "net/url"
|
|
267
|
+
+ "strings"
|
|
268
|
+
+)
|
|
269
|
+
+
|
|
270
|
+
+// Copy from one backend snapshot: a separate HEAD followed by GET could race
|
|
271
|
+
+// an overwrite and copy bytes that no longer match the caller's condition.
|
|
272
|
+
+func (g *GoFakeS3) copyMultipartUploadPart(bucket, object string, uploadID UploadID, number int, w http.ResponseWriter, r *http.Request) error {
|
|
273
|
+
+ source, query, _ := strings.Cut(r.Header.Get("x-amz-copy-source"), "?")
|
|
274
|
+
+ source, err := url.PathUnescape(source)
|
|
275
|
+
+ if err != nil {
|
|
276
|
+
+ return ErrInvalidArgument
|
|
277
|
+
+ }
|
|
278
|
+
+ parts := strings.SplitN(strings.TrimPrefix(source, "/"), "/", 2)
|
|
279
|
+
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
280
|
+
+ return ErrInvalidArgument
|
|
281
|
+
+ }
|
|
282
|
+
+ params, err := url.ParseQuery(query)
|
|
283
|
+
+ if err != nil {
|
|
284
|
+
+ return ErrInvalidArgument
|
|
285
|
+
+ }
|
|
286
|
+
+ for key := range params {
|
|
287
|
+
+ if key != "versionId" {
|
|
288
|
+
+ return ErrInvalidArgument
|
|
289
|
+
+ }
|
|
290
|
+
+ }
|
|
291
|
+
+ // Do not silently ignore conditions this compatibility subset cannot apply.
|
|
292
|
+
+ for _, key := range []string{"x-amz-copy-source-if-modified-since", "x-amz-copy-source-if-unmodified-since"} {
|
|
293
|
+
+ if r.Header.Get(key) != "" {
|
|
294
|
+
+ return ErrNotImplemented
|
|
295
|
+
+ }
|
|
296
|
+
+ }
|
|
297
|
+
+ var src *Object
|
|
298
|
+
+ if version := params.Get("versionId"); version != "" {
|
|
299
|
+
+ if g.versioned == nil {
|
|
300
|
+
+ return ErrNotImplemented
|
|
301
|
+
+ }
|
|
302
|
+
+ src, err = g.versioned.GetObjectVersion(parts[0], parts[1], VersionID(version), nil)
|
|
303
|
+
+ } else {
|
|
304
|
+
+ src, err = g.storage.GetObject(parts[0], parts[1], nil)
|
|
305
|
+
+ }
|
|
306
|
+
+ if err != nil {
|
|
307
|
+
+ return err
|
|
308
|
+
+ }
|
|
309
|
+
+ if src == nil || src.Contents == nil {
|
|
310
|
+
+ return ErrInternal
|
|
311
|
+
+ }
|
|
312
|
+
+ defer src.Contents.Close()
|
|
313
|
+
+ if src.IsDeleteMarker {
|
|
314
|
+
+ return ErrInvalidArgument
|
|
315
|
+
+ }
|
|
316
|
+
+ tag := FormatETag(src.Hash)
|
|
317
|
+
+ if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
|
|
318
|
+
+ return ErrPreconditionFailed
|
|
319
|
+
+ }
|
|
320
|
+
+ if expected := r.Header.Get("x-amz-copy-source-if-none-match"); expected != "" && expected == tag {
|
|
321
|
+
+ return ErrPreconditionFailed
|
|
322
|
+
+ }
|
|
323
|
+
+ start, size := int64(0), src.Size
|
|
324
|
+
+ if value := r.Header.Get("x-amz-copy-source-range"); value != "" {
|
|
325
|
+
+ span, err := parseRangeHeader(value)
|
|
326
|
+
+ if err != nil || span == nil || span.FromEnd || span.End == RangeNoEnd || span.End >= src.Size {
|
|
327
|
+
+ return ErrInvalidRange
|
|
328
|
+
+ }
|
|
329
|
+
+ start, size = span.Start, span.End-span.Start+1
|
|
330
|
+
+ }
|
|
331
|
+
+ if size <= 0 {
|
|
332
|
+
+ return ErrInvalidRange
|
|
333
|
+
+ }
|
|
334
|
+
+ if _, err := io.CopyN(io.Discard, src.Contents, start); err != nil {
|
|
335
|
+
+ return err
|
|
336
|
+
+ }
|
|
337
|
+
+ digest := sha256.New()
|
|
338
|
+
+ body := io.TeeReader(io.LimitReader(src.Contents, size), digest)
|
|
339
|
+
+ etag, err := g.uploader.UploadPart(bucket, object, uploadID, number, size, body)
|
|
340
|
+
+ if err != nil {
|
|
341
|
+
+ return err
|
|
342
|
+
+ }
|
|
343
|
+
+ if src.VersionID != "" {
|
|
344
|
+
+ w.Header().Set("x-amz-copy-source-version-id", string(src.VersionID))
|
|
345
|
+
+ }
|
|
346
|
+
+ return g.xmlEncoder(w).Encode(struct {
|
|
347
|
+
+ XMLName xml.Name `xml:"CopyPartResult"`
|
|
348
|
+
+ ETag string `xml:"ETag"`
|
|
349
|
+
+ LastModified ContentTime `xml:"LastModified"`
|
|
350
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256"`
|
|
351
|
+
+ }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
|
|
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
|
@@ -22,7 +22,16 @@ each part and its ordering, and the composite checksum survives HEAD/GET for
|
|
|
22
22
|
reconciliation. Completion conditions use the backend's atomic conditional write.
|
|
23
23
|
Overwriting an object does not inherit the previous object's checksum metadata.
|
|
24
24
|
This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
|
|
25
|
-
algorithm or streaming checksum trailers.
|
|
25
|
+
algorithm or streaming checksum trailers. It also supports UploadPartCopy from
|
|
26
|
+
one backend snapshot, including closed byte ranges, exact ETag conditions,
|
|
27
|
+
version selection and SHA256 part receipts. Date-based copy conditions are
|
|
28
|
+
explicitly unsupported (501), rather than silently ignored.
|
|
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.
|
|
26
35
|
|
|
27
36
|
Run `./test.sh` from this directory to exercise real HTTP requests against the
|
|
28
37
|
same patched sources used in the image, including corrupt retries, conditional
|
|
@@ -147,39 +147,54 @@ func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
func TestMultipartConditionalCompletionIsAtomic(t *testing.T) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
statuses
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
|
@@ -197,3 +212,161 @@ func TestMultipartAbortAndLegacyUpload(t *testing.T) {
|
|
|
197
212
|
t.Fatal("legacy multipart changed")
|
|
198
213
|
}
|
|
199
214
|
}
|
|
215
|
+
|
|
216
|
+
func TestUploadPartCopyRetainsExactSourceAndRejectsStaleRetry(t *testing.T) {
|
|
217
|
+
p := newProbe(t)
|
|
218
|
+
source := bytes.Repeat([]byte("0123456789"), 600000)
|
|
219
|
+
put := p.request("PUT", "/uploads/staging%20%2B%25.csv", source, nil, 200)
|
|
220
|
+
path := p.start("copied", false)
|
|
221
|
+
headers := map[string]string{"x-amz-copy-source": "/uploads/staging%20%2B%25.csv", "x-amz-copy-source-if-match": put.header.Get("ETag"), "x-amz-copy-source-range": "bytes=3-5242882"}
|
|
222
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
223
|
+
var part struct {
|
|
224
|
+
XMLName xml.Name
|
|
225
|
+
ETag string
|
|
226
|
+
LastModified string
|
|
227
|
+
}
|
|
228
|
+
if err := xml.Unmarshal(res.body, &part); err != nil || part.XMLName.Local != "CopyPartResult" || part.ETag == "" || part.LastModified == "" {
|
|
229
|
+
t.Fatalf("copy part receipt: %s: %v", res.body, err)
|
|
230
|
+
}
|
|
231
|
+
first := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
232
|
+
headers["x-amz-copy-source-if-match"] = `"stale"`
|
|
233
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
234
|
+
headers["x-amz-copy-source-if-match"] = put.header.Get("ETag")
|
|
235
|
+
for _, invalid := range []string{"bytes=6000000-6000001", "bytes=3-6000000", "bytes=5-3", "bytes=-3", "bytes=3-", "bytes=0-1,3-4"} {
|
|
236
|
+
headers["x-amz-copy-source-range"] = invalid
|
|
237
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 416)
|
|
238
|
+
}
|
|
239
|
+
headers["x-amz-copy-source-range"] = "bytes=5242883-5999999"
|
|
240
|
+
res = p.request("PUT", path+"&partNumber=2", nil, headers, 200)
|
|
241
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
second := fmt.Sprintf("<Part><PartNumber>2</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
245
|
+
p.request("POST", path, completion(first, second), nil, 200)
|
|
246
|
+
got := p.request("GET", "/uploads/copied", nil, nil, 200)
|
|
247
|
+
if !bytes.Equal(got.body, source[3:]) {
|
|
248
|
+
t.Fatal("copy changed the retained byte range")
|
|
249
|
+
}
|
|
250
|
+
p.request("PUT", path+"&partNumber=2", nil, headers, 404)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
|
|
254
|
+
p := newProbe(t)
|
|
255
|
+
p.request("PUT", "/uploads?versioning", []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`), nil, 200)
|
|
256
|
+
original := []byte("retained original version")
|
|
257
|
+
source := p.request("PUT", "/uploads/versioned", original, nil, 200)
|
|
258
|
+
version := source.header.Get("x-amz-version-id")
|
|
259
|
+
if version == "" {
|
|
260
|
+
t.Fatal("source has no version")
|
|
261
|
+
}
|
|
262
|
+
p.request("PUT", "/uploads/versioned", []byte("replacement"), nil, 200)
|
|
263
|
+
path := p.start("version-copy", true)
|
|
264
|
+
headers := map[string]string{"x-amz-copy-source": "/uploads/versioned?versionId=" + version, "x-amz-copy-source-if-match": source.header.Get("ETag")}
|
|
265
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
266
|
+
var part struct {
|
|
267
|
+
ETag string
|
|
268
|
+
ChecksumSHA256 string
|
|
269
|
+
}
|
|
270
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
if part.ChecksumSHA256 != sum(original) {
|
|
274
|
+
t.Fatal("copied version checksum differs")
|
|
275
|
+
}
|
|
276
|
+
headers["x-amz-copy-source-if-none-match"] = source.header.Get("ETag")
|
|
277
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
278
|
+
delete(headers, "x-amz-copy-source-if-none-match")
|
|
279
|
+
headers["x-amz-copy-source-if-modified-since"] = "Sat, 31 Oct 2026 00:00:00 GMT"
|
|
280
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 501)
|
|
281
|
+
delete(headers, "x-amz-copy-source-if-modified-since")
|
|
282
|
+
descriptor := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag><ChecksumSHA256>%s</ChecksumSHA256></Part>", part.ETag, part.ChecksumSHA256)
|
|
283
|
+
digest := sha256.Sum256(original)
|
|
284
|
+
composite := sum(digest[:]) + "-1"
|
|
285
|
+
p.request("POST", path, completion(descriptor), map[string]string{"x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": composite}, 200)
|
|
286
|
+
got := p.request("GET", "/uploads/version-copy", nil, nil, 200)
|
|
287
|
+
if !bytes.Equal(got.body, original) || got.header.Get("x-amz-checksum-sha256") != composite {
|
|
288
|
+
t.Fatal("versioned copy bytes or composite receipt changed")
|
|
289
|
+
}
|
|
290
|
+
path = p.start("aborted-copy", false)
|
|
291
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
292
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
293
|
+
headers["x-amz-copy-source"] = "/uploads/missing"
|
|
294
|
+
path = p.start("missing-copy", false)
|
|
295
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
296
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
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
|
+
}
|
|
@@ -239,3 +239,310 @@
|
|
|
239
239
|
// new metadata overwrites old but keep the rest
|
|
240
240
|
// TODO: check how metadata can be deleted?!
|
|
241
241
|
if _, ok := meta[k]; !ok {
|
|
242
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
243
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
244
|
+
@@ -960,6 +960,10 @@
|
|
245
|
+
return ErrInvalidPart
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
+ if r.Header.Get("x-amz-copy-source") != "" {
|
|
249
|
+
+ return g.copyMultipartUploadPart(bucket, object, uploadID, int(partNumber), w, r)
|
|
250
|
+
+ }
|
|
251
|
+
+
|
|
252
|
+
size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
|
|
253
|
+
if err != nil || size <= 0 {
|
|
254
|
+
return ErrMissingContentLength
|
|
255
|
+
--- /dev/null
|
|
256
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/upload_part_copy.go
|
|
257
|
+
@@ -0,0 +1,95 @@
|
|
258
|
+
+package gofakes3
|
|
259
|
+
+
|
|
260
|
+
+import (
|
|
261
|
+
+ "crypto/sha256"
|
|
262
|
+
+ "encoding/base64"
|
|
263
|
+
+ "encoding/xml"
|
|
264
|
+
+ "io"
|
|
265
|
+
+ "net/http"
|
|
266
|
+
+ "net/url"
|
|
267
|
+
+ "strings"
|
|
268
|
+
+)
|
|
269
|
+
+
|
|
270
|
+
+// Copy from one backend snapshot: a separate HEAD followed by GET could race
|
|
271
|
+
+// an overwrite and copy bytes that no longer match the caller's condition.
|
|
272
|
+
+func (g *GoFakeS3) copyMultipartUploadPart(bucket, object string, uploadID UploadID, number int, w http.ResponseWriter, r *http.Request) error {
|
|
273
|
+
+ source, query, _ := strings.Cut(r.Header.Get("x-amz-copy-source"), "?")
|
|
274
|
+
+ source, err := url.PathUnescape(source)
|
|
275
|
+
+ if err != nil {
|
|
276
|
+
+ return ErrInvalidArgument
|
|
277
|
+
+ }
|
|
278
|
+
+ parts := strings.SplitN(strings.TrimPrefix(source, "/"), "/", 2)
|
|
279
|
+
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
280
|
+
+ return ErrInvalidArgument
|
|
281
|
+
+ }
|
|
282
|
+
+ params, err := url.ParseQuery(query)
|
|
283
|
+
+ if err != nil {
|
|
284
|
+
+ return ErrInvalidArgument
|
|
285
|
+
+ }
|
|
286
|
+
+ for key := range params {
|
|
287
|
+
+ if key != "versionId" {
|
|
288
|
+
+ return ErrInvalidArgument
|
|
289
|
+
+ }
|
|
290
|
+
+ }
|
|
291
|
+
+ // Do not silently ignore conditions this compatibility subset cannot apply.
|
|
292
|
+
+ for _, key := range []string{"x-amz-copy-source-if-modified-since", "x-amz-copy-source-if-unmodified-since"} {
|
|
293
|
+
+ if r.Header.Get(key) != "" {
|
|
294
|
+
+ return ErrNotImplemented
|
|
295
|
+
+ }
|
|
296
|
+
+ }
|
|
297
|
+
+ var src *Object
|
|
298
|
+
+ if version := params.Get("versionId"); version != "" {
|
|
299
|
+
+ if g.versioned == nil {
|
|
300
|
+
+ return ErrNotImplemented
|
|
301
|
+
+ }
|
|
302
|
+
+ src, err = g.versioned.GetObjectVersion(parts[0], parts[1], VersionID(version), nil)
|
|
303
|
+
+ } else {
|
|
304
|
+
+ src, err = g.storage.GetObject(parts[0], parts[1], nil)
|
|
305
|
+
+ }
|
|
306
|
+
+ if err != nil {
|
|
307
|
+
+ return err
|
|
308
|
+
+ }
|
|
309
|
+
+ if src == nil || src.Contents == nil {
|
|
310
|
+
+ return ErrInternal
|
|
311
|
+
+ }
|
|
312
|
+
+ defer src.Contents.Close()
|
|
313
|
+
+ if src.IsDeleteMarker {
|
|
314
|
+
+ return ErrInvalidArgument
|
|
315
|
+
+ }
|
|
316
|
+
+ tag := FormatETag(src.Hash)
|
|
317
|
+
+ if expected := r.Header.Get("x-amz-copy-source-if-match"); expected != "" && expected != tag {
|
|
318
|
+
+ return ErrPreconditionFailed
|
|
319
|
+
+ }
|
|
320
|
+
+ if expected := r.Header.Get("x-amz-copy-source-if-none-match"); expected != "" && expected == tag {
|
|
321
|
+
+ return ErrPreconditionFailed
|
|
322
|
+
+ }
|
|
323
|
+
+ start, size := int64(0), src.Size
|
|
324
|
+
+ if value := r.Header.Get("x-amz-copy-source-range"); value != "" {
|
|
325
|
+
+ span, err := parseRangeHeader(value)
|
|
326
|
+
+ if err != nil || span == nil || span.FromEnd || span.End == RangeNoEnd || span.End >= src.Size {
|
|
327
|
+
+ return ErrInvalidRange
|
|
328
|
+
+ }
|
|
329
|
+
+ start, size = span.Start, span.End-span.Start+1
|
|
330
|
+
+ }
|
|
331
|
+
+ if size <= 0 {
|
|
332
|
+
+ return ErrInvalidRange
|
|
333
|
+
+ }
|
|
334
|
+
+ if _, err := io.CopyN(io.Discard, src.Contents, start); err != nil {
|
|
335
|
+
+ return err
|
|
336
|
+
+ }
|
|
337
|
+
+ digest := sha256.New()
|
|
338
|
+
+ body := io.TeeReader(io.LimitReader(src.Contents, size), digest)
|
|
339
|
+
+ etag, err := g.uploader.UploadPart(bucket, object, uploadID, number, size, body)
|
|
340
|
+
+ if err != nil {
|
|
341
|
+
+ return err
|
|
342
|
+
+ }
|
|
343
|
+
+ if src.VersionID != "" {
|
|
344
|
+
+ w.Header().Set("x-amz-copy-source-version-id", string(src.VersionID))
|
|
345
|
+
+ }
|
|
346
|
+
+ return g.xmlEncoder(w).Encode(struct {
|
|
347
|
+
+ XMLName xml.Name `xml:"CopyPartResult"`
|
|
348
|
+
+ ETag string `xml:"ETag"`
|
|
349
|
+
+ LastModified ContentTime `xml:"LastModified"`
|
|
350
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256"`
|
|
351
|
+
+ }{ETag: etag, LastModified: NewContentTime(g.timeSource.Now()), ChecksumSHA256: base64.StdEncoding.EncodeToString(digest.Sum(nil))})
|
|
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,
|