@specific.dev/spectest 0.131.6 → 0.131.8
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 +16 -0
- package/dist/components/s3-runtime/main.go +5 -1
- package/dist/components/s3-runtime/main_test.go +282 -0
- package/dist/components/s3-runtime/multipart.patch +352 -0
- package/dist/components/s3-runtime/test.sh +12 -0
- package/dist/components/s3.js +2 -2
- package/package.json +1 -1
- package/src/components/s3-runtime/README.md +16 -0
- package/src/components/s3-runtime/main.go +5 -1
- package/src/components/s3-runtime/main_test.go +282 -0
- package/src/components/s3-runtime/multipart.patch +352 -0
- package/src/components/s3-runtime/test.sh +12 -0
- package/src/components/s3.ts +2 -2
|
@@ -16,6 +16,22 @@ uploads, multipart initiation and copies. Upstream currently drops these headers
|
|
|
16
16
|
in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
|
|
17
17
|
a failed patch stops the build. Recheck it when changing the upstream pin.
|
|
18
18
|
|
|
19
|
+
`multipart.patch` adds SHA256 composite multipart integrity to the existing
|
|
20
|
+
uploader: supplied part checksums are validated before storage, completion checks
|
|
21
|
+
each part and its ordering, and the composite checksum survives HEAD/GET for
|
|
22
|
+
reconciliation. Completion conditions use the backend's atomic conditional write.
|
|
23
|
+
Overwriting an object does not inherit the previous object's checksum metadata.
|
|
24
|
+
This patch supports SHA256/COMPOSITE; it does not implement every S3 checksum
|
|
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
|
+
Run `./test.sh` from this directory to exercise real HTTP requests against the
|
|
31
|
+
same patched sources used in the image, including corrupt retries, conditional
|
|
32
|
+
completion races, abort, metadata reconciliation and unchecksummed uploads.
|
|
33
|
+
The test runs in a temporary vendor directory and enables Go's race detector.
|
|
34
|
+
|
|
19
35
|
From `sdk`, run the real-image contract on a Linux Docker host:
|
|
20
36
|
|
|
21
37
|
```
|
|
@@ -11,6 +11,10 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
func main() {
|
|
14
|
+
log.Fatal(http.ListenAndServe(":9090", newServer()))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func newServer() http.Handler {
|
|
14
18
|
// VM snapshots include RAM: buckets, versions and in-flight multipart state
|
|
15
19
|
// fork together. A fresh process starts empty, as S3Mock did by default.
|
|
16
20
|
// Unlike the Bolt backend, s3mem also supports bucket versioning.
|
|
@@ -21,7 +25,7 @@ func main() {
|
|
|
21
25
|
gofakes3.WithAutoBucket(false),
|
|
22
26
|
gofakes3.WithLogger(gofakes3.GlobalLog()),
|
|
23
27
|
).Server()
|
|
24
|
-
|
|
28
|
+
return cors(server)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// Preserve S3Mock's permissive browser uploads, including arbitrary metadata
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/xml"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"testing"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type s3Response struct {
|
|
18
|
+
status int
|
|
19
|
+
header http.Header
|
|
20
|
+
body []byte
|
|
21
|
+
}
|
|
22
|
+
type s3Probe struct {
|
|
23
|
+
t *testing.T
|
|
24
|
+
server *httptest.Server
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func newProbe(t *testing.T) *s3Probe {
|
|
28
|
+
t.Helper()
|
|
29
|
+
p := &s3Probe{t, httptest.NewServer(newServer())}
|
|
30
|
+
t.Cleanup(p.server.Close)
|
|
31
|
+
p.request("PUT", "/uploads", nil, nil, 200)
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
func (p *s3Probe) request(method, path string, body []byte, headers map[string]string, want int) s3Response {
|
|
35
|
+
p.t.Helper()
|
|
36
|
+
req, err := http.NewRequest(method, p.server.URL+path, bytes.NewReader(body))
|
|
37
|
+
if err != nil {
|
|
38
|
+
p.t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
for k, v := range headers {
|
|
41
|
+
req.Header.Set(k, v)
|
|
42
|
+
}
|
|
43
|
+
res, err := p.server.Client().Do(req)
|
|
44
|
+
if err != nil {
|
|
45
|
+
p.t.Fatal(err)
|
|
46
|
+
}
|
|
47
|
+
defer res.Body.Close()
|
|
48
|
+
data, err := io.ReadAll(res.Body)
|
|
49
|
+
if err != nil {
|
|
50
|
+
p.t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
if res.StatusCode != want {
|
|
53
|
+
p.t.Fatalf("%s %s: got %d want %d: %s", method, path, res.StatusCode, want, data)
|
|
54
|
+
}
|
|
55
|
+
return s3Response{res.StatusCode, res.Header, data}
|
|
56
|
+
}
|
|
57
|
+
func sum(body []byte) string {
|
|
58
|
+
digest := sha256.Sum256(body)
|
|
59
|
+
return base64.StdEncoding.EncodeToString(digest[:])
|
|
60
|
+
}
|
|
61
|
+
func (p *s3Probe) start(key string, checksum bool) string {
|
|
62
|
+
p.t.Helper()
|
|
63
|
+
headers := map[string]string{"Cache-Control": "private, max-age=60"}
|
|
64
|
+
if checksum {
|
|
65
|
+
headers["x-amz-checksum-algorithm"] = "SHA256"
|
|
66
|
+
headers["x-amz-checksum-type"] = "COMPOSITE"
|
|
67
|
+
}
|
|
68
|
+
res := p.request("POST", "/uploads/"+key+"?uploads", nil, headers, 200)
|
|
69
|
+
var out struct {
|
|
70
|
+
UploadID string `xml:"UploadId"`
|
|
71
|
+
}
|
|
72
|
+
if err := xml.Unmarshal(res.body, &out); err != nil || out.UploadID == "" {
|
|
73
|
+
p.t.Fatalf("initiate: %s, %v", res.body, err)
|
|
74
|
+
}
|
|
75
|
+
return "/uploads/" + key + "?uploadId=" + out.UploadID
|
|
76
|
+
}
|
|
77
|
+
func (p *s3Probe) part(path string, number int, body []byte, checksum bool) string {
|
|
78
|
+
p.t.Helper()
|
|
79
|
+
headers := map[string]string{}
|
|
80
|
+
if checksum {
|
|
81
|
+
headers["x-amz-checksum-sha256"] = sum(body)
|
|
82
|
+
}
|
|
83
|
+
res := p.request("PUT", fmt.Sprintf("%s&partNumber=%d", path, number), body, headers, 200)
|
|
84
|
+
if checksum && res.header.Get("x-amz-checksum-sha256") != sum(body) {
|
|
85
|
+
p.t.Fatalf("part checksum acknowledgement: %v", res.header)
|
|
86
|
+
}
|
|
87
|
+
tag := res.header.Get("ETag")
|
|
88
|
+
if tag == "" {
|
|
89
|
+
p.t.Fatal("missing ETag")
|
|
90
|
+
}
|
|
91
|
+
digest := ""
|
|
92
|
+
if checksum {
|
|
93
|
+
digest = "<ChecksumSHA256>" + sum(body) + "</ChecksumSHA256>"
|
|
94
|
+
}
|
|
95
|
+
return fmt.Sprintf("<Part><PartNumber>%d</PartNumber><ETag>%s</ETag>%s</Part>", number, tag, digest)
|
|
96
|
+
}
|
|
97
|
+
func completion(parts ...string) []byte {
|
|
98
|
+
return []byte("<CompleteMultipartUpload>" + strings.Join(parts, "") + "</CompleteMultipartUpload>")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestMultipartSHA256IntegrityAndReconciliation(t *testing.T) {
|
|
102
|
+
p := newProbe(t)
|
|
103
|
+
path := p.start("large", true)
|
|
104
|
+
first := bytes.Repeat([]byte("a"), 8*1024*1024)
|
|
105
|
+
last := bytes.Repeat([]byte("b"), 1024*1024)
|
|
106
|
+
part1 := p.part(path, 1, first, true)
|
|
107
|
+
// A rejected retry must not replace the already acknowledged part.
|
|
108
|
+
res := p.request("PUT", path+"&partNumber=1", []byte("corrupt"), map[string]string{"x-amz-checksum-sha256": sum(first)}, 400)
|
|
109
|
+
if !bytes.Contains(res.body, []byte("BadDigest")) {
|
|
110
|
+
t.Fatalf("corrupt part: %s", res.body)
|
|
111
|
+
}
|
|
112
|
+
part2 := p.part(path, 2, last, true)
|
|
113
|
+
for _, parts := range [][]string{{part2, part1}, {part1, part1}, {strings.Replace(part1, "<PartNumber>1", "<PartNumber>-1", 1)}, {strings.Replace(part1, sum(first), sum(last), 1), part2}, {strings.Replace(part1, "<ETag>", "<ETag>bad", 1), part2}} {
|
|
114
|
+
p.request("POST", path, completion(parts...), nil, 400)
|
|
115
|
+
}
|
|
116
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-type": "FULL_OBJECT"}, 400)
|
|
117
|
+
p.request("POST", path, completion(part1, part2), map[string]string{"x-amz-checksum-sha256": "wrong"}, 400)
|
|
118
|
+
a, b := sha256.Sum256(first), sha256.Sum256(last)
|
|
119
|
+
expected := sum(append(a[:], b[:]...)) + "-2"
|
|
120
|
+
res = p.request("POST", path, completion(part1, part2), map[string]string{"If-None-Match": "*", "x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": expected}, 200)
|
|
121
|
+
var completed struct {
|
|
122
|
+
ChecksumSHA256 string
|
|
123
|
+
ChecksumType string
|
|
124
|
+
}
|
|
125
|
+
if err := xml.Unmarshal(res.body, &completed); err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
if completed.ChecksumSHA256 != expected || completed.ChecksumType != "COMPOSITE" {
|
|
129
|
+
t.Fatalf("complete: %s", res.body)
|
|
130
|
+
}
|
|
131
|
+
// Reconcile using retained metadata after an unobserved completion response.
|
|
132
|
+
head := p.request("HEAD", "/uploads/large", nil, map[string]string{"x-amz-checksum-mode": "ENABLED"}, 200)
|
|
133
|
+
if head.header.Get("x-amz-checksum-sha256") != expected || head.header.Get("x-amz-checksum-type") != "COMPOSITE" || head.header.Get("Content-Length") != fmt.Sprint(len(first)+len(last)) || head.header.Get("Cache-Control") != "private, max-age=60" {
|
|
134
|
+
t.Fatalf("reconciliation: %v", head.header)
|
|
135
|
+
}
|
|
136
|
+
got := p.request("GET", "/uploads/large", nil, nil, 200)
|
|
137
|
+
if !bytes.Equal(got.body, append(first, last...)) {
|
|
138
|
+
t.Fatal("completed bytes differ")
|
|
139
|
+
}
|
|
140
|
+
p.request("PUT", path+"&partNumber=1", first, nil, 404)
|
|
141
|
+
// An ordinary overwrite must not inherit an obsolete composite checksum.
|
|
142
|
+
p.request("PUT", "/uploads/large", []byte("replacement"), nil, 200)
|
|
143
|
+
head = p.request("HEAD", "/uploads/large", nil, nil, 200)
|
|
144
|
+
if head.header.Get("x-amz-checksum-sha256") != "" || head.header.Get("x-amz-checksum-type") != "" {
|
|
145
|
+
t.Fatal("overwrite retained stale checksum")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
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
|
|
165
|
+
}
|
|
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)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func TestMultipartAbortAndLegacyUpload(t *testing.T) {
|
|
187
|
+
p := newProbe(t)
|
|
188
|
+
path := p.start("aborted", true)
|
|
189
|
+
part := p.part(path, 1, []byte("abc"), true)
|
|
190
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
191
|
+
p.request("POST", path, completion(part), nil, 404)
|
|
192
|
+
p.request("HEAD", "/uploads/aborted", nil, nil, 404)
|
|
193
|
+
path = p.start("legacy", false)
|
|
194
|
+
part = p.part(path, 1, []byte("legacy"), false)
|
|
195
|
+
p.request("POST", path, completion(part), nil, 200)
|
|
196
|
+
if got := p.request("GET", "/uploads/legacy", nil, nil, 200); string(got.body) != "legacy" {
|
|
197
|
+
t.Fatal("legacy multipart changed")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func TestUploadPartCopyRetainsExactSourceAndRejectsStaleRetry(t *testing.T) {
|
|
202
|
+
p := newProbe(t)
|
|
203
|
+
source := bytes.Repeat([]byte("0123456789"), 600000)
|
|
204
|
+
put := p.request("PUT", "/uploads/staging%20%2B%25.csv", source, nil, 200)
|
|
205
|
+
path := p.start("copied", false)
|
|
206
|
+
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"}
|
|
207
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
208
|
+
var part struct {
|
|
209
|
+
XMLName xml.Name
|
|
210
|
+
ETag string
|
|
211
|
+
LastModified string
|
|
212
|
+
}
|
|
213
|
+
if err := xml.Unmarshal(res.body, &part); err != nil || part.XMLName.Local != "CopyPartResult" || part.ETag == "" || part.LastModified == "" {
|
|
214
|
+
t.Fatalf("copy part receipt: %s: %v", res.body, err)
|
|
215
|
+
}
|
|
216
|
+
first := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
217
|
+
headers["x-amz-copy-source-if-match"] = `"stale"`
|
|
218
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
219
|
+
headers["x-amz-copy-source-if-match"] = put.header.Get("ETag")
|
|
220
|
+
for _, invalid := range []string{"bytes=6000000-6000001", "bytes=3-6000000", "bytes=5-3", "bytes=-3", "bytes=3-", "bytes=0-1,3-4"} {
|
|
221
|
+
headers["x-amz-copy-source-range"] = invalid
|
|
222
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 416)
|
|
223
|
+
}
|
|
224
|
+
headers["x-amz-copy-source-range"] = "bytes=5242883-5999999"
|
|
225
|
+
res = p.request("PUT", path+"&partNumber=2", nil, headers, 200)
|
|
226
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
227
|
+
t.Fatal(err)
|
|
228
|
+
}
|
|
229
|
+
second := fmt.Sprintf("<Part><PartNumber>2</PartNumber><ETag>%s</ETag></Part>", part.ETag)
|
|
230
|
+
p.request("POST", path, completion(first, second), nil, 200)
|
|
231
|
+
got := p.request("GET", "/uploads/copied", nil, nil, 200)
|
|
232
|
+
if !bytes.Equal(got.body, source[3:]) {
|
|
233
|
+
t.Fatal("copy changed the retained byte range")
|
|
234
|
+
}
|
|
235
|
+
p.request("PUT", path+"&partNumber=2", nil, headers, 404)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func TestUploadPartCopyVersionChecksumAndAbort(t *testing.T) {
|
|
239
|
+
p := newProbe(t)
|
|
240
|
+
p.request("PUT", "/uploads?versioning", []byte(`<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>`), nil, 200)
|
|
241
|
+
original := []byte("retained original version")
|
|
242
|
+
source := p.request("PUT", "/uploads/versioned", original, nil, 200)
|
|
243
|
+
version := source.header.Get("x-amz-version-id")
|
|
244
|
+
if version == "" {
|
|
245
|
+
t.Fatal("source has no version")
|
|
246
|
+
}
|
|
247
|
+
p.request("PUT", "/uploads/versioned", []byte("replacement"), nil, 200)
|
|
248
|
+
path := p.start("version-copy", true)
|
|
249
|
+
headers := map[string]string{"x-amz-copy-source": "/uploads/versioned?versionId=" + version, "x-amz-copy-source-if-match": source.header.Get("ETag")}
|
|
250
|
+
res := p.request("PUT", path+"&partNumber=1", nil, headers, 200)
|
|
251
|
+
var part struct {
|
|
252
|
+
ETag string
|
|
253
|
+
ChecksumSHA256 string
|
|
254
|
+
}
|
|
255
|
+
if err := xml.Unmarshal(res.body, &part); err != nil {
|
|
256
|
+
t.Fatal(err)
|
|
257
|
+
}
|
|
258
|
+
if part.ChecksumSHA256 != sum(original) {
|
|
259
|
+
t.Fatal("copied version checksum differs")
|
|
260
|
+
}
|
|
261
|
+
headers["x-amz-copy-source-if-none-match"] = source.header.Get("ETag")
|
|
262
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 412)
|
|
263
|
+
delete(headers, "x-amz-copy-source-if-none-match")
|
|
264
|
+
headers["x-amz-copy-source-if-modified-since"] = "Sat, 31 Oct 2026 00:00:00 GMT"
|
|
265
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 501)
|
|
266
|
+
delete(headers, "x-amz-copy-source-if-modified-since")
|
|
267
|
+
descriptor := fmt.Sprintf("<Part><PartNumber>1</PartNumber><ETag>%s</ETag><ChecksumSHA256>%s</ChecksumSHA256></Part>", part.ETag, part.ChecksumSHA256)
|
|
268
|
+
digest := sha256.Sum256(original)
|
|
269
|
+
composite := sum(digest[:]) + "-1"
|
|
270
|
+
p.request("POST", path, completion(descriptor), map[string]string{"x-amz-checksum-type": "COMPOSITE", "x-amz-checksum-sha256": composite}, 200)
|
|
271
|
+
got := p.request("GET", "/uploads/version-copy", nil, nil, 200)
|
|
272
|
+
if !bytes.Equal(got.body, original) || got.header.Get("x-amz-checksum-sha256") != composite {
|
|
273
|
+
t.Fatal("versioned copy bytes or composite receipt changed")
|
|
274
|
+
}
|
|
275
|
+
path = p.start("aborted-copy", false)
|
|
276
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
277
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
278
|
+
headers["x-amz-copy-source"] = "/uploads/missing"
|
|
279
|
+
path = p.start("missing-copy", false)
|
|
280
|
+
p.request("PUT", path+"&partNumber=1", nil, headers, 404)
|
|
281
|
+
p.request("DELETE", path, nil, nil, 204)
|
|
282
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
2
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
|
|
3
|
+
@@ -933,6 +933,14 @@
|
|
4
|
+
return err
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
+ if meta["X-Amz-Checksum-Algorithm"] == "SHA256" {
|
|
8
|
+
+ if kind := meta["X-Amz-Checksum-Type"]; kind != "" && kind != "COMPOSITE" {
|
|
9
|
+
+ return ErrInvalidArgument
|
|
10
|
+
+ }
|
|
11
|
+
+ meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
12
|
+
+ w.Header().Set("x-amz-checksum-algorithm", "SHA256")
|
|
13
|
+
+ w.Header().Set("x-amz-checksum-type", "COMPOSITE")
|
|
14
|
+
+ }
|
|
15
|
+
uploadID, err := g.uploader.CreateMultipartUpload(bucket, object, meta)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return err
|
|
18
|
+
@@ -983,12 +991,20 @@
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
+ var checksum *sha256PartReader
|
|
23
|
+
+ if expected := r.Header.Get("x-amz-checksum-sha256"); expected != "" {
|
|
24
|
+
+ checksum = newSHA256PartReader(rdr, expected)
|
|
25
|
+
+ rdr = checksum
|
|
26
|
+
+ }
|
|
27
|
+
etag, err := g.uploader.UploadPart(bucket, object, uploadID, int(partNumber), r.ContentLength, rdr)
|
|
28
|
+
if err != nil {
|
|
29
|
+
return err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
w.Header().Add("ETag", etag)
|
|
33
|
+
+ if checksum != nil {
|
|
34
|
+
+ w.Header().Set("x-amz-checksum-sha256", checksum.sum())
|
|
35
|
+
+ }
|
|
36
|
+
return nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@@ -1009,6 +1025,13 @@
|
|
40
|
+
return err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
+ var err error
|
|
44
|
+
+ in.Conditions, err = parsePutConditions(r.Header)
|
|
45
|
+
+ if err != nil {
|
|
46
|
+
+ return err
|
|
47
|
+
+ }
|
|
48
|
+
+ in.ChecksumSHA256 = r.Header.Get("x-amz-checksum-sha256")
|
|
49
|
+
+ in.ChecksumType = r.Header.Get("x-amz-checksum-type")
|
|
50
|
+
versionID, etag, err := g.uploader.CompleteMultipartUpload(bucket, object, uploadID, &in)
|
|
51
|
+
if err != nil {
|
|
52
|
+
return err
|
|
53
|
+
@@ -1031,10 +1054,12 @@
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return g.xmlEncoder(w).Encode(&CompleteMultipartUploadResult{
|
|
57
|
+
- ETag: etag,
|
|
58
|
+
- Bucket: bucket,
|
|
59
|
+
- Key: object,
|
|
60
|
+
- Location: location,
|
|
61
|
+
+ ETag: etag,
|
|
62
|
+
+ Bucket: bucket,
|
|
63
|
+
+ Key: object,
|
|
64
|
+
+ Location: location,
|
|
65
|
+
+ ChecksumSHA256: in.ChecksumSHA256,
|
|
66
|
+
+ ChecksumType: in.ChecksumType,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
71
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/uploader.go
|
|
72
|
+
@@ -3,6 +3,8 @@
|
|
73
|
+
import (
|
|
74
|
+
"bytes"
|
|
75
|
+
"crypto/md5"
|
|
76
|
+
+ "crypto/sha256"
|
|
77
|
+
+ "encoding/base64"
|
|
78
|
+
"encoding/hex"
|
|
79
|
+
"fmt"
|
|
80
|
+
"io"
|
|
81
|
+
@@ -428,13 +430,26 @@
|
|
82
|
+
return "", "", ErrInvalidPart
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
- if !input.partsAreSorted() {
|
|
86
|
+
+ if len(input.Parts) == 0 || !input.partsAreSorted() {
|
|
87
|
+
return "", "", ErrInvalidPartOrder
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var size int64
|
|
91
|
+
|
|
92
|
+
- for _, inPart := range input.Parts {
|
|
93
|
+
+ composite := sha256.New()
|
|
94
|
+
+ withSHA256 := mpu.Meta["X-Amz-Checksum-Algorithm"] == "SHA256"
|
|
95
|
+
+ if withSHA256 && input.ChecksumType != "" && input.ChecksumType != "COMPOSITE" {
|
|
96
|
+
+ return "", "", ErrBadDigest
|
|
97
|
+
+ }
|
|
98
|
+
+ previous := 0
|
|
99
|
+
+ for i, inPart := range input.Parts {
|
|
100
|
+
+ if inPart.PartNumber <= previous {
|
|
101
|
+
+ return "", "", ErrInvalidPartOrder
|
|
102
|
+
+ }
|
|
103
|
+
+ previous = inPart.PartNumber
|
|
104
|
+
+ if withSHA256 && inPart.PartNumber != i+1 {
|
|
105
|
+
+ return "", "", ErrInvalidPartOrder
|
|
106
|
+
+ }
|
|
107
|
+
if inPart.PartNumber >= mpuPartsLen || mpu.parts[inPart.PartNumber] == nil {
|
|
108
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part number %d in complete request", inPart.PartNumber)
|
|
109
|
+
}
|
|
110
|
+
@@ -444,6 +459,13 @@
|
|
111
|
+
return "", "", ErrorMessagef(ErrInvalidPart, "unexpected part etag for number %d in complete request", inPart.PartNumber)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
+ if withSHA256 {
|
|
115
|
+
+ digest := sha256.Sum256(upPart.Body)
|
|
116
|
+
+ if inPart.ChecksumSHA256 != base64.StdEncoding.EncodeToString(digest[:]) {
|
|
117
|
+
+ return "", "", ErrBadDigest
|
|
118
|
+
+ }
|
|
119
|
+
+ composite.Write(digest[:])
|
|
120
|
+
+ }
|
|
121
|
+
size += int64(len(upPart.Body))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@@ -461,7 +483,20 @@
|
|
125
|
+
|
|
126
|
+
etag = fmt.Sprintf(`"%s-%d"`, hex.EncodeToString(hash.Sum(nil)), len(input.Parts))
|
|
127
|
+
|
|
128
|
+
- result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), nil)
|
|
129
|
+
+ if withSHA256 {
|
|
130
|
+
+ checksum := fmt.Sprintf("%s-%d", base64.StdEncoding.EncodeToString(composite.Sum(nil)), len(input.Parts))
|
|
131
|
+
+ if input.ChecksumSHA256 != "" && input.ChecksumSHA256 != checksum {
|
|
132
|
+
+ return "", "", ErrBadDigest
|
|
133
|
+
+ }
|
|
134
|
+
+ input.ChecksumSHA256 = checksum
|
|
135
|
+
+ input.ChecksumType = "COMPOSITE"
|
|
136
|
+
+ mpu.Meta["X-Amz-Checksum-Sha256"] = checksum
|
|
137
|
+
+ mpu.Meta["X-Amz-Checksum-Type"] = "COMPOSITE"
|
|
138
|
+
+ } else {
|
|
139
|
+
+ input.ChecksumSHA256 = ""
|
|
140
|
+
+ input.ChecksumType = ""
|
|
141
|
+
+ }
|
|
142
|
+
+ result, err := u.storage.PutObject(bucket, object, mpu.Meta, bytes.NewReader(body), int64(len(body)), input.Conditions)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return "", "", err
|
|
145
|
+
}
|
|
146
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
147
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/messages.go
|
|
148
|
+
@@ -48,12 +48,16 @@
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type CompletedPart struct {
|
|
152
|
+
- PartNumber int `xml:"PartNumber"`
|
|
153
|
+
- ETag string `xml:"ETag"`
|
|
154
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
155
|
+
+ PartNumber int `xml:"PartNumber"`
|
|
156
|
+
+ ETag string `xml:"ETag"`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CompleteMultipartUploadRequest struct {
|
|
160
|
+
- Parts []CompletedPart `xml:"Part"`
|
|
161
|
+
+ Conditions *PutConditions `xml:"-"`
|
|
162
|
+
+ ChecksumSHA256 string `xml:"-"`
|
|
163
|
+
+ ChecksumType string `xml:"-"`
|
|
164
|
+
+ Parts []CompletedPart `xml:"Part"`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func (c CompleteMultipartUploadRequest) partsAreSorted() bool {
|
|
168
|
+
@@ -70,10 +74,12 @@
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type CompleteMultipartUploadResult struct {
|
|
172
|
+
- Location string `xml:"Location"`
|
|
173
|
+
- Bucket string `xml:"Bucket"`
|
|
174
|
+
- Key string `xml:"Key"`
|
|
175
|
+
- ETag string `xml:"ETag"`
|
|
176
|
+
+ ChecksumSHA256 string `xml:"ChecksumSHA256,omitempty"`
|
|
177
|
+
+ ChecksumType string `xml:"ChecksumType,omitempty"`
|
|
178
|
+
+ Location string `xml:"Location"`
|
|
179
|
+
+ Bucket string `xml:"Bucket"`
|
|
180
|
+
+ Key string `xml:"Key"`
|
|
181
|
+
+ ETag string `xml:"ETag"`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Content struct {
|
|
185
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
186
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/checksum.go
|
|
187
|
+
@@ -0,0 +1,32 @@
|
|
188
|
+
+package gofakes3
|
|
189
|
+
+
|
|
190
|
+
+import (
|
|
191
|
+
+ "crypto/sha256"
|
|
192
|
+
+ "encoding/base64"
|
|
193
|
+
+ "hash"
|
|
194
|
+
+ "io"
|
|
195
|
+
+)
|
|
196
|
+
+
|
|
197
|
+
+// Validate before UploadPart commits bytes to the existing multipart store.
|
|
198
|
+
+type sha256PartReader struct {
|
|
199
|
+
+ input io.Reader
|
|
200
|
+
+ digest hash.Hash
|
|
201
|
+
+ expected string
|
|
202
|
+
+}
|
|
203
|
+
+
|
|
204
|
+
+func newSHA256PartReader(input io.Reader, expected string) *sha256PartReader {
|
|
205
|
+
+ return &sha256PartReader{input: input, digest: sha256.New(), expected: expected}
|
|
206
|
+
+}
|
|
207
|
+
+
|
|
208
|
+
+func (r *sha256PartReader) sum() string {
|
|
209
|
+
+ return base64.StdEncoding.EncodeToString(r.digest.Sum(nil))
|
|
210
|
+
+}
|
|
211
|
+
+
|
|
212
|
+
+func (r *sha256PartReader) Read(p []byte) (int, error) {
|
|
213
|
+
+ n, err := r.input.Read(p)
|
|
214
|
+
+ r.digest.Write(p[:n])
|
|
215
|
+
+ if err == io.EOF && r.sum() != r.expected {
|
|
216
|
+
+ return n, ErrBadDigest
|
|
217
|
+
+ }
|
|
218
|
+
+ return n, err
|
|
219
|
+
+}
|
|
220
|
+
--- a/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
221
|
+
+++ b/vendor/github.com/johannesboyne/gofakes3/backend.go
|
|
222
|
+
@@ -3,6 +3,7 @@
|
|
223
|
+
import (
|
|
224
|
+
"encoding/hex"
|
|
225
|
+
"errors"
|
|
226
|
+
+ "strings"
|
|
227
|
+
|
|
228
|
+
"io"
|
|
229
|
+
"time"
|
|
230
|
+
@@ -434,6 +435,11 @@
|
|
231
|
+
// carry over metadata if it exists
|
|
232
|
+
if existingObj != nil {
|
|
233
|
+
for k, v := range existingObj.Metadata {
|
|
234
|
+
+ // Integrity metadata belongs to the bytes being written, never a
|
|
235
|
+
+ // previous version of this key. CopyObject supplies its own metadata.
|
|
236
|
+
+ if strings.HasPrefix(k, "X-Amz-Checksum-") {
|
|
237
|
+
+ continue
|
|
238
|
+
+ }
|
|
239
|
+
// new metadata overwrites old but keep the rest
|
|
240
|
+
// TODO: check how metadata can be deleted?!
|
|
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
|
+
+}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Exercise the exact vendored compatibility patches used by the runtime image.
|
|
3
|
+
set -eu
|
|
4
|
+
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
5
|
+
test_dir=$(mktemp -d)
|
|
6
|
+
trap 'rm -rf "$test_dir"' EXIT HUP INT TERM
|
|
7
|
+
cp "$source_dir"/*.go "$source_dir/go.mod" "$source_dir/go.sum" "$source_dir"/*.patch "$test_dir/"
|
|
8
|
+
cd "$test_dir"
|
|
9
|
+
go mod vendor
|
|
10
|
+
patch -p1 < metadata.patch
|
|
11
|
+
patch -p1 < multipart.patch
|
|
12
|
+
go test -mod=vendor -race ./...
|