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.
@@ -0,0 +1,624 @@
1
+ 'use strict';
2
+
3
+ const { S3Error } = require('../models/error');
4
+ const { parseXml, asArray, escapeXml, S3_XMLNS } = require('../xml');
5
+ const {
6
+ send,
7
+ sendXml,
8
+ sendDoc,
9
+ paginate,
10
+ parseListParams,
11
+ encodeListValue,
12
+ contentsEntry,
13
+ } = require('../models/response');
14
+ const {
15
+ readBody,
16
+ validateBucketName,
17
+ isoDate,
18
+ DUMMY_OWNER,
19
+ } = require('../utils');
20
+
21
+ const dummyAclXml = () =>
22
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
23
+ `<AccessControlPolicy xmlns="${S3_XMLNS}">` +
24
+ `<Owner><ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Owner>` +
25
+ '<AccessControlList><Grant>' +
26
+ '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="CanonicalUser">' +
27
+ `<ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Grantee>` +
28
+ '<Permission>FULL_CONTROL</Permission>' +
29
+ '</Grant></AccessControlList></AccessControlPolicy>';
30
+
31
+ async function requireBucket(ctx) {
32
+ const meta = await ctx.store.getBucket(ctx.bucket);
33
+ if (!meta) {
34
+ throw new S3Error('NoSuchBucket', 'The specified bucket does not exist', {
35
+ BucketName: ctx.bucket,
36
+ });
37
+ }
38
+ return meta;
39
+ }
40
+
41
+ // ---------------------------------------------------------------- GET
42
+
43
+ async function getBucket(ctx) {
44
+ const { query, res } = ctx;
45
+
46
+ if (query.has('location')) {
47
+ await requireBucket(ctx);
48
+ const location =
49
+ (await ctx.store.getBucket(ctx.bucket)).configs.location || '';
50
+ sendXml(
51
+ res,
52
+ 200,
53
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
54
+ `<LocationConstraint xmlns="${S3_XMLNS}">${escapeXml(location)}</LocationConstraint>`
55
+ );
56
+ return;
57
+ }
58
+ if (query.has('acl')) {
59
+ await requireBucket(ctx);
60
+ sendXml(res, 200, dummyAclXml());
61
+ return;
62
+ }
63
+ if (query.has('cors')) {
64
+ const meta = await requireBucket(ctx);
65
+ if (!meta.configs.cors) {
66
+ throw new S3Error(
67
+ 'NoSuchCORSConfiguration',
68
+ 'The CORS configuration does not exist',
69
+ {
70
+ BucketName: ctx.bucket,
71
+ }
72
+ );
73
+ }
74
+ sendXml(res, 200, meta.configs.cors.xml);
75
+ return;
76
+ }
77
+ if (query.has('website')) {
78
+ const meta = await requireBucket(ctx);
79
+ if (!meta.configs.website) {
80
+ throw new S3Error(
81
+ 'NoSuchWebsiteConfiguration',
82
+ 'The specified bucket does not have a website configuration',
83
+ { BucketName: ctx.bucket }
84
+ );
85
+ }
86
+ sendXml(res, 200, meta.configs.website.xml);
87
+ return;
88
+ }
89
+ if (query.has('tagging')) {
90
+ const meta = await requireBucket(ctx);
91
+ if (!meta.configs.tagging) {
92
+ throw new S3Error('NoSuchTagSet', 'The TagSet does not exist', {
93
+ BucketName: ctx.bucket,
94
+ });
95
+ }
96
+ sendXml(res, 200, meta.configs.tagging.xml);
97
+ return;
98
+ }
99
+ if (query.has('versioning')) {
100
+ await requireBucket(ctx);
101
+ sendDoc(res, 200, 'VersioningConfiguration', {});
102
+ return;
103
+ }
104
+ if (query.has('versions')) {
105
+ await listObjectVersions(ctx);
106
+ return;
107
+ }
108
+ if (query.has('uploads')) {
109
+ await listMultipartUploads(ctx);
110
+ return;
111
+ }
112
+ if (query.has('policy')) {
113
+ await requireBucket(ctx);
114
+ throw new S3Error(
115
+ 'NoSuchBucketPolicy',
116
+ 'The bucket policy does not exist',
117
+ {
118
+ BucketName: ctx.bucket,
119
+ }
120
+ );
121
+ }
122
+ if (query.has('lifecycle')) {
123
+ await requireBucket(ctx);
124
+ throw new S3Error(
125
+ 'NoSuchLifecycleConfiguration',
126
+ 'The lifecycle configuration does not exist'
127
+ );
128
+ }
129
+ if (query.has('encryption')) {
130
+ await requireBucket(ctx);
131
+ sendDoc(res, 200, 'ServerSideEncryptionConfiguration', {
132
+ Rule: {
133
+ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' },
134
+ BucketKeyEnabled: false,
135
+ },
136
+ });
137
+ return;
138
+ }
139
+ if (query.has('object-lock')) {
140
+ await requireBucket(ctx);
141
+ throw new S3Error(
142
+ 'ObjectLockConfigurationNotFoundError',
143
+ 'Object Lock configuration does not exist for this bucket',
144
+ { statusCode: 404, BucketName: ctx.bucket }
145
+ );
146
+ }
147
+ if (query.has('publicAccessBlock')) {
148
+ await requireBucket(ctx);
149
+ throw new S3Error(
150
+ 'NoSuchPublicAccessBlockConfiguration',
151
+ 'The public access block configuration was not found',
152
+ { statusCode: 404 }
153
+ );
154
+ }
155
+ if (query.has('notification')) {
156
+ await requireBucket(ctx);
157
+ sendDoc(res, 200, 'NotificationConfiguration', {});
158
+ return;
159
+ }
160
+ if (query.has('requestPayment')) {
161
+ await requireBucket(ctx);
162
+ sendDoc(res, 200, 'RequestPaymentConfiguration', { Payer: 'BucketOwner' });
163
+ return;
164
+ }
165
+ if (query.has('logging')) {
166
+ await requireBucket(ctx);
167
+ sendDoc(res, 200, 'BucketLoggingStatus', {});
168
+ return;
169
+ }
170
+ if (query.has('accelerate')) {
171
+ await requireBucket(ctx);
172
+ sendDoc(res, 200, 'AccelerateConfiguration', {});
173
+ return;
174
+ }
175
+ if (query.get('list-type') === '2') {
176
+ await listObjectsV2(ctx);
177
+ return;
178
+ }
179
+ await listObjectsV1(ctx);
180
+ }
181
+
182
+ async function listObjectsV1(ctx) {
183
+ await requireBucket(ctx);
184
+ const params = parseListParams(ctx.query);
185
+ const marker = ctx.query.get('marker') || '';
186
+ const metas = await ctx.store.listObjects(ctx.bucket);
187
+ const page = paginate(metas, { ...params, marker });
188
+
189
+ const result = {
190
+ Name: ctx.bucket,
191
+ Prefix: encodeListValue(params.prefix, params.encodingType) || '',
192
+ Marker: encodeListValue(marker, params.encodingType) || '',
193
+ MaxKeys: params.maxKeys,
194
+ EncodingType: params.encodingType,
195
+ Delimiter:
196
+ encodeListValue(params.delimiter, params.encodingType) || undefined,
197
+ IsTruncated: page.truncated,
198
+ Contents: page.contents.map((meta) =>
199
+ contentsEntry(meta, params.encodingType)
200
+ ),
201
+ CommonPrefixes: page.prefixes.map((prefix) => ({
202
+ Prefix: encodeListValue(prefix, params.encodingType),
203
+ })),
204
+ };
205
+ if (page.truncated && params.delimiter) {
206
+ result.NextMarker = encodeListValue(page.lastItem, params.encodingType);
207
+ }
208
+ sendDoc(ctx.res, 200, 'ListBucketResult', result);
209
+ }
210
+
211
+ async function listObjectsV2(ctx) {
212
+ await requireBucket(ctx);
213
+ const params = parseListParams(ctx.query);
214
+ const token = ctx.query.get('continuation-token');
215
+ const startAfter = ctx.query.get('start-after') || '';
216
+ const fetchOwner = ctx.query.get('fetch-owner') === 'true';
217
+ let marker = startAfter;
218
+ if (token) {
219
+ try {
220
+ marker = Buffer.from(token, 'base64').toString('utf8');
221
+ } catch {
222
+ throw new S3Error(
223
+ 'InvalidArgument',
224
+ 'The continuation token provided is incorrect'
225
+ );
226
+ }
227
+ }
228
+ const metas = await ctx.store.listObjects(ctx.bucket);
229
+ const page = paginate(metas, { ...params, marker });
230
+
231
+ const result = {
232
+ Name: ctx.bucket,
233
+ Prefix: encodeListValue(params.prefix, params.encodingType) || '',
234
+ MaxKeys: params.maxKeys,
235
+ EncodingType: params.encodingType,
236
+ Delimiter:
237
+ encodeListValue(params.delimiter, params.encodingType) || undefined,
238
+ KeyCount: page.contents.length + page.prefixes.length,
239
+ IsTruncated: page.truncated,
240
+ ContinuationToken: token || undefined,
241
+ StartAfter: encodeListValue(startAfter, params.encodingType) || undefined,
242
+ Contents: page.contents.map((meta) =>
243
+ contentsEntry(meta, params.encodingType, { owner: fetchOwner })
244
+ ),
245
+ CommonPrefixes: page.prefixes.map((prefix) => ({
246
+ Prefix: encodeListValue(prefix, params.encodingType),
247
+ })),
248
+ };
249
+ if (page.truncated) {
250
+ result.NextContinuationToken = Buffer.from(page.lastItem, 'utf8').toString(
251
+ 'base64'
252
+ );
253
+ }
254
+ sendDoc(ctx.res, 200, 'ListBucketResult', result);
255
+ }
256
+
257
+ async function listObjectVersions(ctx) {
258
+ await requireBucket(ctx);
259
+ const params = parseListParams(ctx.query);
260
+ const marker = ctx.query.get('key-marker') || '';
261
+ const metas = await ctx.store.listObjects(ctx.bucket);
262
+ const page = paginate(metas, { ...params, marker });
263
+
264
+ const result = {
265
+ Name: ctx.bucket,
266
+ Prefix: encodeListValue(params.prefix, params.encodingType) || '',
267
+ KeyMarker: encodeListValue(marker, params.encodingType) || '',
268
+ VersionIdMarker: '',
269
+ MaxKeys: params.maxKeys,
270
+ EncodingType: params.encodingType,
271
+ Delimiter:
272
+ encodeListValue(params.delimiter, params.encodingType) || undefined,
273
+ IsTruncated: page.truncated,
274
+ Version: page.contents.map((meta) => ({
275
+ Key: encodeListValue(meta.key, params.encodingType),
276
+ VersionId: 'null',
277
+ IsLatest: true,
278
+ LastModified: isoDate(meta.lastModified),
279
+ ETag: `"${meta.etag}"`,
280
+ Size: meta.size,
281
+ StorageClass: meta.storageClass || 'STANDARD',
282
+ Owner: DUMMY_OWNER,
283
+ })),
284
+ CommonPrefixes: page.prefixes.map((prefix) => ({
285
+ Prefix: encodeListValue(prefix, params.encodingType),
286
+ })),
287
+ };
288
+ if (page.truncated) {
289
+ result.NextKeyMarker = encodeListValue(page.lastItem, params.encodingType);
290
+ result.NextVersionIdMarker = '';
291
+ }
292
+ sendDoc(ctx.res, 200, 'ListVersionsResult', result);
293
+ }
294
+
295
+ async function listMultipartUploads(ctx) {
296
+ await requireBucket(ctx);
297
+ const params = parseListParams(ctx.query);
298
+ const keyMarker = ctx.query.get('key-marker') || '';
299
+ const uploads = (await ctx.store.listUploads(ctx.bucket)).filter(
300
+ (upload) =>
301
+ (!params.prefix || upload.key.startsWith(params.prefix)) &&
302
+ (!keyMarker || upload.key > keyMarker)
303
+ );
304
+ const maxUploads = Math.min(
305
+ parseInt(ctx.query.get('max-uploads') || '1000', 10) || 1000,
306
+ 1000
307
+ );
308
+ const page = uploads.slice(0, maxUploads);
309
+ const truncated = uploads.length > page.length;
310
+
311
+ sendDoc(ctx.res, 200, 'ListMultipartUploadsResult', {
312
+ Bucket: ctx.bucket,
313
+ KeyMarker: keyMarker,
314
+ UploadIdMarker: '',
315
+ NextKeyMarker: truncated ? page[page.length - 1].key : undefined,
316
+ NextUploadIdMarker: truncated ? page[page.length - 1].uploadId : undefined,
317
+ Prefix: encodeListValue(params.prefix, params.encodingType) || '',
318
+ Delimiter:
319
+ encodeListValue(params.delimiter, params.encodingType) || undefined,
320
+ EncodingType: params.encodingType,
321
+ MaxUploads: maxUploads,
322
+ IsTruncated: truncated,
323
+ Upload: page.map((upload) => ({
324
+ Key: encodeListValue(upload.key, params.encodingType),
325
+ UploadId: upload.uploadId,
326
+ Initiator: DUMMY_OWNER,
327
+ Owner: DUMMY_OWNER,
328
+ StorageClass: upload.storageClass || 'STANDARD',
329
+ Initiated: isoDate(upload.initiated),
330
+ ChecksumAlgorithm: upload.checksumAlgorithm,
331
+ ChecksumType: upload.checksumType,
332
+ })),
333
+ });
334
+ }
335
+
336
+ // ---------------------------------------------------------------- PUT
337
+
338
+ const SUPPORTED_CORS_METHODS = new Set([
339
+ 'GET',
340
+ 'PUT',
341
+ 'POST',
342
+ 'DELETE',
343
+ 'HEAD',
344
+ ]);
345
+
346
+ function parseCorsConfig(xmlString) {
347
+ let parsed;
348
+ try {
349
+ parsed = parseXml(xmlString);
350
+ } catch {
351
+ throw new S3Error(
352
+ 'MalformedXML',
353
+ 'The XML you provided was not well-formed or did not validate against our published schema'
354
+ );
355
+ }
356
+ if (!parsed.CORSConfiguration) {
357
+ throw new S3Error(
358
+ 'MalformedXML',
359
+ 'The XML you provided was not well-formed or did not validate against our published schema'
360
+ );
361
+ }
362
+ const rules = asArray(parsed.CORSConfiguration.CORSRule).map((rule) => {
363
+ const allowedMethods = asArray(rule.AllowedMethod);
364
+ for (const method of allowedMethods) {
365
+ if (!SUPPORTED_CORS_METHODS.has(method)) {
366
+ throw new S3Error(
367
+ 'InvalidRequest',
368
+ `Found unsupported HTTP method in CORS config. Unsupported method is ${method}`
369
+ );
370
+ }
371
+ }
372
+ return {
373
+ allowedOrigins: asArray(rule.AllowedOrigin),
374
+ allowedMethods,
375
+ allowedHeaders: asArray(rule.AllowedHeader).map((header) =>
376
+ header.toLowerCase()
377
+ ),
378
+ exposeHeaders: asArray(rule.ExposeHeader),
379
+ maxAgeSeconds: rule.MaxAgeSeconds
380
+ ? parseInt(rule.MaxAgeSeconds, 10)
381
+ : undefined,
382
+ };
383
+ });
384
+ if (rules.length === 0) {
385
+ throw new S3Error(
386
+ 'MalformedXML',
387
+ 'The XML you provided was not well-formed or did not validate against our published schema'
388
+ );
389
+ }
390
+ return { xml: xmlString, rules };
391
+ }
392
+
393
+ function parseWebsiteConfig(xmlString) {
394
+ let parsed;
395
+ try {
396
+ parsed = parseXml(xmlString);
397
+ } catch {
398
+ throw new S3Error(
399
+ 'MalformedXML',
400
+ 'The XML you provided was not well-formed or did not validate against our published schema'
401
+ );
402
+ }
403
+ const config = parsed.WebsiteConfiguration;
404
+ if (!config) {
405
+ throw new S3Error(
406
+ 'MalformedXML',
407
+ 'The XML you provided was not well-formed or did not validate against our published schema'
408
+ );
409
+ }
410
+ const website = { xml: xmlString };
411
+ if (config.RedirectAllRequestsTo) {
412
+ website.redirectAllRequestsTo = {
413
+ hostName: config.RedirectAllRequestsTo.HostName,
414
+ protocol: config.RedirectAllRequestsTo.Protocol || 'http',
415
+ };
416
+ return website;
417
+ }
418
+ if (!config.IndexDocument || !config.IndexDocument.Suffix) {
419
+ throw new S3Error(
420
+ 'InvalidArgument',
421
+ 'A value for IndexDocument Suffix must be provided if RedirectAllRequestsTo is empty'
422
+ );
423
+ }
424
+ website.indexDocument = config.IndexDocument.Suffix;
425
+ if (config.ErrorDocument) website.errorDocument = config.ErrorDocument.Key;
426
+ return website;
427
+ }
428
+
429
+ function parseTaggingConfig(xmlString) {
430
+ let parsed;
431
+ try {
432
+ parsed = parseXml(xmlString);
433
+ } catch {
434
+ throw new S3Error(
435
+ 'MalformedXML',
436
+ 'The XML you provided was not well-formed or did not validate against our published schema'
437
+ );
438
+ }
439
+ const tagging = parsed.Tagging;
440
+ if (!tagging || !tagging.TagSet) {
441
+ throw new S3Error(
442
+ 'MalformedXML',
443
+ 'The XML you provided was not well-formed or did not validate against our published schema'
444
+ );
445
+ }
446
+ const tags = {};
447
+ for (const tag of asArray(tagging.TagSet.Tag)) {
448
+ if (typeof tag.Key !== 'string') {
449
+ throw new S3Error(
450
+ 'MalformedXML',
451
+ 'The XML you provided was not well-formed or did not validate against our published schema'
452
+ );
453
+ }
454
+ tags[tag.Key] = typeof tag.Value === 'string' ? tag.Value : '';
455
+ }
456
+ return { xml: xmlString, tags };
457
+ }
458
+
459
+ async function putBucket(ctx) {
460
+ const { query, res } = ctx;
461
+
462
+ if (query.has('cors')) {
463
+ await requireBucket(ctx);
464
+ const body = await readBody(ctx.req);
465
+ const config = parseCorsConfig(body.toString('utf8'));
466
+ await ctx.store.putBucketConfig(ctx.bucket, 'cors', config);
467
+ send(res, 200, {});
468
+ return;
469
+ }
470
+ if (query.has('website')) {
471
+ await requireBucket(ctx);
472
+ const body = await readBody(ctx.req);
473
+ const config = parseWebsiteConfig(body.toString('utf8'));
474
+ await ctx.store.putBucketConfig(ctx.bucket, 'website', config);
475
+ send(res, 200, {});
476
+ return;
477
+ }
478
+ if (query.has('tagging')) {
479
+ await requireBucket(ctx);
480
+ const body = await readBody(ctx.req);
481
+ const config = parseTaggingConfig(body.toString('utf8'));
482
+ await ctx.store.putBucketConfig(ctx.bucket, 'tagging', config);
483
+ send(res, 204, {});
484
+ return;
485
+ }
486
+ if (query.has('acl')) {
487
+ await requireBucket(ctx);
488
+ await readBody(ctx.req);
489
+ send(res, 200, {});
490
+ return;
491
+ }
492
+ if (query.has('versioning')) {
493
+ await requireBucket(ctx);
494
+ const body = await readBody(ctx.req);
495
+ let status;
496
+ try {
497
+ status = parseXml(body.toString('utf8')).VersioningConfiguration?.Status;
498
+ } catch {
499
+ throw new S3Error(
500
+ 'MalformedXML',
501
+ 'The XML you provided was not well-formed or did not validate against our published schema'
502
+ );
503
+ }
504
+ if (status === 'Enabled') {
505
+ throw new S3Error(
506
+ 'NotImplemented',
507
+ 'Bucket versioning is not supported by this server'
508
+ );
509
+ }
510
+ send(res, 200, {});
511
+ return;
512
+ }
513
+ const knownUnsupported = [
514
+ 'lifecycle',
515
+ 'policy',
516
+ 'encryption',
517
+ 'object-lock',
518
+ 'publicAccessBlock',
519
+ 'notification',
520
+ 'replication',
521
+ 'requestPayment',
522
+ 'logging',
523
+ 'accelerate',
524
+ 'analytics',
525
+ 'metrics',
526
+ 'inventory',
527
+ 'intelligent-tiering',
528
+ 'ownershipControls',
529
+ ];
530
+ for (const subresource of knownUnsupported) {
531
+ if (query.has(subresource)) {
532
+ await readBody(ctx.req);
533
+ // Accept and ignore so provisioning scripts keep working.
534
+ send(res, 200, {});
535
+ return;
536
+ }
537
+ }
538
+
539
+ // CreateBucket
540
+ validateBucketName(ctx.bucket);
541
+ const body = await readBody(ctx.req);
542
+ let location;
543
+ if (body.length > 0) {
544
+ try {
545
+ const parsed = parseXml(body.toString('utf8'));
546
+ location =
547
+ parsed.CreateBucketConfiguration?.LocationConstraint || undefined;
548
+ if (location && typeof location !== 'string') location = undefined;
549
+ } catch {
550
+ throw new S3Error(
551
+ 'MalformedXML',
552
+ 'The XML you provided was not well-formed or did not validate against our published schema'
553
+ );
554
+ }
555
+ }
556
+ const { created } = await ctx.store.createBucket(ctx.bucket);
557
+ if (created && location) {
558
+ await ctx.store.putBucketConfig(ctx.bucket, 'location', location);
559
+ }
560
+ // Matching us-east-1 behavior: re-creating a bucket you own succeeds.
561
+ send(ctx.res, 200, { Location: `/${ctx.bucket}` });
562
+ }
563
+
564
+ // ---------------------------------------------------------------- DELETE
565
+
566
+ async function deleteBucket(ctx) {
567
+ const { query, res } = ctx;
568
+ const configSubresources = {
569
+ cors: 'cors',
570
+ website: 'website',
571
+ tagging: 'tagging',
572
+ lifecycle: null,
573
+ policy: null,
574
+ encryption: null,
575
+ publicAccessBlock: null,
576
+ replication: null,
577
+ };
578
+ for (const [subresource, configKey] of Object.entries(configSubresources)) {
579
+ if (query.has(subresource)) {
580
+ await requireBucket(ctx);
581
+ if (configKey)
582
+ await ctx.store.putBucketConfig(ctx.bucket, configKey, null);
583
+ send(res, 204, {});
584
+ return;
585
+ }
586
+ }
587
+ await requireBucket(ctx);
588
+ const result = await ctx.store.deleteBucket(ctx.bucket);
589
+ if (!result.deleted) {
590
+ throw new S3Error(
591
+ 'BucketNotEmpty',
592
+ 'The bucket you tried to delete is not empty',
593
+ {
594
+ BucketName: ctx.bucket,
595
+ }
596
+ );
597
+ }
598
+ send(res, 204, {});
599
+ }
600
+
601
+ // ---------------------------------------------------------------- HEAD
602
+
603
+ async function headBucket(ctx) {
604
+ const meta = await ctx.store.getBucket(ctx.bucket);
605
+ if (!meta) {
606
+ send(ctx.res, 404, {});
607
+ return;
608
+ }
609
+ send(ctx.res, 200, {
610
+ 'x-amz-bucket-region': meta.configs.location || ctx.options.region,
611
+ });
612
+ }
613
+
614
+ module.exports = {
615
+ requireBucket,
616
+ getBucket,
617
+ putBucket,
618
+ deleteBucket,
619
+ headBucket,
620
+ dummyAclXml,
621
+ parseCorsConfig,
622
+ parseWebsiteConfig,
623
+ parseTaggingConfig,
624
+ };