s3-test-server 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Israel Alagbe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,259 @@
1
- # Temporary Holding Version
1
+ # AWS S3 Local Emulator Test Server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight Amazon S3 emulator for local development and testing.
4
+
5
+ Zero runtime dependencies, a single small package, and wire-level
6
+ compatibility with both old and new S3 clients:
7
+
8
+ - **AWS SDK for JavaScript v3**, including flexible checksums
9
+ (CRC32, CRC32C, CRC64NVME, SHA1, SHA256), `aws-chunked` streaming uploads
10
+ with trailers, and full-object / composite multipart checksums
11
+ - **AWS SDK for JavaScript v2**, SigV4 and legacy SigV2 (`signatureVersion: 's3'`)
12
+ - **Any other S3 client** that signs with Signature V2 or V4, header-based or
13
+ presigned, path-style or virtual-host-style
14
+
15
+ Data is stored on the filesystem (a temp directory by default), so it survives
16
+ restarts when you point it at a directory of your own.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ npm install --save-dev s3-test-server
22
+ ```
23
+
24
+ ```js
25
+ const S3TestServer = require('s3-test-server');
26
+
27
+ const server = new S3TestServer({
28
+ port: 4568,
29
+ configureBuckets: [{ name: 'my-bucket' }],
30
+ });
31
+
32
+ const { endpoint } = await server.run(); // http://localhost:4568
33
+ // ... run your tests ...
34
+ await server.close();
35
+ ```
36
+
37
+ Point any S3 client at it:
38
+
39
+ ```js
40
+ import { S3Client } from '@aws-sdk/client-s3';
41
+
42
+ const client = new S3Client({
43
+ endpoint: 'http://localhost:4568',
44
+ region: 'us-east-1',
45
+ credentials: { accessKeyId: 'S3TEST', secretAccessKey: 'S3TEST' },
46
+ forcePathStyle: true,
47
+ });
48
+ ```
49
+
50
+ Or from the command line:
51
+
52
+ ```bash
53
+ npx s3-test-server -d ./s3-data -p 4568 --configure-bucket my-bucket
54
+ ```
55
+
56
+ Run `s3-test-server --help` for all flags.
57
+
58
+ ## Why not just mock?
59
+
60
+ Mocking the S3 client tests your assumptions about S3, not S3 itself. Stubs
61
+ skip request signing, checksum validation, multipart ETag rules, pagination
62
+ contracts and error shapes, so code that passes mocked tests can still break
63
+ against the real service, and every SDK upgrade silently invalidates the
64
+ stubs.
65
+
66
+ Before, with a mocked client, nothing below the stub actually runs:
67
+
68
+ ```js
69
+ jest.mock('@aws-sdk/client-s3');
70
+ S3Client.prototype.send = jest.fn().mockResolvedValue({ ETag: '"abc123"' });
71
+
72
+ test('uploads a report', async () => {
73
+ await uploadReport('march.csv', csv);
74
+ expect(S3Client.prototype.send).toHaveBeenCalled(); // proves nothing about S3
75
+ });
76
+ ```
77
+
78
+ After, the same test against `s3-test-server`. Your production code runs
79
+ unmodified and every byte goes over HTTP (`aws-s3.local` is any hostname you
80
+ map to 127.0.0.1):
81
+
82
+ ```js
83
+ const server = new S3TestServer({
84
+ address: 'aws-s3.local',
85
+ configureBuckets: [{ name: 'reports' }],
86
+ });
87
+
88
+ beforeAll(() => server.run()); // http://aws-s3.local:4568
89
+ afterAll(() => server.close());
90
+
91
+ test('uploads a report and returns its URL', async () => {
92
+ const url = await uploadReport('march.csv', csv); // real code, real wire
93
+
94
+ // the full object URL is real, not invented by a stub
95
+ expect(url).toBe('http://aws-s3.local:4568/reports/march.csv');
96
+
97
+ // and it resolves: read the object back like any HTTP resource
98
+ const saved = await fetch(url);
99
+ expect(await saved.text()).toBe(csv.toString());
100
+ });
101
+ ```
102
+
103
+ If `uploadReport` signs incorrectly, streams a corrupt body or mishandles
104
+ multipart thresholds, this test fails, exactly as it would against AWS.
105
+
106
+ ## Options
107
+
108
+ All options are optional. `new S3TestServer()` starts an HTTP server on
109
+ `localhost:4568` backed by a temporary directory.
110
+
111
+ ### Server
112
+
113
+ - `address` (string, default `'localhost'`): hostname or IP to bind to.
114
+ - `port` (number, default `4568`): port to listen on. Use `0` for an
115
+ ephemeral port.
116
+ - `key`, `cert` (string | Buffer): TLS key and certificate. Provide both to
117
+ serve HTTPS.
118
+ - `silent` (boolean, default `false`): suppress log output.
119
+
120
+ ### Storage
121
+
122
+ - `directory` (string, default: a temporary directory): where buckets and
123
+ objects are stored. Data persists across restarts.
124
+ - `resetOnStart` (boolean, default `false`): clear the data directory on
125
+ every start, so a persistent `directory` begins empty.
126
+ - `resetOnClose` (boolean, default `false`): delete all bucket data when
127
+ `close()` is called.
128
+ - `configureBuckets` (array, default `[]`): buckets to create at startup,
129
+ each `{ name, configs }` with optional raw XML configs (CORS, website,
130
+ tagging).
131
+
132
+ ### Credentials and addressing
133
+
134
+ - `accessKeyId` (string, default `'S3TEST'`): access key clients must sign
135
+ with.
136
+ - `secretAccessKey` (string, default `'S3TEST'`): matching secret key.
137
+ - `region` (string, default `'us-east-1'`): region reported in responses and
138
+ event records.
139
+ - `vhostBuckets` (boolean, default `true`): virtual-host-style addressing
140
+ (`bucket.localhost`, `bucket.s3.<region>.amazonaws.com`).
141
+ - `serviceEndpoint` (string, default `'amazonaws.com'`): root domain
142
+ recognized for virtual-host-style access.
143
+ - `allowMismatchedSignatures` (boolean, default `false`): accept well-formed
144
+ but incorrect signatures.
145
+
146
+ Anonymous (unsigned) requests are accepted, as on a public bucket, which is
147
+ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
148
+ `accessKeyId`/`secretAccessKey`.
149
+
150
+ ## Supported operations
151
+
152
+ ### Service and buckets
153
+
154
+ - ListBuckets, CreateBucket, DeleteBucket, HeadBucket, GetBucketLocation
155
+ - ListObjects (v1), ListObjectsV2, ListObjectVersions (unversioned mapping)
156
+ - ListMultipartUploads
157
+ - Get/Put/DeleteBucketCors, Get/Put/DeleteBucketWebsite,
158
+ Get/Put/DeleteBucketTagging
159
+ - GetBucketVersioning, GetBucketEncryption, GetBucketNotification,
160
+ GetBucketRequestPayment, GetBucketLogging, GetBucketAccelerate
161
+ - Bucket ACLs (dummy responses)
162
+
163
+ ### Objects
164
+
165
+ - PutObject, GetObject, HeadObject, DeleteObject, DeleteObjects
166
+ - CopyObject with COPY/REPLACE directives and copy conditions
167
+ - POST form uploads: `multipart/form-data`, `${filename}` substitution,
168
+ `success_action_*`
169
+ - Get/Put/DeleteObjectTagging, GetObjectAttributes, object ACLs (dummy)
170
+ - Range requests (`206 Partial Content`)
171
+ - Conditional reads: `If-Match`, `If-None-Match`, `If-(Un)Modified-Since`
172
+ - Conditional writes: `If-None-Match: *`, `If-Match`
173
+ - `response-*` header overrides on GET
174
+ - Integrity validation: `Content-MD5` and `x-amz-checksum-*`
175
+ (CRC32, CRC32C, CRC64NVME, SHA1, SHA256), from headers or `aws-chunked`
176
+ trailers
177
+
178
+ ### Multipart uploads
179
+
180
+ - CreateMultipartUpload, UploadPart, UploadPartCopy (with
181
+ `x-amz-copy-source-range`), CompleteMultipartUpload, AbortMultipartUpload,
182
+ ListParts
183
+ - Correct multipart ETags (`md5-of-md5s-N`)
184
+ - Composite and full-object checksums
185
+
186
+ ### Static website hosting
187
+
188
+ - Index and error documents, folder redirects
189
+ - `x-amz-website-redirect-location` and `RedirectAllRequestsTo`
190
+ - Served for anonymous GET/HEAD requests on buckets with a website
191
+ configuration
192
+
193
+ ### CORS
194
+
195
+ - Preflight (`OPTIONS`) evaluation and response headers, driven by the
196
+ bucket's CORS configuration
197
+
198
+ ### Not implemented
199
+
200
+ - Bucket versioning (`PutBucketVersioning` with `Enabled` returns
201
+ `NotImplemented`), object locking, lifecycle execution, bucket policies/IAM
202
+ - Per-chunk signatures inside `aws-chunked` bodies are not re-verified (the
203
+ seed signature is)
204
+
205
+ ## Events
206
+
207
+ The server is an `EventEmitter` and emits S3-style notifications:
208
+
209
+ ```js
210
+ server.on('event', (notification) => {
211
+ const [record] = notification.Records;
212
+ console.log(record.eventName, record.s3.object.key);
213
+ });
214
+ ```
215
+
216
+ Emitted for `ObjectCreated:Put`, `ObjectCreated:Copy`, `ObjectCreated:Post`,
217
+ `ObjectCreated:CompleteMultipartUpload` and `ObjectRemoved:Delete`.
218
+
219
+ ## Using as middleware
220
+
221
+ ```js
222
+ const http = require('http');
223
+ const S3TestServer = require('s3-test-server');
224
+
225
+ const s3 = new S3TestServer({ silent: true });
226
+ http.createServer(s3.callback()).listen(4568);
227
+ // also works as an Express/Connect handler
228
+ ```
229
+
230
+ ## Programmatic API
231
+
232
+ - `run([callback])` returns `Promise<{ address, port, protocol, endpoint }>`
233
+ - `close([callback])` returns `Promise<void>`
234
+ - `callback()` / `getMiddleware()` returns a request handler
235
+ - `configureBuckets()` provisions `options.configureBuckets` without the API
236
+ - `reset()` deletes all buckets and configurations
237
+
238
+ TypeScript definitions ship with the package (`lib/index.d.ts`).
239
+
240
+ ## HTTPS
241
+
242
+ ```js
243
+ const fs = require('fs');
244
+ const server = new S3TestServer({
245
+ key: fs.readFileSync('./key.pem'),
246
+ cert: fs.readFileSync('./cert.pem'),
247
+ });
248
+ ```
249
+
250
+ With a self-signed certificate, configure your client to accept it (for the
251
+ AWS SDKs, a custom HTTPS agent with `rejectUnauthorized: false`).
252
+
253
+ ## Requirements
254
+
255
+ Node.js >= 20.
256
+
257
+ ## License
258
+
259
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../lib/cli').main();
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ // CRC tables are built lazily. Implemented locally (rather than via
6
+ // zlib.crc32) so the package runs on all Node.js 20.x releases.
7
+
8
+ function makeCrc32Table(poly) {
9
+ const table = new Uint32Array(256);
10
+ for (let n = 0; n < 256; n++) {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++) {
13
+ c = c & 1 ? (c >>> 1) ^ poly : c >>> 1;
14
+ }
15
+ table[n] = c >>> 0;
16
+ }
17
+ return table;
18
+ }
19
+
20
+ let crc32Table;
21
+ let crc32cTable;
22
+ let crc64Table;
23
+
24
+ function makeCrc64Table(poly) {
25
+ const table = new BigUint64Array(256);
26
+ for (let n = 0n; n < 256n; n++) {
27
+ let c = n;
28
+ for (let k = 0; k < 8; k++) {
29
+ c = c & 1n ? (c >> 1n) ^ poly : c >> 1n;
30
+ }
31
+ table[Number(n)] = c;
32
+ }
33
+ return table;
34
+ }
35
+
36
+ class Crc32 {
37
+ constructor(castagnoli = false) {
38
+ if (castagnoli) {
39
+ crc32cTable = crc32cTable || makeCrc32Table(0x82f63b78);
40
+ this.table = crc32cTable;
41
+ } else {
42
+ crc32Table = crc32Table || makeCrc32Table(0xedb88320);
43
+ this.table = crc32Table;
44
+ }
45
+ this.crc = 0xffffffff;
46
+ }
47
+
48
+ update(buf) {
49
+ let crc = this.crc;
50
+ const table = this.table;
51
+ for (let i = 0; i < buf.length; i++) {
52
+ crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
53
+ }
54
+ this.crc = crc >>> 0;
55
+ }
56
+
57
+ digest() {
58
+ const out = Buffer.alloc(4);
59
+ out.writeUInt32BE((this.crc ^ 0xffffffff) >>> 0);
60
+ return out;
61
+ }
62
+ }
63
+
64
+ // CRC-64/NVME: reflected polynomial of 0xAD93D23594C93659.
65
+ const CRC64_POLY = 0x9a6c9329ac4bc9b5n;
66
+ const CRC64_MASK = 0xffffffffffffffffn;
67
+
68
+ class Crc64Nvme {
69
+ constructor() {
70
+ crc64Table = crc64Table || makeCrc64Table(CRC64_POLY);
71
+ this.crc = CRC64_MASK;
72
+ }
73
+
74
+ update(buf) {
75
+ let crc = this.crc;
76
+ const table = crc64Table;
77
+ for (let i = 0; i < buf.length; i++) {
78
+ crc = table[Number((crc ^ BigInt(buf[i])) & 0xffn)] ^ (crc >> 8n);
79
+ }
80
+ this.crc = crc;
81
+ }
82
+
83
+ digest() {
84
+ const out = Buffer.alloc(8);
85
+ out.writeBigUInt64BE((this.crc ^ CRC64_MASK) & CRC64_MASK);
86
+ return out;
87
+ }
88
+ }
89
+
90
+ class NodeHash {
91
+ constructor(algorithm) {
92
+ this.hash = crypto.createHash(algorithm);
93
+ }
94
+
95
+ update(buf) {
96
+ this.hash.update(buf);
97
+ }
98
+
99
+ digest() {
100
+ return this.hash.digest();
101
+ }
102
+ }
103
+
104
+ const CHECKSUM_ALGORITHMS = ['CRC32', 'CRC32C', 'CRC64NVME', 'SHA1', 'SHA256'];
105
+
106
+ function createChecksummer(algorithm) {
107
+ switch (algorithm) {
108
+ case 'CRC32':
109
+ return new Crc32(false);
110
+ case 'CRC32C':
111
+ return new Crc32(true);
112
+ case 'CRC64NVME':
113
+ return new Crc64Nvme();
114
+ case 'SHA1':
115
+ return new NodeHash('sha1');
116
+ case 'SHA256':
117
+ return new NodeHash('sha256');
118
+ default:
119
+ throw new Error(`Unsupported checksum algorithm: ${algorithm}`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Computes MD5 + SHA256 (always) and any requested flexible checksums over a
125
+ * stream of buffers.
126
+ */
127
+ class DigestCalculator {
128
+ constructor(algorithms = []) {
129
+ this.md5 = crypto.createHash('md5');
130
+ this.sha256 = crypto.createHash('sha256');
131
+ this.size = 0;
132
+ this.checksummers = {};
133
+ for (const algorithm of algorithms) {
134
+ if (!this.checksummers[algorithm]) {
135
+ this.checksummers[algorithm] = createChecksummer(algorithm);
136
+ }
137
+ }
138
+ }
139
+
140
+ update(buf) {
141
+ this.size += buf.length;
142
+ this.md5.update(buf);
143
+ this.sha256.update(buf);
144
+ for (const checksummer of Object.values(this.checksummers)) {
145
+ checksummer.update(buf);
146
+ }
147
+ }
148
+
149
+ digest() {
150
+ const md5 = this.md5.digest();
151
+ const checksums = {};
152
+ for (const [algorithm, checksummer] of Object.entries(this.checksummers)) {
153
+ checksums[algorithm] = checksummer.digest().toString('base64');
154
+ }
155
+ return {
156
+ size: this.size,
157
+ md5Hex: md5.toString('hex'),
158
+ md5Base64: md5.toString('base64'),
159
+ sha256Hex: this.sha256.digest('hex'),
160
+ checksums,
161
+ };
162
+ }
163
+ }
164
+
165
+ function checksumOf(algorithm, buf) {
166
+ const checksummer = createChecksummer(algorithm);
167
+ checksummer.update(buf);
168
+ return checksummer.digest().toString('base64');
169
+ }
170
+
171
+ module.exports = {
172
+ CHECKSUM_ALGORITHMS,
173
+ DigestCalculator,
174
+ createChecksummer,
175
+ checksumOf,
176
+ };
package/lib/chunked.js ADDED
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const { Transform } = require('stream');
4
+
5
+ const MAX_HEADER_LENGTH = 64 * 1024;
6
+
7
+ /**
8
+ * Decodes the `aws-chunked` content encoding used by SigV4 streaming uploads:
9
+ *
10
+ * <hex-size>[;chunk-signature=...]\r\n<data>\r\n ... 0[;chunk-signature=...]\r\n
11
+ * [trailer-name:value\r\n ...]\r\n
12
+ *
13
+ * Emits the raw payload bytes and collects any trailing checksum headers in
14
+ * `this.trailers` (lowercased names), available once the stream ends.
15
+ * Chunk signatures are not themselves verified; the seed signature in the
16
+ * Authorization header has already been checked by then.
17
+ */
18
+ class AwsChunkedDecoder extends Transform {
19
+ constructor() {
20
+ super();
21
+ this.buffer = Buffer.alloc(0);
22
+ this.state = 'size';
23
+ this.remaining = 0;
24
+ this.trailers = {};
25
+ }
26
+
27
+ _transform(chunk, _encoding, callback) {
28
+ this.buffer =
29
+ this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
30
+ try {
31
+ this._consume();
32
+ callback();
33
+ } catch (err) {
34
+ callback(err);
35
+ }
36
+ }
37
+
38
+ _flush(callback) {
39
+ if (
40
+ this.state === 'data' ||
41
+ (this.state === 'size' && this.buffer.length > 0)
42
+ ) {
43
+ callback(new Error('Truncated aws-chunked payload'));
44
+ return;
45
+ }
46
+ callback();
47
+ }
48
+
49
+ _readLine() {
50
+ const idx = this.buffer.indexOf('\r\n');
51
+ if (idx === -1) {
52
+ if (this.buffer.length > MAX_HEADER_LENGTH) {
53
+ throw new Error('aws-chunked header line too long');
54
+ }
55
+ return null;
56
+ }
57
+ const line = this.buffer.subarray(0, idx).toString('utf8');
58
+ this.buffer = this.buffer.subarray(idx + 2);
59
+ return line;
60
+ }
61
+
62
+ _consume() {
63
+ for (;;) {
64
+ if (this.state === 'done') {
65
+ this.buffer = Buffer.alloc(0);
66
+ return;
67
+ }
68
+ if (this.state === 'size') {
69
+ const line = this._readLine();
70
+ if (line === null) return;
71
+ if (line === '') continue; // tolerate stray blank lines between chunks
72
+ const sizeToken = line.split(';')[0].trim();
73
+ if (!/^[0-9a-fA-F]+$/.test(sizeToken)) {
74
+ throw new Error(
75
+ `Invalid aws-chunked size line: ${JSON.stringify(line)}`
76
+ );
77
+ }
78
+ this.remaining = parseInt(sizeToken, 16);
79
+ this.state = this.remaining === 0 ? 'trailer' : 'data';
80
+ } else if (this.state === 'data') {
81
+ if (this.buffer.length === 0) return;
82
+ const take = Math.min(this.remaining, this.buffer.length);
83
+ this.push(this.buffer.subarray(0, take));
84
+ this.buffer = this.buffer.subarray(take);
85
+ this.remaining -= take;
86
+ if (this.remaining === 0) this.state = 'data-crlf';
87
+ } else if (this.state === 'data-crlf') {
88
+ if (this.buffer.length < 2) return;
89
+ // Expect \r\n after the chunk body; be lenient if it is missing.
90
+ if (this.buffer[0] === 0x0d && this.buffer[1] === 0x0a) {
91
+ this.buffer = this.buffer.subarray(2);
92
+ }
93
+ this.state = 'size';
94
+ } else if (this.state === 'trailer') {
95
+ const line = this._readLine();
96
+ if (line === null) {
97
+ // No trailing CRLF after the zero chunk is also accepted.
98
+ if (this.buffer.length === 0) this.state = 'done';
99
+ return;
100
+ }
101
+ if (line === '') {
102
+ this.state = 'done';
103
+ continue;
104
+ }
105
+ const idx = line.indexOf(':');
106
+ if (idx > 0) {
107
+ const name = line.slice(0, idx).trim().toLowerCase();
108
+ const value = line.slice(idx + 1).trim();
109
+ if (name !== 'x-amz-trailer-signature') {
110
+ this.trailers[name] = value;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ module.exports = { AwsChunkedDecoder };