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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { S3Error } = require('../models/error');
|
|
4
|
+
const { send, sendDoc, quoteEtag } = require('../models/response');
|
|
5
|
+
const { readBody, awsUriEncode } = require('../utils');
|
|
6
|
+
const { requireBucket } = require('./bucket');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Browser-style form upload: POST / with multipart/form-data.
|
|
10
|
+
* Policy documents and their signatures are accepted but not enforced.
|
|
11
|
+
*/
|
|
12
|
+
async function postFormUpload(ctx) {
|
|
13
|
+
await requireBucket(ctx);
|
|
14
|
+
const contentType = String(ctx.req.headers['content-type'] || '');
|
|
15
|
+
const boundaryMatch = /boundary="?([^";]+)"?/i.exec(contentType);
|
|
16
|
+
if (!contentType.startsWith('multipart/form-data') || !boundaryMatch) {
|
|
17
|
+
throw new S3Error(
|
|
18
|
+
'MalformedPOSTRequest',
|
|
19
|
+
'The body of your POST request is not well-formed multipart/form-data.'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const body = await readBody(ctx.req, { limit: 5 * 1024 * 1024 * 1024 });
|
|
23
|
+
const { fields, file } = parseMultipartFormData(body, boundaryMatch[1]);
|
|
24
|
+
if (!file) {
|
|
25
|
+
throw new S3Error(
|
|
26
|
+
'InvalidArgument',
|
|
27
|
+
'POST requires exactly one file upload per request.',
|
|
28
|
+
{ ArgumentName: 'file', ArgumentValue: '0' }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
let key = fields.key;
|
|
32
|
+
if (!key) {
|
|
33
|
+
throw new S3Error(
|
|
34
|
+
'InvalidArgument',
|
|
35
|
+
"Bucket POST must contain a field named 'key'. If it is specified, please check the order of the fields.",
|
|
36
|
+
{ ArgumentName: 'key', ArgumentValue: '' }
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
key = key.replace(/\$\{filename\}/g, file.filename || '');
|
|
40
|
+
|
|
41
|
+
const { Readable } = require('stream');
|
|
42
|
+
const staged = await ctx.store.stageObject(
|
|
43
|
+
ctx.bucket,
|
|
44
|
+
Readable.from([file.content]),
|
|
45
|
+
['CRC64NVME']
|
|
46
|
+
);
|
|
47
|
+
const meta = await staged.commit(key, {
|
|
48
|
+
contentType:
|
|
49
|
+
fields['Content-Type'] || file.contentType || 'binary/octet-stream',
|
|
50
|
+
cacheControl: fields['Cache-Control'],
|
|
51
|
+
contentDisposition: fields['Content-Disposition'],
|
|
52
|
+
contentEncoding: fields['Content-Encoding'],
|
|
53
|
+
userMetadata: Object.fromEntries(
|
|
54
|
+
Object.entries(fields)
|
|
55
|
+
.filter(([name]) => name.toLowerCase().startsWith('x-amz-meta-'))
|
|
56
|
+
.map(([name, value]) => [
|
|
57
|
+
name.slice('x-amz-meta-'.length).toLowerCase(),
|
|
58
|
+
value,
|
|
59
|
+
])
|
|
60
|
+
),
|
|
61
|
+
tagging: {},
|
|
62
|
+
checksums: staged.digests.checksums,
|
|
63
|
+
checksumAlgorithm: 'CRC64NVME',
|
|
64
|
+
checksumType: 'FULL_OBJECT',
|
|
65
|
+
});
|
|
66
|
+
ctx.emitEvent('ObjectCreated:Post', meta);
|
|
67
|
+
|
|
68
|
+
const etag = quoteEtag(meta.etag);
|
|
69
|
+
const redirect = fields.success_action_redirect || fields.redirect;
|
|
70
|
+
if (redirect) {
|
|
71
|
+
let location;
|
|
72
|
+
try {
|
|
73
|
+
location = new URL(redirect);
|
|
74
|
+
location.searchParams.set('bucket', ctx.bucket);
|
|
75
|
+
location.searchParams.set('key', key);
|
|
76
|
+
location.searchParams.set('etag', etag);
|
|
77
|
+
} catch {
|
|
78
|
+
location = null;
|
|
79
|
+
}
|
|
80
|
+
if (location) {
|
|
81
|
+
send(ctx.res, 303, { Location: location.toString(), ETag: etag });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const status = parseInt(fields.success_action_status, 10);
|
|
86
|
+
if (status === 200 || status === 204) {
|
|
87
|
+
send(ctx.res, status, { ETag: etag });
|
|
88
|
+
} else if (status === 201) {
|
|
89
|
+
const host = ctx.req.headers.host || 'localhost';
|
|
90
|
+
ctx.res.setHeader('ETag', etag);
|
|
91
|
+
sendDoc(ctx.res, 201, 'PostResponse', {
|
|
92
|
+
Location: `http://${host}/${ctx.bucket}/${awsUriEncode(key, false)}`,
|
|
93
|
+
Bucket: ctx.bucket,
|
|
94
|
+
Key: key,
|
|
95
|
+
ETag: etag,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
send(ctx.res, 204, { ETag: etag });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseMultipartFormData(body, boundary) {
|
|
103
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
104
|
+
const fields = {};
|
|
105
|
+
let file = null;
|
|
106
|
+
|
|
107
|
+
let offset = body.indexOf(delimiter);
|
|
108
|
+
while (offset !== -1) {
|
|
109
|
+
offset += delimiter.length;
|
|
110
|
+
// closing delimiter "--boundary--"
|
|
111
|
+
if (body[offset] === 0x2d && body[offset + 1] === 0x2d) break;
|
|
112
|
+
// skip the CRLF after the boundary line
|
|
113
|
+
if (body[offset] === 0x0d && body[offset + 1] === 0x0a) offset += 2;
|
|
114
|
+
|
|
115
|
+
const headerEnd = body.indexOf('\r\n\r\n', offset);
|
|
116
|
+
if (headerEnd === -1) break;
|
|
117
|
+
const rawHeaders = body.subarray(offset, headerEnd).toString('utf8');
|
|
118
|
+
const next = body.indexOf(delimiter, headerEnd + 4);
|
|
119
|
+
if (next === -1) break;
|
|
120
|
+
// content runs up to the CRLF that precedes the next boundary
|
|
121
|
+
const content = body.subarray(headerEnd + 4, next - 2);
|
|
122
|
+
|
|
123
|
+
const headers = {};
|
|
124
|
+
for (const line of rawHeaders.split('\r\n')) {
|
|
125
|
+
const idx = line.indexOf(':');
|
|
126
|
+
if (idx > 0)
|
|
127
|
+
headers[line.slice(0, idx).trim().toLowerCase()] = line
|
|
128
|
+
.slice(idx + 1)
|
|
129
|
+
.trim();
|
|
130
|
+
}
|
|
131
|
+
const disposition = headers['content-disposition'] || '';
|
|
132
|
+
const nameMatch = /name="([^"]*)"/.exec(disposition);
|
|
133
|
+
const filenameMatch = /filename="([^"]*)"/.exec(disposition);
|
|
134
|
+
if (nameMatch) {
|
|
135
|
+
if (filenameMatch || nameMatch[1] === 'file') {
|
|
136
|
+
file = {
|
|
137
|
+
filename: filenameMatch ? filenameMatch[1] : '',
|
|
138
|
+
content,
|
|
139
|
+
contentType: headers['content-type'],
|
|
140
|
+
};
|
|
141
|
+
} else {
|
|
142
|
+
fields[nameMatch[1]] = content.toString('utf8');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
offset = next;
|
|
146
|
+
}
|
|
147
|
+
return { fields, file };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { postFormUpload };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sendDoc } = require('../models/response');
|
|
4
|
+
const { isoDate, DUMMY_OWNER } = require('../utils');
|
|
5
|
+
|
|
6
|
+
async function listBuckets(ctx) {
|
|
7
|
+
const buckets = await ctx.store.listBuckets();
|
|
8
|
+
sendDoc(ctx.res, 200, 'ListAllMyBucketsResult', {
|
|
9
|
+
Owner: DUMMY_OWNER,
|
|
10
|
+
Buckets: {
|
|
11
|
+
Bucket: buckets.map((bucket) => ({
|
|
12
|
+
Name: bucket.name,
|
|
13
|
+
CreationDate: isoDate(bucket.creationDate),
|
|
14
|
+
})),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { listBuckets };
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import { IncomingMessage, ServerResponse, Server as HttpServer } from 'http';
|
|
5
|
+
|
|
6
|
+
declare class S3TestServer extends EventEmitter {
|
|
7
|
+
constructor(options?: S3TestServer.S3TestServerOptions);
|
|
8
|
+
|
|
9
|
+
options: S3TestServer.ResolvedOptions;
|
|
10
|
+
|
|
11
|
+
/** The underlying HTTP(S) server, available after `run()` resolves. */
|
|
12
|
+
httpServer: HttpServer | null;
|
|
13
|
+
|
|
14
|
+
/** Starts the server. */
|
|
15
|
+
run(): Promise<S3TestServer.RunAddress>;
|
|
16
|
+
run(
|
|
17
|
+
callback: (err: Error | null, address?: S3TestServer.RunAddress) => void
|
|
18
|
+
): this;
|
|
19
|
+
|
|
20
|
+
/** Stops the server (removing data when `resetOnClose` is set). */
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
close(callback: (err: Error | null) => void): this;
|
|
23
|
+
|
|
24
|
+
/** Returns a request handler for use with `http.createServer()` or Express. */
|
|
25
|
+
callback(): S3TestServer.RequestHandler;
|
|
26
|
+
/** Alias of {@link callback}. */
|
|
27
|
+
getMiddleware(): S3TestServer.RequestHandler;
|
|
28
|
+
|
|
29
|
+
/** Provisions the buckets in `options.configureBuckets` without the S3 API. */
|
|
30
|
+
configureBuckets(): Promise<void>;
|
|
31
|
+
|
|
32
|
+
/** Deletes all buckets, objects and configurations in the store. */
|
|
33
|
+
reset(): Promise<void>;
|
|
34
|
+
|
|
35
|
+
on(
|
|
36
|
+
event: 'event',
|
|
37
|
+
listener: (notification: S3TestServer.S3EventNotification) => void
|
|
38
|
+
): this;
|
|
39
|
+
on(event: string | symbol, listener: (...args: any[]) => void): this;
|
|
40
|
+
once(
|
|
41
|
+
event: 'event',
|
|
42
|
+
listener: (notification: S3TestServer.S3EventNotification) => void
|
|
43
|
+
): this;
|
|
44
|
+
once(event: string | symbol, listener: (...args: any[]) => void): this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
declare namespace S3TestServer {
|
|
48
|
+
interface BucketConfiguration {
|
|
49
|
+
/** Bucket name (must be a valid S3 bucket name). */
|
|
50
|
+
name: string;
|
|
51
|
+
/**
|
|
52
|
+
* Raw XML configuration documents applied to the bucket at startup.
|
|
53
|
+
* CORS (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and
|
|
54
|
+
* tagging (`<Tagging>`) documents are recognized by their root element.
|
|
55
|
+
*/
|
|
56
|
+
configs?: Array<string | Buffer>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface S3TestServerOptions {
|
|
60
|
+
/** Hostname or IP to bind to. Default: `"localhost"`. */
|
|
61
|
+
address?: string;
|
|
62
|
+
/** Port to listen on. Use `0` for an ephemeral port. Default: `4568`. */
|
|
63
|
+
port?: number;
|
|
64
|
+
/** Private key for HTTPS. When both `key` and `cert` are set, the server uses TLS. */
|
|
65
|
+
key?: string | Buffer;
|
|
66
|
+
/** Certificate for HTTPS. */
|
|
67
|
+
cert?: string | Buffer;
|
|
68
|
+
/** Suppress log output. Default: `false`. */
|
|
69
|
+
silent?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Directory in which buckets and objects are stored.
|
|
72
|
+
* Default: a freshly created temporary directory.
|
|
73
|
+
*/
|
|
74
|
+
directory?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Clear the data directory each time the server starts, so a persistent
|
|
77
|
+
* `directory` does not leak state between runs. Default: `false`.
|
|
78
|
+
*/
|
|
79
|
+
resetOnStart?: boolean;
|
|
80
|
+
/** Remove all bucket data when the server closes. Default: `false`. */
|
|
81
|
+
resetOnClose?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Accept well-formed signatures even when they do not match
|
|
84
|
+
* (useful with exotic clients). Default: `false`.
|
|
85
|
+
*/
|
|
86
|
+
allowMismatchedSignatures?: boolean;
|
|
87
|
+
/** Enable virtual-host-style bucket addressing. Default: `true`. */
|
|
88
|
+
vhostBuckets?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Root domain used to recognize virtual-host-style requests
|
|
91
|
+
* (e.g. `bucket.s3.<region>.amazonaws.com`). Default: `"amazonaws.com"`.
|
|
92
|
+
*/
|
|
93
|
+
serviceEndpoint?: string;
|
|
94
|
+
/** Access key id clients must sign requests with. Default: `"S3TEST"`. */
|
|
95
|
+
accessKeyId?: string;
|
|
96
|
+
/** Secret access key clients must sign requests with. Default: `"S3TEST"`. */
|
|
97
|
+
secretAccessKey?: string;
|
|
98
|
+
/** Region reported in responses and event records. Default: `"us-east-1"`. */
|
|
99
|
+
region?: string;
|
|
100
|
+
/** Buckets to create (and configure) before the server starts. */
|
|
101
|
+
configureBuckets?: BucketConfiguration[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type ResolvedOptions = S3TestServerOptions &
|
|
105
|
+
Required<
|
|
106
|
+
Pick<
|
|
107
|
+
S3TestServerOptions,
|
|
108
|
+
| 'address'
|
|
109
|
+
| 'port'
|
|
110
|
+
| 'silent'
|
|
111
|
+
| 'resetOnStart'
|
|
112
|
+
| 'resetOnClose'
|
|
113
|
+
| 'allowMismatchedSignatures'
|
|
114
|
+
| 'vhostBuckets'
|
|
115
|
+
| 'serviceEndpoint'
|
|
116
|
+
| 'accessKeyId'
|
|
117
|
+
| 'secretAccessKey'
|
|
118
|
+
| 'region'
|
|
119
|
+
>
|
|
120
|
+
>;
|
|
121
|
+
|
|
122
|
+
interface RunAddress {
|
|
123
|
+
/** Bound address, as reported by `net.Server#address()`. */
|
|
124
|
+
address: string;
|
|
125
|
+
family: string;
|
|
126
|
+
port: number;
|
|
127
|
+
/** `"http"` or `"https"`. */
|
|
128
|
+
protocol: string;
|
|
129
|
+
/** Convenience endpoint URL, e.g. `"http://localhost:4568"`. */
|
|
130
|
+
endpoint: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type S3EventName =
|
|
134
|
+
| 'ObjectCreated:Put'
|
|
135
|
+
| 'ObjectCreated:Copy'
|
|
136
|
+
| 'ObjectCreated:Post'
|
|
137
|
+
| 'ObjectCreated:CompleteMultipartUpload'
|
|
138
|
+
| 'ObjectRemoved:Delete';
|
|
139
|
+
|
|
140
|
+
interface S3EventRecord {
|
|
141
|
+
eventVersion: string;
|
|
142
|
+
eventSource: 'aws:s3';
|
|
143
|
+
awsRegion: string;
|
|
144
|
+
eventTime: string;
|
|
145
|
+
eventName: S3EventName;
|
|
146
|
+
userIdentity: { principalId: string };
|
|
147
|
+
requestParameters: { sourceIPAddress: string };
|
|
148
|
+
responseElements: Record<string, string>;
|
|
149
|
+
s3: {
|
|
150
|
+
s3SchemaVersion: string;
|
|
151
|
+
configurationId: string;
|
|
152
|
+
bucket: {
|
|
153
|
+
name: string;
|
|
154
|
+
ownerIdentity: { principalId: string };
|
|
155
|
+
arn: string;
|
|
156
|
+
};
|
|
157
|
+
object: {
|
|
158
|
+
/** URL-encoded object key. */
|
|
159
|
+
key: string;
|
|
160
|
+
size: number;
|
|
161
|
+
eTag?: string;
|
|
162
|
+
sequencer: string;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface S3EventNotification {
|
|
168
|
+
Records: S3EventRecord[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void;
|
|
172
|
+
|
|
173
|
+
class S3Error extends Error {
|
|
174
|
+
code: string;
|
|
175
|
+
statusCode: number;
|
|
176
|
+
detail: Record<string, unknown>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export {
|
|
180
|
+
S3TestServer,
|
|
181
|
+
BucketConfiguration,
|
|
182
|
+
S3TestServerOptions,
|
|
183
|
+
ResolvedOptions,
|
|
184
|
+
RunAddress,
|
|
185
|
+
S3EventName,
|
|
186
|
+
S3EventRecord,
|
|
187
|
+
S3EventNotification,
|
|
188
|
+
RequestHandler,
|
|
189
|
+
S3Error,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export = S3TestServer;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { FilesystemStore } = require('./stores/filesystem');
|
|
10
|
+
const { createRequestHandler } = require('./routes');
|
|
11
|
+
const { awsUriEncode } = require('./utils');
|
|
12
|
+
const { S3Error } = require('./models/error');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPTIONS = {
|
|
15
|
+
address: 'localhost',
|
|
16
|
+
port: 4568,
|
|
17
|
+
key: undefined,
|
|
18
|
+
cert: undefined,
|
|
19
|
+
silent: false,
|
|
20
|
+
directory: undefined,
|
|
21
|
+
resetOnStart: false,
|
|
22
|
+
resetOnClose: false,
|
|
23
|
+
allowMismatchedSignatures: false,
|
|
24
|
+
vhostBuckets: true,
|
|
25
|
+
serviceEndpoint: 'amazonaws.com',
|
|
26
|
+
accessKeyId: 'S3TEST',
|
|
27
|
+
secretAccessKey: 'S3TEST',
|
|
28
|
+
region: 'us-east-1',
|
|
29
|
+
configureBuckets: [],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
class S3TestServer extends EventEmitter {
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
super();
|
|
35
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
36
|
+
this.logger = {
|
|
37
|
+
info: (...args) => {
|
|
38
|
+
if (!this.options.silent) console.log(...args);
|
|
39
|
+
},
|
|
40
|
+
error: (...args) => {
|
|
41
|
+
if (!this.options.silent) console.error(...args);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
this.httpServer = null;
|
|
45
|
+
this.store = null;
|
|
46
|
+
this._middleware = null;
|
|
47
|
+
this._sequencer = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_ensureStore() {
|
|
51
|
+
if (this.store) return this.store;
|
|
52
|
+
let root = this.options.directory;
|
|
53
|
+
if (!root) {
|
|
54
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 's3-test-server-'));
|
|
55
|
+
this._createdTempDir = true;
|
|
56
|
+
}
|
|
57
|
+
fs.mkdirSync(root, { recursive: true });
|
|
58
|
+
this.store = new FilesystemStore(root);
|
|
59
|
+
return this.store;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns a `(req, res)` handler usable with http.createServer() or as an
|
|
64
|
+
* Express/Connect middleware.
|
|
65
|
+
*/
|
|
66
|
+
callback() {
|
|
67
|
+
this._ensureStore();
|
|
68
|
+
if (!this._middleware) this._middleware = createRequestHandler(this);
|
|
69
|
+
return this._middleware;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getMiddleware() {
|
|
73
|
+
return this.callback();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Starts the server. Returns a promise of `{ address, port, ... }`. */
|
|
77
|
+
run(callback) {
|
|
78
|
+
const promise = (async () => {
|
|
79
|
+
this._ensureStore();
|
|
80
|
+
await this.store.initialize();
|
|
81
|
+
// Start from an empty data directory when asked, so a persistent
|
|
82
|
+
// `directory` does not leak state between runs.
|
|
83
|
+
if (this.options.resetOnStart) {
|
|
84
|
+
await this.store.reset();
|
|
85
|
+
}
|
|
86
|
+
await this.configureBuckets();
|
|
87
|
+
const handler = this.callback();
|
|
88
|
+
this.httpServer =
|
|
89
|
+
this.options.key && this.options.cert
|
|
90
|
+
? https.createServer(
|
|
91
|
+
{ key: this.options.key, cert: this.options.cert },
|
|
92
|
+
handler
|
|
93
|
+
)
|
|
94
|
+
: http.createServer(handler);
|
|
95
|
+
await new Promise((resolve, reject) => {
|
|
96
|
+
this.httpServer.once('error', reject);
|
|
97
|
+
this.httpServer.listen(this.options.port, this.options.address, () => {
|
|
98
|
+
this.httpServer.removeListener('error', reject);
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
const address = this.httpServer.address();
|
|
103
|
+
const protocol = this.options.key && this.options.cert ? 'https' : 'http';
|
|
104
|
+
this.logger.info(
|
|
105
|
+
`S3 test server listening on ${protocol}://${this.options.address}:${address.port}`
|
|
106
|
+
);
|
|
107
|
+
return {
|
|
108
|
+
...address,
|
|
109
|
+
protocol,
|
|
110
|
+
endpoint: `${protocol}://${this.options.address}:${address.port}`,
|
|
111
|
+
};
|
|
112
|
+
})();
|
|
113
|
+
if (typeof callback === 'function') {
|
|
114
|
+
promise.then(
|
|
115
|
+
(address) => callback(null, address),
|
|
116
|
+
(err) => callback(err)
|
|
117
|
+
);
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
return promise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Stops the server. With `resetOnClose`, removes all bucket data. */
|
|
124
|
+
close(callback) {
|
|
125
|
+
const promise = (async () => {
|
|
126
|
+
if (this.httpServer) {
|
|
127
|
+
this.httpServer.closeAllConnections?.();
|
|
128
|
+
await new Promise((resolve, reject) => {
|
|
129
|
+
this.httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
130
|
+
});
|
|
131
|
+
this.httpServer = null;
|
|
132
|
+
}
|
|
133
|
+
if (this.store && this.options.resetOnClose) {
|
|
134
|
+
if (this._createdTempDir) await this.store.destroy();
|
|
135
|
+
else await this.store.reset();
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
if (typeof callback === 'function') {
|
|
139
|
+
promise.then(
|
|
140
|
+
() => callback(null),
|
|
141
|
+
(err) => callback(err)
|
|
142
|
+
);
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
return promise;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Creates the buckets given in `options.configureBuckets` (name plus raw
|
|
150
|
+
* XML configs: CORS, website or tagging documents, detected by their root
|
|
151
|
+
* element) without going through the S3 API.
|
|
152
|
+
*/
|
|
153
|
+
async configureBuckets() {
|
|
154
|
+
this._ensureStore();
|
|
155
|
+
await this.store.initialize();
|
|
156
|
+
const {
|
|
157
|
+
parseCorsConfig,
|
|
158
|
+
parseWebsiteConfig,
|
|
159
|
+
parseTaggingConfig,
|
|
160
|
+
} = require('./controllers/bucket');
|
|
161
|
+
const { validateBucketName } = require('./utils');
|
|
162
|
+
for (const { name, configs = [] } of this.options.configureBuckets || []) {
|
|
163
|
+
validateBucketName(name);
|
|
164
|
+
await this.store.createBucket(name);
|
|
165
|
+
for (const rawConfig of configs) {
|
|
166
|
+
const xml = Buffer.isBuffer(rawConfig)
|
|
167
|
+
? rawConfig.toString('utf8')
|
|
168
|
+
: String(rawConfig);
|
|
169
|
+
if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
|
|
170
|
+
await this.store.putBucketConfig(name, 'cors', parseCorsConfig(xml));
|
|
171
|
+
} else if (/<\s*(\w+:)?WebsiteConfiguration[\s>]/.test(xml)) {
|
|
172
|
+
await this.store.putBucketConfig(
|
|
173
|
+
name,
|
|
174
|
+
'website',
|
|
175
|
+
parseWebsiteConfig(xml)
|
|
176
|
+
);
|
|
177
|
+
} else if (/<\s*(\w+:)?Tagging[\s>]/.test(xml)) {
|
|
178
|
+
await this.store.putBucketConfig(
|
|
179
|
+
name,
|
|
180
|
+
'tagging',
|
|
181
|
+
parseTaggingConfig(xml)
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
throw new S3Error(
|
|
185
|
+
'MalformedXML',
|
|
186
|
+
'Unrecognized bucket configuration document'
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Deletes all buckets, objects and bucket configurations. */
|
|
194
|
+
async reset() {
|
|
195
|
+
this._ensureStore();
|
|
196
|
+
await this.store.reset();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_emitEvent(eventName, bucket, objectMeta) {
|
|
200
|
+
if (!objectMeta) return;
|
|
201
|
+
this._sequencer += 1;
|
|
202
|
+
const record = {
|
|
203
|
+
eventVersion: '2.1',
|
|
204
|
+
eventSource: 'aws:s3',
|
|
205
|
+
awsRegion: this.options.region,
|
|
206
|
+
eventTime: new Date().toISOString(),
|
|
207
|
+
eventName,
|
|
208
|
+
userIdentity: { principalId: 'AIDAJDPLRKLG7UEXAMPLE' },
|
|
209
|
+
requestParameters: { sourceIPAddress: '127.0.0.1' },
|
|
210
|
+
responseElements: {
|
|
211
|
+
'x-amz-request-id': 'C3D13FE58DE4C810',
|
|
212
|
+
'x-amz-id-2':
|
|
213
|
+
'FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD',
|
|
214
|
+
},
|
|
215
|
+
s3: {
|
|
216
|
+
s3SchemaVersion: '1.0',
|
|
217
|
+
configurationId: 'testConfigRule',
|
|
218
|
+
bucket: {
|
|
219
|
+
name: bucket,
|
|
220
|
+
ownerIdentity: { principalId: 'A3NL1KOZZKExample' },
|
|
221
|
+
arn: `arn:aws:s3:::${bucket}`,
|
|
222
|
+
},
|
|
223
|
+
object: {
|
|
224
|
+
key: awsUriEncode(objectMeta.key, false),
|
|
225
|
+
size: objectMeta.size,
|
|
226
|
+
eTag: objectMeta.etag,
|
|
227
|
+
sequencer: this._sequencer
|
|
228
|
+
.toString(16)
|
|
229
|
+
.toUpperCase()
|
|
230
|
+
.padStart(16, '0'),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
this.emit('event', { Records: [record] });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = S3TestServer;
|
|
239
|
+
module.exports.S3TestServer = S3TestServer;
|
|
240
|
+
module.exports.default = S3TestServer;
|
|
241
|
+
module.exports.S3Error = S3Error;
|