@specific.dev/spectest 0.129.0 → 0.130.0

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.
@@ -0,0 +1,33 @@
1
+ # Native S3 component runtime
2
+
3
+ GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
4
+ multi-stage Dockerfile, builds for the worker's native architecture, and ships
5
+ only the static binary and dependency licenses. The Go builder is pinned to a
6
+ multi-architecture digest. No project files or options enter the image build.
7
+
8
+ The entrypoint retains the component's port 9090, path-style addressing, no
9
+ automatic buckets, no signature/date validation, and permissive browser CORS.
10
+ The memory backend supports pagination and versioning. Full VM snapshots clone
11
+ its buckets, versions and pending multipart uploads; restarting the process
12
+ starts empty, matching S3Mock's default temporary store.
13
+
14
+ `metadata.patch` preserves Cache-Control, Content-Language and Expires on object
15
+ uploads, multipart initiation and copies. Upstream currently drops these headers
16
+ in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
17
+ a failed patch stops the build. Recheck it when changing the upstream pin.
18
+
19
+ From `sdk`, run the real-image contract on a Linux Docker host:
20
+
21
+ ```
22
+ SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
23
+ SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
24
+ bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
25
+ bun run build
26
+ ```
27
+
28
+ The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
29
+ credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
30
+ metadata, copy/range requests, multipart, paginated listing, versioning, and
31
+ fresh-process lifecycle. It does not prove every S3 API is interchangeable:
32
+ GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
33
+ Java/Spring/S3Mock-specific environment settings are no longer interpreted.
@@ -0,0 +1,11 @@
1
+ module spectest-s3
2
+
3
+ go 1.24
4
+
5
+ require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
6
+
7
+ require (
8
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
9
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
10
+ golang.org/x/tools v0.8.0 // indirect
11
+ )
@@ -0,0 +1,54 @@
1
+ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
2
+ github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
3
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
4
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
5
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
6
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
7
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
8
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
9
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
10
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
11
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
12
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
13
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
14
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
15
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
16
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
17
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
18
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
19
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
20
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
21
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
22
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
23
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
24
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
25
+ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
26
+ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
27
+ github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
28
+ github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
29
+ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
30
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
32
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
33
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
36
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
37
+ github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
38
+ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
39
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
40
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
41
+ go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
42
+ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
43
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
44
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
45
+ golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
46
+ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
47
+ golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
48
+ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
49
+ golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
50
+ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
51
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
52
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
53
+ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
54
+ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -0,0 +1,77 @@
1
+ // The component's native entrypoint. Keep its defaults compatible with S3Mock;
2
+ // the upstream CLI enforces clock skew and has different browser CORS defaults.
3
+ package main
4
+
5
+ import (
6
+ "log"
7
+ "net/http"
8
+
9
+ "github.com/johannesboyne/gofakes3"
10
+ "github.com/johannesboyne/gofakes3/backend/s3mem"
11
+ )
12
+
13
+ func main() {
14
+ // VM snapshots include RAM: buckets, versions and in-flight multipart state
15
+ // fork together. A fresh process starts empty, as S3Mock did by default.
16
+ // Unlike the Bolt backend, s3mem also supports bucket versioning.
17
+ backend := s3mem.New()
18
+ server := gofakes3.New(backend,
19
+ gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
20
+ gofakes3.WithHostBucket(false),
21
+ gofakes3.WithAutoBucket(false),
22
+ gofakes3.WithLogger(gofakes3.GlobalLog()),
23
+ ).Server()
24
+ log.Fatal(http.ListenAndServe(":9090", cors(server)))
25
+ }
26
+
27
+ // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
28
+ // headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
29
+ // only exposes ETag on preflights, hiding it from actual browser responses.
30
+ func cors(next http.Handler) http.Handler {
31
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ origin := r.Header.Get("Origin")
33
+ w.Header().Add("Vary", "Origin")
34
+ w.Header().Add("Vary", "Access-Control-Request-Method")
35
+ w.Header().Add("Vary", "Access-Control-Request-Headers")
36
+ if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
37
+ w.Header().Set("Access-Control-Allow-Origin", origin)
38
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
39
+ w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
40
+ w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
41
+ w.WriteHeader(http.StatusOK)
42
+ return
43
+ }
44
+ writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
45
+ next.ServeHTTP(writer, r)
46
+ if !writer.written {
47
+ writer.WriteHeader(http.StatusOK)
48
+ }
49
+ })
50
+ }
51
+
52
+ type corsWriter struct {
53
+ http.ResponseWriter
54
+ hasOrigin bool
55
+ written bool
56
+ }
57
+
58
+ func (w *corsWriter) WriteHeader(status int) {
59
+ if w.written {
60
+ return
61
+ }
62
+ w.written = true
63
+ if w.hasOrigin {
64
+ w.Header().Set("Access-Control-Allow-Origin", "*")
65
+ w.Header().Set("Access-Control-Expose-Headers", "*")
66
+ } else {
67
+ w.Header().Del("Access-Control-Allow-Origin")
68
+ }
69
+ w.ResponseWriter.WriteHeader(status)
70
+ }
71
+
72
+ func (w *corsWriter) Write(body []byte) (int, error) {
73
+ if !w.written {
74
+ w.WriteHeader(http.StatusOK)
75
+ }
76
+ return w.ResponseWriter.Write(body)
77
+ }
@@ -0,0 +1,12 @@
1
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
2
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
3
+ @@ -1191,6 +1191,9 @@
4
+ for hk, hv := range headers {
5
+ if strings.HasPrefix(hk, "X-Amz-") ||
6
+ hk == "Content-Type" ||
7
+ + hk == "Cache-Control" ||
8
+ + hk == "Content-Language" ||
9
+ + hk == "Expires" ||
10
+ hk == "Content-Disposition" ||
11
+ hk == "Content-Encoding" {
12
+ meta[hk] = hv[0]
@@ -1,8 +1,7 @@
1
1
  import { type S3ClientLike } from "../s3.js";
2
2
  export interface S3Options {
3
3
  /**
4
- * Buckets to create on first boot. The backing server's own `initialBuckets`
5
- * env is unreliable across versions, so the component creates them in `setup`
4
+ * Buckets to create on first boot. The component creates them in `setup`
6
5
  * (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
7
6
  * re-run on warm starts/forks.
8
7
  */
@@ -42,7 +41,7 @@ export interface S3Helpers {
42
41
  }
43
42
  /**
44
43
  * A ready-to-use, S3-compatible object store (backed by
45
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
44
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
46
45
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
47
46
  *
48
47
  * ```ts
@@ -90,8 +89,8 @@ export declare function s3(opts?: S3Options): {
90
89
  }[] | undefined;
91
90
  env?: Record<string, string> | undefined;
92
91
  image: {
93
- type: "registry";
94
- reference: string;
92
+ type: "dockerfile";
93
+ content: string;
95
94
  };
96
95
  ports: number[];
97
96
  };
@@ -1,21 +1,31 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  import { S3Client } from "../s3.js";
3
- // Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
4
- // is the current release; avoid exactly 5.0.0, which crashed at startup on
5
- // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
6
- const S3_IMAGE = "adobe/s3mock:5.1.0";
7
- // Port the store listens on, fixed like the image and deliberately NOT an
8
- // option. The backing server's HTTP port is baked into the image, and nothing
9
- // the component returns can move it — so a `port` option could only relabel
10
- // the ready check, the declared ports and the client endpoint while the
11
- // container kept listening here. That is exactly what it did until 0.78.0: a
12
- // project that passed one got 60 s of refused probes and an environment that
13
- // never booted, with a healthy container beside it. A service reaches the
14
- // store at `http://<key>:9090`.
4
+ // Build once per architecture/cache from pinned source: upstream's Docker Hub
5
+ // image is amd64-only. Only the static native server ships in the runtime image.
6
+ // Keep the Dockerfile independent of options so stores share the build cache.
7
+ const S3_DOCKERFILE = `
8
+ FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
9
+ RUN apk add --no-cache patch
10
+ WORKDIR /src
11
+ ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
12
+ const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
13
+ return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
14
+ }).join("\n")}
15
+ RUN go mod vendor && patch -p1 < metadata.patch
16
+ RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
17
+ FROM scratch
18
+ COPY --from=build /s3-server /s3-server
19
+ COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
20
+ COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
21
+ EXPOSE 9090
22
+ ENTRYPOINT ["/s3-server"]
23
+ `;
24
+ // Public endpoint stays fixed across backing-server changes.
15
25
  const S3_PORT = 9090;
16
26
  /**
17
27
  * A ready-to-use, S3-compatible object store (backed by
18
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
28
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
19
29
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
20
30
  *
21
31
  * ```ts
@@ -51,7 +61,7 @@ export function s3(opts = {}) {
51
61
  const secretAccessKey = opts.secretAccessKey ?? "s3";
52
62
  const region = opts.region ?? "us-east-1";
53
63
  return {
54
- image: { type: "registry", reference: S3_IMAGE },
64
+ image: { type: "dockerfile", content: S3_DOCKERFILE },
55
65
  ports: [S3_PORT],
56
66
  ...(opts.env ? { env: opts.env } : {}),
57
67
  ...(opts.hosts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.129.0",
3
+ "version": "0.130.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,159 @@
1
+ // Linux opt-in: creates and removes its own containers/network/image.
2
+ // SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
3
+ // Add SPECTEST_TEST_S3_BASELINE=1 to run the same contract against S3Mock 5.1.0.
4
+ import { expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { s3 } from "./s3.js";
9
+ import { runContainerArgs } from "../harness/container-run.js";
10
+ import { startRecording, stopRecording } from "../recorder.js";
11
+
12
+ async function run(args: string[]) {
13
+ const child = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
14
+ const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
15
+ return { exit, stdout, stderr };
16
+ }
17
+ async function checked(args: string[]) {
18
+ const result = await run(args);
19
+ if (result.exit) throw new Error(`${args.join(" ")}: ${result.stderr}\n${result.stdout}`);
20
+ return result.stdout.trim();
21
+ }
22
+ async function ready(endpoint: string) {
23
+ const start = performance.now();
24
+ while (performance.now() - start < 60_000) {
25
+ try { if ((await fetch(endpoint, { signal: AbortSignal.timeout(500) })).ok) return performance.now() - start; } catch {}
26
+ await Bun.sleep(20);
27
+ }
28
+ throw new Error(`S3 not ready: ${endpoint}`);
29
+ }
30
+
31
+ const enabled = process.env.SPECTEST_TEST_S3_DOCKER === "1";
32
+ const baseline = process.env.SPECTEST_TEST_S3_BASELINE === "1";
33
+ test.skipIf(!enabled)("S3 preserves the public component and object-storage contract", async () => {
34
+ const root = mkdtempSync(join(tmpdir(), "spectest-s3-"));
35
+ const name = `spectest-s3-${process.pid}-${Date.now()}`;
36
+ const tag = baseline ? "adobe/s3mock:5.1.0" : name;
37
+ const definition = s3({ buckets: ["uploads", "assets"], hosts: ["files.test"], env: { CUSTOM_S3_ENV: "forwarded" } });
38
+ let proxy: ReturnType<typeof Bun.serve> | undefined;
39
+ try {
40
+ await checked(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost", "-keyout", `${root}/key.pem`, "-out", `${root}/ca.pem`]);
41
+ if (baseline) await checked(["docker", "pull", tag]);
42
+ else {
43
+ writeFileSync(`${root}/Dockerfile`, definition.image.content);
44
+ await checked(["docker", "build", "-t", tag, root]);
45
+ }
46
+ await checked(["docker", "network", "create", name]);
47
+ // Exercise the real harness command, including its CA mounts, on scratch.
48
+ await checked(["docker", ...runContainerArgs({ svc: { ...definition, name }, tag, network: name, hostCacheName: "spectest-host", caPath: `${root}/ca.pem`, caBundle: `${root}/ca.pem` })]);
49
+ const ip = await checked(["docker", "inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", name]);
50
+ const endpoint = `http://${ip}:9090`;
51
+ console.log(`${baseline ? "S3Mock" : "GoFakeS3"} ready after ${Math.round(await ready(endpoint))} ms of probing`);
52
+ const info = JSON.parse(await checked(["docker", "inspect", name]))[0];
53
+ expect(info.Config.Env).toContain("CUSTOM_S3_ENV=forwarded");
54
+ // No implicit bucket, even when the helper has a default bucket configured.
55
+ expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
56
+ expect((await fetch(`${endpoint}/missing/key`, { method: "PUT", body: "x" })).status).toBe(404);
57
+ await definition.setup!({ name: ip });
58
+ await definition.setup!({ name: ip }); // idempotent CreateBucket
59
+
60
+ const client = definition.helpers({ name: ip }).client;
61
+ startRecording();
62
+ await client.write("hello.txt", "hello world", { type: "text/plain" });
63
+ expect((await client.file("hello.txt").text()).unwrap()).toBe("hello world");
64
+ const events = stopRecording();
65
+ expect(events.filter(event => event.kind === "s3").map(event => event.op)).toEqual(["write", "read"]);
66
+ expect(events.some(event => event.error)).toBe(false);
67
+ expect((await client.stat("hello.txt")).unwrap().size).toBe(11);
68
+ const other = s3({ bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1" }).helpers({ name: ip }).client;
69
+ await other.write("custom.txt", "custom credentials");
70
+ expect((await other.file("custom.txt").text()).unwrap()).toBe("custom credentials");
71
+ await client.write("override.txt", "per-call bucket", { bucket: "assets" });
72
+ expect((await other.file("override.txt").text()).unwrap()).toBe("per-call bucket");
73
+
74
+ // Host-preserving TLS termination: the store must remain path-style.
75
+ proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, tls: { key: Bun.file(`${root}/key.pem`), cert: Bun.file(`${root}/ca.pem`) }, async fetch(request) {
76
+ const url = new URL(request.url);
77
+ return fetch(endpoint + url.pathname + url.search, { method: request.method, headers: request.headers, body: request.body, redirect: "manual" });
78
+ } });
79
+ for (const tls of [false, true]) {
80
+ const transport = (url: string) => tls ? url.replace(endpoint, `https://localhost:${proxy!.port}`) : url;
81
+ const options = { headers: { Host: "files.test", Origin: "https://app.test" }, ...(tls ? { tls: { ca: await Bun.file(`${root}/ca.pem`).text() } } : {}) };
82
+ const put = await fetch(transport(client.presign("presigned.txt", { method: "PUT" })), { ...options, method: "PUT", body: "presigned content" });
83
+ expect(put.status).toBe(200);
84
+ expect(put.headers.get("access-control-expose-headers")).toBe("*");
85
+ expect(await (await fetch(transport(client.presign("presigned.txt")), options)).text()).toBe("presigned content");
86
+ const preflight = await fetch(transport(`${endpoint}/uploads/presigned.txt`), { ...options, method: "OPTIONS", headers: { ...options.headers, "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "content-type,x-amz-meta-custom" } });
87
+ expect(preflight.status).toBe(200);
88
+ expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.test");
89
+ expect(preflight.headers.get("access-control-allow-headers")).toContain("x-amz-meta-custom");
90
+ expect(preflight.headers.get("access-control-allow-credentials")).toBe("true");
91
+ }
92
+ // Arbitrary signatures/dates must not turn into auth failures after restore.
93
+ const stale = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { "x-amz-date": "20000101T000000Z", Authorization: "AWS4-HMAC-SHA256 invalid" } });
94
+ expect(stale.status).toBe(200);
95
+ const range = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { Range: "bytes=1-4" } });
96
+ expect(range.status).toBe(206);
97
+ expect(await range.text()).toBe("ello");
98
+ const metadata = { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=hello.txt", "Cache-Control": "max-age=600", "Content-Language": "en", "Expires": "Wed, 28 Oct 2026 00:00:00 GMT", "x-amz-meta-custom": "retained" };
99
+ expect((await fetch(`${endpoint}/uploads/metadata.txt`, { method: "PUT", headers: metadata, body: "metadata" })).status).toBe(200);
100
+ const head = await fetch(`${endpoint}/uploads/metadata.txt`, { method: "HEAD" });
101
+ for (const [key, value] of Object.entries(metadata)) expect(head.headers.get(key)).toBe(value);
102
+ expect((await fetch(`${endpoint}/uploads/copied.txt`, { method: "PUT", headers: { "x-amz-copy-source": "/uploads/metadata.txt" } })).status).toBe(200);
103
+ expect((await client.file("copied.txt").text()).unwrap()).toBe("metadata");
104
+
105
+ // Exercise both Bun's multipart writer and explicit multi-part S3 requests.
106
+ const bytes = new Uint8Array(12 * 1024 * 1024).fill(65);
107
+ await client.write("large.bin", bytes);
108
+ expect((await client.file("large.bin").arrayBuffer()).unwrap()).toEqual(bytes.buffer);
109
+ const start = await fetch(`${endpoint}/uploads/manual.bin?uploads`, { method: "POST" });
110
+ expect(start.status).toBe(200);
111
+ const id = (await start.text()).match(/<UploadId>([^<]+)/)![1]!;
112
+ const etags: string[] = [];
113
+ for (const [i, size] of [[1, 5 * 1024 * 1024], [2, 1234]]) {
114
+ const part = await fetch(`${endpoint}/uploads/manual.bin?partNumber=${i}&uploadId=${encodeURIComponent(id)}`, { method: "PUT", body: new Uint8Array(size).fill(i) });
115
+ expect(part.status).toBe(200);
116
+ etags.push(part.headers.get("etag")!);
117
+ }
118
+ const complete = await fetch(`${endpoint}/uploads/manual.bin?uploadId=${encodeURIComponent(id)}`, { method: "POST", body: `<CompleteMultipartUpload>${etags.map((etag, i) => `<Part><PartNumber>${i + 1}</PartNumber><ETag>${etag}</ETag></Part>`).join("")}</CompleteMultipartUpload>` });
119
+ expect(complete.status).toBe(200);
120
+ expect((await client.stat("manual.bin")).unwrap().size).toBe(5 * 1024 * 1024 + 1234);
121
+ await client.write("pages/one", "one");
122
+ await client.write("pages/two", "two");
123
+ const page = (await client.list({ prefix: "pages/", maxKeys: 1 })).unwrap();
124
+ expect(page.contents?.map(item => item.key)).toEqual(["pages/one"]);
125
+ expect(page.isTruncated).toBe(true);
126
+ const next = (await client.list({ prefix: "pages/", maxKeys: 1, continuationToken: page.nextContinuationToken })).unwrap();
127
+ expect(next.contents?.map(item => item.key)).toEqual(["pages/two"]);
128
+ await client.delete("pages/one");
129
+ expect((await client.exists("pages/one")).unwrap()).toBe(false);
130
+
131
+ // Versioning stays opt-in and old versions remain readable.
132
+ expect((await fetch(`${endpoint}/assets?versioning`, { method: "PUT", body: '<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>' })).status).toBe(200);
133
+ const first = await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "first" });
134
+ const version = first.headers.get("x-amz-version-id");
135
+ expect(version).toBeTruthy();
136
+ expect((await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "second" })).status).toBe(200);
137
+ expect(await (await fetch(`${endpoint}/assets/versioned.txt?versionId=${encodeURIComponent(version!)}`)).text()).toBe("first");
138
+ expect((await other.file("versioned.txt").text()).unwrap()).toBe("second");
139
+
140
+ await checked(["docker", "restart", name]);
141
+ await ready(endpoint);
142
+ // A process restart is different from a VM restore: the original S3Mock
143
+ // chose a fresh temporary store on every process start.
144
+ expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
145
+ await definition.setup!({ name: ip });
146
+ await client.write("after-restart.txt", "fresh store");
147
+ expect((await client.file("after-restart.txt").text()).unwrap()).toBe("fresh store");
148
+ } catch (error) {
149
+ console.error(await run(["docker", "logs", "--tail", "40", name]));
150
+ throw error;
151
+ } finally {
152
+ stopRecording();
153
+ proxy?.stop(true);
154
+ await run(["docker", "rm", "-f", "-v", name]);
155
+ await run(["docker", "network", "rm", name]);
156
+ if (!baseline) await run(["docker", "image", "rm", tag]);
157
+ rmSync(root, { recursive: true, force: true });
158
+ }
159
+ }, 600_000);
@@ -0,0 +1,33 @@
1
+ # Native S3 component runtime
2
+
3
+ GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
4
+ multi-stage Dockerfile, builds for the worker's native architecture, and ships
5
+ only the static binary and dependency licenses. The Go builder is pinned to a
6
+ multi-architecture digest. No project files or options enter the image build.
7
+
8
+ The entrypoint retains the component's port 9090, path-style addressing, no
9
+ automatic buckets, no signature/date validation, and permissive browser CORS.
10
+ The memory backend supports pagination and versioning. Full VM snapshots clone
11
+ its buckets, versions and pending multipart uploads; restarting the process
12
+ starts empty, matching S3Mock's default temporary store.
13
+
14
+ `metadata.patch` preserves Cache-Control, Content-Language and Expires on object
15
+ uploads, multipart initiation and copies. Upstream currently drops these headers
16
+ in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
17
+ a failed patch stops the build. Recheck it when changing the upstream pin.
18
+
19
+ From `sdk`, run the real-image contract on a Linux Docker host:
20
+
21
+ ```
22
+ SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
23
+ SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
24
+ bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
25
+ bun run build
26
+ ```
27
+
28
+ The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
29
+ credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
30
+ metadata, copy/range requests, multipart, paginated listing, versioning, and
31
+ fresh-process lifecycle. It does not prove every S3 API is interchangeable:
32
+ GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
33
+ Java/Spring/S3Mock-specific environment settings are no longer interpreted.
@@ -0,0 +1,11 @@
1
+ module spectest-s3
2
+
3
+ go 1.24
4
+
5
+ require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
6
+
7
+ require (
8
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
9
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
10
+ golang.org/x/tools v0.8.0 // indirect
11
+ )
@@ -0,0 +1,54 @@
1
+ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
2
+ github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
3
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
4
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
5
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
6
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
7
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
8
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
9
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
10
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
11
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
12
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
13
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
14
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
15
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
16
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
17
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
18
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
19
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
20
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
21
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
22
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
23
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
24
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
25
+ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
26
+ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
27
+ github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
28
+ github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
29
+ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
30
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
32
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
33
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
36
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
37
+ github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
38
+ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
39
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
40
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
41
+ go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
42
+ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
43
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
44
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
45
+ golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
46
+ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
47
+ golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
48
+ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
49
+ golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
50
+ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
51
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
52
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
53
+ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
54
+ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -0,0 +1,77 @@
1
+ // The component's native entrypoint. Keep its defaults compatible with S3Mock;
2
+ // the upstream CLI enforces clock skew and has different browser CORS defaults.
3
+ package main
4
+
5
+ import (
6
+ "log"
7
+ "net/http"
8
+
9
+ "github.com/johannesboyne/gofakes3"
10
+ "github.com/johannesboyne/gofakes3/backend/s3mem"
11
+ )
12
+
13
+ func main() {
14
+ // VM snapshots include RAM: buckets, versions and in-flight multipart state
15
+ // fork together. A fresh process starts empty, as S3Mock did by default.
16
+ // Unlike the Bolt backend, s3mem also supports bucket versioning.
17
+ backend := s3mem.New()
18
+ server := gofakes3.New(backend,
19
+ gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
20
+ gofakes3.WithHostBucket(false),
21
+ gofakes3.WithAutoBucket(false),
22
+ gofakes3.WithLogger(gofakes3.GlobalLog()),
23
+ ).Server()
24
+ log.Fatal(http.ListenAndServe(":9090", cors(server)))
25
+ }
26
+
27
+ // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
28
+ // headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
29
+ // only exposes ETag on preflights, hiding it from actual browser responses.
30
+ func cors(next http.Handler) http.Handler {
31
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ origin := r.Header.Get("Origin")
33
+ w.Header().Add("Vary", "Origin")
34
+ w.Header().Add("Vary", "Access-Control-Request-Method")
35
+ w.Header().Add("Vary", "Access-Control-Request-Headers")
36
+ if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
37
+ w.Header().Set("Access-Control-Allow-Origin", origin)
38
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
39
+ w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
40
+ w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
41
+ w.WriteHeader(http.StatusOK)
42
+ return
43
+ }
44
+ writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
45
+ next.ServeHTTP(writer, r)
46
+ if !writer.written {
47
+ writer.WriteHeader(http.StatusOK)
48
+ }
49
+ })
50
+ }
51
+
52
+ type corsWriter struct {
53
+ http.ResponseWriter
54
+ hasOrigin bool
55
+ written bool
56
+ }
57
+
58
+ func (w *corsWriter) WriteHeader(status int) {
59
+ if w.written {
60
+ return
61
+ }
62
+ w.written = true
63
+ if w.hasOrigin {
64
+ w.Header().Set("Access-Control-Allow-Origin", "*")
65
+ w.Header().Set("Access-Control-Expose-Headers", "*")
66
+ } else {
67
+ w.Header().Del("Access-Control-Allow-Origin")
68
+ }
69
+ w.ResponseWriter.WriteHeader(status)
70
+ }
71
+
72
+ func (w *corsWriter) Write(body []byte) (int, error) {
73
+ if !w.written {
74
+ w.WriteHeader(http.StatusOK)
75
+ }
76
+ return w.ResponseWriter.Write(body)
77
+ }
@@ -0,0 +1,12 @@
1
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
2
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
3
+ @@ -1191,6 +1191,9 @@
4
+ for hk, hv := range headers {
5
+ if strings.HasPrefix(hk, "X-Amz-") ||
6
+ hk == "Content-Type" ||
7
+ + hk == "Cache-Control" ||
8
+ + hk == "Content-Language" ||
9
+ + hk == "Expires" ||
10
+ hk == "Content-Disposition" ||
11
+ hk == "Content-Encoding" {
12
+ meta[hk] = hv[0]
@@ -0,0 +1,36 @@
1
+ import { expect, test } from "bun:test";
2
+ import { s3 } from "./s3.js";
3
+
4
+ function presign(options: Parameters<typeof s3>[0], bucket?: string) {
5
+ return new URL(s3(options).helpers({ name: "storage" }).client.presign("hello.txt", bucket ? { bucket } : {}));
6
+ }
7
+
8
+ test("S3 keeps its endpoint, readiness, credential and region defaults", () => {
9
+ const service = s3();
10
+ expect(service.ports).toEqual([9090]);
11
+ expect(service.readyCheck).toEqual({ type: "http", port: 9090, path: "/", timeoutSecs: 60 });
12
+ expect(service.setup).toBeUndefined();
13
+ expect(service.env).toBeUndefined();
14
+ expect(service.tls).toBeUndefined();
15
+ const url = presign({}, "uploads");
16
+ expect(url.origin).toBe("http://storage:9090");
17
+ expect(url.pathname).toBe("/uploads/hello.txt");
18
+ expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^s3\/\d+\/us-east-1\/s3\/aws4_request$/);
19
+ });
20
+
21
+ test("S3 keeps bucket precedence, caller credentials, region, env and TLS routes", () => {
22
+ const options = { buckets: ["uploads", "assets"], bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1", hosts: ["files.test", "storage.test"], env: { CUSTOM: "value" } };
23
+ expect(presign({ buckets: options.buckets }).pathname).toBe("/uploads/hello.txt");
24
+ const url = presign(options);
25
+ expect(url.pathname).toBe("/assets/hello.txt");
26
+ expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^custom\/\d+\/eu-west-1\/s3\/aws4_request$/);
27
+ expect(presign(options, "override").pathname).toBe("/override/hello.txt");
28
+ expect(presign({ ...options, secretAccessKey: "different" }).searchParams.get("X-Amz-Signature")).not.toBe(url.searchParams.get("X-Amz-Signature"));
29
+ expect(s3(options).env).toEqual(options.env);
30
+ expect(s3(options).tls).toEqual(options.hosts.map(hostname => ({ hostname, port: 9090 })));
31
+ // Selecting a helper default has never implicitly created that bucket.
32
+ expect(s3({ bucket: "not-created" }).setup).toBeUndefined();
33
+ expect(s3({ buckets: [] }).setup).toBeUndefined();
34
+ // Options must not create per-project copies of the same native image.
35
+ expect(s3(options).image).toEqual(s3().image);
36
+ });
@@ -1,11 +1,11 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  import type { ServiceDefinition } from "../index.js";
3
4
  import { S3Client, type S3ClientLike } from "../s3.js";
4
5
 
5
6
  export interface S3Options {
6
7
  /**
7
- * Buckets to create on first boot. The backing server's own `initialBuckets`
8
- * env is unreliable across versions, so the component creates them in `setup`
8
+ * Buckets to create on first boot. The component creates them in `setup`
9
9
  * (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
10
10
  * re-run on warm starts/forks.
11
11
  */
@@ -45,24 +45,33 @@ export interface S3Helpers {
45
45
  client: S3ClientLike;
46
46
  }
47
47
 
48
- // Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
49
- // is the current release; avoid exactly 5.0.0, which crashed at startup on
50
- // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
51
- const S3_IMAGE = "adobe/s3mock:5.1.0";
48
+ // Build once per architecture/cache from pinned source: upstream's Docker Hub
49
+ // image is amd64-only. Only the static native server ships in the runtime image.
50
+ // Keep the Dockerfile independent of options so stores share the build cache.
51
+ const S3_DOCKERFILE = `
52
+ FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
53
+ RUN apk add --no-cache patch
54
+ WORKDIR /src
55
+ ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
56
+ const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
57
+ return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
58
+ }).join("\n")}
59
+ RUN go mod vendor && patch -p1 < metadata.patch
60
+ RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
61
+ FROM scratch
62
+ COPY --from=build /s3-server /s3-server
63
+ COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
64
+ COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
65
+ EXPOSE 9090
66
+ ENTRYPOINT ["/s3-server"]
67
+ `;
52
68
 
53
- // Port the store listens on, fixed like the image and deliberately NOT an
54
- // option. The backing server's HTTP port is baked into the image, and nothing
55
- // the component returns can move it — so a `port` option could only relabel
56
- // the ready check, the declared ports and the client endpoint while the
57
- // container kept listening here. That is exactly what it did until 0.78.0: a
58
- // project that passed one got 60 s of refused probes and an environment that
59
- // never booted, with a healthy container beside it. A service reaches the
60
- // store at `http://<key>:9090`.
69
+ // Public endpoint stays fixed across backing-server changes.
61
70
  const S3_PORT = 9090;
62
71
 
63
72
  /**
64
73
  * A ready-to-use, S3-compatible object store (backed by
65
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
74
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
66
75
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
67
76
  *
68
77
  * ```ts
@@ -99,7 +108,7 @@ export function s3(opts: S3Options = {}) {
99
108
  const region = opts.region ?? "us-east-1";
100
109
 
101
110
  return {
102
- image: { type: "registry", reference: S3_IMAGE },
111
+ image: { type: "dockerfile", content: S3_DOCKERFILE },
103
112
  ports: [S3_PORT],
104
113
  ...(opts.env ? { env: opts.env } : {}),
105
114
  ...(opts.hosts