s3-test-server 0.0.0-stage → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +231 -2
- package/bin/s3-test-server.js +4 -0
- package/lib/checksums.js +176 -0
- package/lib/chunked.js +118 -0
- package/lib/cli.js +142 -0
- package/lib/controllers/bucket.js +624 -0
- package/lib/controllers/object.js +1216 -0
- package/lib/controllers/post.js +150 -0
- package/lib/controllers/service.js +19 -0
- package/lib/index.d.ts +193 -0
- package/lib/index.js +241 -0
- package/lib/middleware/cors.js +107 -0
- package/lib/middleware/error-handler.js +31 -0
- package/lib/middleware/vhost.js +61 -0
- package/lib/middleware/website.js +108 -0
- package/lib/models/error.js +79 -0
- package/lib/models/response.js +157 -0
- package/lib/routes.js +155 -0
- package/lib/signature/common.js +28 -0
- package/lib/signature/index.js +49 -0
- package/lib/signature/v2.js +129 -0
- package/lib/signature/v4.js +227 -0
- package/lib/stores/filesystem.js +391 -0
- package/lib/utils.js +115 -0
- package/lib/xml.js +162 -0
- package/package.json +46 -4
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,232 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AWS S3 Local Emulator Test Server
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
| Option | Type | Default | Description |
|
|
114
|
+
| -------------- | ------------------ | ------------- | ------------------------------------------------- |
|
|
115
|
+
| `address` | `string` | `'localhost'` | Hostname or IP to bind to. |
|
|
116
|
+
| `port` | `number` | `4568` | Port to listen on. Use `0` for an ephemeral port. |
|
|
117
|
+
| `key` / `cert` | `string \| Buffer` | none | TLS key and certificate. Provide both for HTTPS. |
|
|
118
|
+
| `silent` | `boolean` | `false` | Suppress log output. |
|
|
119
|
+
|
|
120
|
+
### Storage
|
|
121
|
+
|
|
122
|
+
| Option | Type | Default | Description |
|
|
123
|
+
| ------------------ | ----------------------- | -------- | ------------------------------------------------------------------------------------------ |
|
|
124
|
+
| `directory` | `string` | temp dir | Where buckets and objects are stored. Data persists across restarts. |
|
|
125
|
+
| `resetOnStart` | `boolean` | `false` | Clear the data directory on every start, so a persistent `directory` begins empty. |
|
|
126
|
+
| `resetOnClose` | `boolean` | `false` | Delete all bucket data when `close()` is called. |
|
|
127
|
+
| `configureBuckets` | `BucketConfiguration[]` | `[]` | Buckets to create at startup, each with optional raw XML configs (CORS, website, tagging). |
|
|
128
|
+
|
|
129
|
+
### Credentials and addressing
|
|
130
|
+
|
|
131
|
+
| Option | Type | Default | Description |
|
|
132
|
+
| --------------------------- | --------- | ----------------- | --------------------------------------------------------------------------------------- |
|
|
133
|
+
| `accessKeyId` | `string` | `'S3TEST'` | Access key clients must sign with. |
|
|
134
|
+
| `secretAccessKey` | `string` | `'S3TEST'` | Matching secret key. |
|
|
135
|
+
| `region` | `string` | `'us-east-1'` | Region reported in responses and event records. |
|
|
136
|
+
| `vhostBuckets` | `boolean` | `true` | Virtual-host-style addressing (`bucket.localhost`, `bucket.s3.<region>.amazonaws.com`). |
|
|
137
|
+
| `serviceEndpoint` | `string` | `'amazonaws.com'` | Root domain recognized for virtual-host-style access. |
|
|
138
|
+
| `allowMismatchedSignatures` | `boolean` | `false` | Accept well-formed but incorrect signatures. |
|
|
139
|
+
|
|
140
|
+
Anonymous (unsigned) requests are accepted, as on a public bucket, which is
|
|
141
|
+
handy for plain `fetch`/`curl` in tests. Signed requests are verified against
|
|
142
|
+
`accessKeyId`/`secretAccessKey`.
|
|
143
|
+
|
|
144
|
+
## Supported operations
|
|
145
|
+
|
|
146
|
+
**Service / buckets**: ListBuckets, Create/Delete/HeadBucket,
|
|
147
|
+
GetBucketLocation, ListObjects (v1), ListObjectsV2, ListObjectVersions
|
|
148
|
+
(unversioned mapping), Get/Put/DeleteBucketCors, Get/Put/DeleteBucketWebsite,
|
|
149
|
+
Get/Put/DeleteBucketTagging, GetBucketVersioning, GetBucketEncryption,
|
|
150
|
+
GetBucketNotification/RequestPayment/Logging/Accelerate, ACLs (dummy),
|
|
151
|
+
ListMultipartUploads.
|
|
152
|
+
|
|
153
|
+
**Objects**: Put/Get/Head/DeleteObject, DeleteObjects, CopyObject
|
|
154
|
+
(COPY/REPLACE directives, copy conditions), POST form uploads
|
|
155
|
+
(`multipart/form-data`, `${filename}` substitution, `success_action_*`),
|
|
156
|
+
Get/Put/DeleteObjectTagging, GetObjectAttributes, object ACLs (dummy),
|
|
157
|
+
Range requests, conditional reads (`If-Match`, `If-None-Match`,
|
|
158
|
+
`If-(Un)Modified-Since`) and **conditional writes** (`If-None-Match: *`,
|
|
159
|
+
`If-Match`), `response-*` header overrides, `Content-MD5` and
|
|
160
|
+
`x-amz-checksum-*` validation (headers or `aws-chunked` trailers).
|
|
161
|
+
|
|
162
|
+
**Multipart**: Create/Abort/CompleteMultipartUpload, UploadPart,
|
|
163
|
+
UploadPartCopy (with `x-amz-copy-source-range`), ListParts, correct
|
|
164
|
+
multipart ETags (`md5-of-md5s-N`), composite and full-object checksums.
|
|
165
|
+
|
|
166
|
+
**Static website hosting**: index/error documents, folder redirects,
|
|
167
|
+
`x-amz-website-redirect-location`, `RedirectAllRequestsTo`; served for
|
|
168
|
+
anonymous GET/HEAD requests on buckets with a website configuration.
|
|
169
|
+
|
|
170
|
+
**CORS**: preflight (`OPTIONS`) evaluation and response headers, driven by
|
|
171
|
+
the bucket's CORS configuration.
|
|
172
|
+
|
|
173
|
+
Not implemented: bucket versioning (`PutBucketVersioning` with `Enabled`
|
|
174
|
+
returns `NotImplemented`), object locking, lifecycle execution, bucket
|
|
175
|
+
policies/IAM. Per-chunk signatures inside `aws-chunked` bodies are not
|
|
176
|
+
re-verified (the seed signature is).
|
|
177
|
+
|
|
178
|
+
## Events
|
|
179
|
+
|
|
180
|
+
The server is an `EventEmitter` and emits S3-style notifications:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
server.on('event', (notification) => {
|
|
184
|
+
const [record] = notification.Records;
|
|
185
|
+
console.log(record.eventName, record.s3.object.key);
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Emitted for `ObjectCreated:Put`, `ObjectCreated:Copy`, `ObjectCreated:Post`,
|
|
190
|
+
`ObjectCreated:CompleteMultipartUpload` and `ObjectRemoved:Delete`.
|
|
191
|
+
|
|
192
|
+
## Using as middleware
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
const http = require('http');
|
|
196
|
+
const S3TestServer = require('s3-test-server');
|
|
197
|
+
|
|
198
|
+
const s3 = new S3TestServer({ silent: true });
|
|
199
|
+
http.createServer(s3.callback()).listen(4568);
|
|
200
|
+
// also works as an Express/Connect handler
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Programmatic API
|
|
204
|
+
|
|
205
|
+
- `run([callback])` returns `Promise<{ address, port, protocol, endpoint }>`
|
|
206
|
+
- `close([callback])` returns `Promise<void>`
|
|
207
|
+
- `callback()` / `getMiddleware()` returns a request handler
|
|
208
|
+
- `configureBuckets()` provisions `options.configureBuckets` without the API
|
|
209
|
+
- `reset()` deletes all buckets and configurations
|
|
210
|
+
|
|
211
|
+
TypeScript definitions ship with the package (`lib/index.d.ts`).
|
|
212
|
+
|
|
213
|
+
## HTTPS
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
const fs = require('fs');
|
|
217
|
+
const server = new S3TestServer({
|
|
218
|
+
key: fs.readFileSync('./key.pem'),
|
|
219
|
+
cert: fs.readFileSync('./cert.pem'),
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
With a self-signed certificate, configure your client to accept it (for the
|
|
224
|
+
AWS SDKs, a custom HTTPS agent with `rejectUnauthorized: false`).
|
|
225
|
+
|
|
226
|
+
## Requirements
|
|
227
|
+
|
|
228
|
+
Node.js >= 20.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT
|
package/lib/checksums.js
ADDED
|
@@ -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 };
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const S3TestServer = require('./index');
|
|
6
|
+
|
|
7
|
+
const HELP = `Usage: s3-test-server [options]
|
|
8
|
+
|
|
9
|
+
A lightweight Amazon S3 emulator for development and testing.
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-a, --address <host> Hostname or IP to bind to (default: localhost)
|
|
13
|
+
-p, --port <n> Port of the server (default: 4568)
|
|
14
|
+
-d, --directory <path> Data directory (default: a temporary directory)
|
|
15
|
+
--access-key-id <id> Access key clients must sign with (default: S3TEST)
|
|
16
|
+
--secret-access-key <key> Matching secret access key (default: S3TEST)
|
|
17
|
+
--region <region> Region reported by the server (default: us-east-1)
|
|
18
|
+
--key <path> Path to a private key file for HTTPS
|
|
19
|
+
--cert <path> Path to a certificate file for HTTPS
|
|
20
|
+
--service-endpoint <host> Root domain for virtual-host-style access
|
|
21
|
+
(default: amazonaws.com)
|
|
22
|
+
--no-vhost-buckets Disable virtual-host-style bucket addressing
|
|
23
|
+
--allow-mismatched-signatures Accept well-formed but incorrect signatures
|
|
24
|
+
--reset-on-start Clear the data directory when the server starts
|
|
25
|
+
--reset-on-close Delete all bucket data when the server closes
|
|
26
|
+
--configure-bucket <name[,config,...]>
|
|
27
|
+
Create a bucket at startup; optional paths to
|
|
28
|
+
CORS/website/tagging XML files (repeatable)
|
|
29
|
+
-s, --silent Suppress log output
|
|
30
|
+
-v, --version Print the version and exit
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
s3-test-server -d ./s3-data -p 9000 \\
|
|
35
|
+
--configure-bucket "website,./cors.xml,./website.xml"
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const options = { configureBuckets: [] };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
const next = () => {
|
|
43
|
+
i += 1;
|
|
44
|
+
if (i >= argv.length) {
|
|
45
|
+
console.error(`Missing value for ${arg}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return argv[i];
|
|
49
|
+
};
|
|
50
|
+
switch (arg) {
|
|
51
|
+
case '-a':
|
|
52
|
+
case '--address':
|
|
53
|
+
options.address = next();
|
|
54
|
+
break;
|
|
55
|
+
case '-p':
|
|
56
|
+
case '--port':
|
|
57
|
+
options.port = parseInt(next(), 10);
|
|
58
|
+
break;
|
|
59
|
+
case '-d':
|
|
60
|
+
case '--directory':
|
|
61
|
+
options.directory = path.resolve(next());
|
|
62
|
+
break;
|
|
63
|
+
case '--access-key-id':
|
|
64
|
+
options.accessKeyId = next();
|
|
65
|
+
break;
|
|
66
|
+
case '--secret-access-key':
|
|
67
|
+
options.secretAccessKey = next();
|
|
68
|
+
break;
|
|
69
|
+
case '--region':
|
|
70
|
+
options.region = next();
|
|
71
|
+
break;
|
|
72
|
+
case '--key':
|
|
73
|
+
options.key = fs.readFileSync(next());
|
|
74
|
+
break;
|
|
75
|
+
case '--cert':
|
|
76
|
+
options.cert = fs.readFileSync(next());
|
|
77
|
+
break;
|
|
78
|
+
case '--service-endpoint':
|
|
79
|
+
options.serviceEndpoint = next();
|
|
80
|
+
break;
|
|
81
|
+
case '--no-vhost-buckets':
|
|
82
|
+
options.vhostBuckets = false;
|
|
83
|
+
break;
|
|
84
|
+
case '--allow-mismatched-signatures':
|
|
85
|
+
options.allowMismatchedSignatures = true;
|
|
86
|
+
break;
|
|
87
|
+
case '--reset-on-start':
|
|
88
|
+
options.resetOnStart = true;
|
|
89
|
+
break;
|
|
90
|
+
case '--reset-on-close':
|
|
91
|
+
options.resetOnClose = true;
|
|
92
|
+
break;
|
|
93
|
+
case '--configure-bucket': {
|
|
94
|
+
const [name, ...configPaths] = next().split(',');
|
|
95
|
+
options.configureBuckets.push({
|
|
96
|
+
name: name.trim(),
|
|
97
|
+
configs: configPaths.map((configPath) =>
|
|
98
|
+
fs.readFileSync(path.resolve(configPath.trim()))
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case '-s':
|
|
104
|
+
case '--silent':
|
|
105
|
+
options.silent = true;
|
|
106
|
+
break;
|
|
107
|
+
case '-v':
|
|
108
|
+
case '--version':
|
|
109
|
+
console.log(require('../package.json').version);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
break;
|
|
112
|
+
case '-h':
|
|
113
|
+
case '--help':
|
|
114
|
+
console.log(HELP);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
console.error(`Unknown option: ${arg}\n`);
|
|
119
|
+
console.error(HELP);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return options;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
127
|
+
const options = parseArgs(process.argv.slice(2));
|
|
128
|
+
const server = new S3TestServer(options);
|
|
129
|
+
const shutdown = () => {
|
|
130
|
+
server.close().finally(() => process.exit(0));
|
|
131
|
+
};
|
|
132
|
+
process.on('SIGINT', shutdown);
|
|
133
|
+
process.on('SIGTERM', shutdown);
|
|
134
|
+
try {
|
|
135
|
+
await server.run();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { main };
|