@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.
@@ -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.8",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- 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,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
+ }