@specific.dev/spectest 0.131.6 → 0.131.7

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.
@@ -16,6 +16,19 @@ 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.
26
+
27
+ Run `./test.sh` from this directory to exercise real HTTP requests against the
28
+ same patched sources used in the image, including corrupt retries, conditional
29
+ completion races, abort, metadata reconciliation and unchecksummed uploads.
30
+ The test runs in a temporary vendor directory and enables Go's race detector.
31
+
19
32
  From `sdk`, run the real-image contract on a Linux Docker host:
20
33
 
21
34
  ```
@@ -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
- log.Fatal(http.ListenAndServe(":9090", cors(server)))
28
+ return cors(server)
25
29
  }
26
30
 
27
31
  // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
@@ -0,0 +1,199 @@
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
+ }
@@ -0,0 +1,241 @@
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 {
@@ -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 ./...
@@ -8,11 +8,11 @@ const S3_DOCKERFILE = `
8
8
  FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
9
9
  RUN apk add --no-cache patch
10
10
  WORKDIR /src
11
- ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
11
+ ${["go.mod", "go.sum", "main.go", "metadata.patch", "multipart.patch"].map((file) => {
12
12
  const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
13
13
  return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
14
14
  }).join("\n")}
15
- RUN go mod vendor && patch -p1 < metadata.patch
15
+ RUN go mod vendor && patch -p1 < metadata.patch && patch -p1 < multipart.patch
16
16
  RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
17
17
  FROM scratch
18
18
  COPY --from=build /s3-server /s3-server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.6",
3
+ "version": "0.131.7",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,19 @@ 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.
26
+
27
+ Run `./test.sh` from this directory to exercise real HTTP requests against the
28
+ same patched sources used in the image, including corrupt retries, conditional
29
+ completion races, abort, metadata reconciliation and unchecksummed uploads.
30
+ The test runs in a temporary vendor directory and enables Go's race detector.
31
+
19
32
  From `sdk`, run the real-image contract on a Linux Docker host:
20
33
 
21
34
  ```
@@ -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
- log.Fatal(http.ListenAndServe(":9090", cors(server)))
28
+ return cors(server)
25
29
  }
26
30
 
27
31
  // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
@@ -0,0 +1,199 @@
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
+ }
@@ -0,0 +1,241 @@
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 {
@@ -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 ./...
@@ -52,11 +52,11 @@ const S3_DOCKERFILE = `
52
52
  FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
53
53
  RUN apk add --no-cache patch
54
54
  WORKDIR /src
55
- ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
55
+ ${["go.mod", "go.sum", "main.go", "metadata.patch", "multipart.patch"].map((file) => {
56
56
  const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
57
57
  return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
58
58
  }).join("\n")}
59
- RUN go mod vendor && patch -p1 < metadata.patch
59
+ RUN go mod vendor && patch -p1 < metadata.patch && patch -p1 < multipart.patch
60
60
  RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
61
61
  FROM scratch
62
62
  COPY --from=build /s3-server /s3-server