local-serverless-stack 1.0.0 → 1.1.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/CHANGELOG.md +125 -3
- package/README.md +92 -38
- package/bin/cli.js +218 -28
- package/bin/i18n.js +57 -3
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/namespaces/services.d.ts +46 -0
- package/dist/client/namespaces/services.d.ts.map +1 -1
- package/dist/client/namespaces/services.js +1 -0
- package/dist/client/namespaces/services.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +17 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/engine/backends/self-backend.d.ts +26 -1
- package/dist/server/engine/backends/self-backend.d.ts.map +1 -1
- package/dist/server/engine/backends/self-backend.js +104 -11
- package/dist/server/engine/backends/self-backend.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.js +12 -1
- package/dist/server/engine/emulators/dynamodb/expressions/parser.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts +3 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts.map +1 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js +78 -0
- package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js.map +1 -0
- package/dist/server/engine/emulators/dynamodb/index.d.ts +11 -0
- package/dist/server/engine/emulators/dynamodb/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/index.js +101 -7
- package/dist/server/engine/emulators/dynamodb/index.js.map +1 -1
- package/dist/server/engine/emulators/dynamodb/query.d.ts +1 -0
- package/dist/server/engine/emulators/dynamodb/query.d.ts.map +1 -1
- package/dist/server/engine/emulators/dynamodb/query.js +34 -6
- package/dist/server/engine/emulators/dynamodb/query.js.map +1 -1
- package/dist/server/engine/emulators/s3/index.d.ts +10 -0
- package/dist/server/engine/emulators/s3/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/s3/index.js +369 -25
- package/dist/server/engine/emulators/s3/index.js.map +1 -1
- package/dist/server/engine/emulators/sqs/index.d.ts +2 -0
- package/dist/server/engine/emulators/sqs/index.d.ts.map +1 -1
- package/dist/server/engine/emulators/sqs/index.js +70 -7
- package/dist/server/engine/emulators/sqs/index.js.map +1 -1
- package/dist/server/engine/emulators/sqs/queue.d.ts +2 -1
- package/dist/server/engine/emulators/sqs/queue.d.ts.map +1 -1
- package/dist/server/engine/emulators/sqs/queue.js +68 -35
- package/dist/server/engine/emulators/sqs/queue.js.map +1 -1
- package/dist/server/engine/emulators/ssm/index.d.ts +31 -0
- package/dist/server/engine/emulators/ssm/index.d.ts.map +1 -0
- package/dist/server/engine/emulators/ssm/index.js +670 -0
- package/dist/server/engine/emulators/ssm/index.js.map +1 -0
- package/dist/server/engine/http/errors.d.ts +1 -0
- package/dist/server/engine/http/errors.d.ts.map +1 -1
- package/dist/server/engine/http/errors.js +13 -5
- package/dist/server/engine/http/errors.js.map +1 -1
- package/dist/server/engine/http/is-aws-request.d.ts.map +1 -1
- package/dist/server/engine/http/is-aws-request.js +13 -3
- package/dist/server/engine/http/is-aws-request.js.map +1 -1
- package/dist/server/engine/http/protocols/aws-json.d.ts +1 -0
- package/dist/server/engine/http/protocols/aws-json.d.ts.map +1 -1
- package/dist/server/engine/http/protocols/aws-json.js +13 -0
- package/dist/server/engine/http/protocols/aws-json.js.map +1 -1
- package/dist/server/engine/http/reset.d.ts +4 -0
- package/dist/server/engine/http/reset.d.ts.map +1 -0
- package/dist/server/engine/http/reset.js +17 -0
- package/dist/server/engine/http/reset.js.map +1 -0
- package/dist/server/engine/http/router.d.ts +1 -0
- package/dist/server/engine/http/router.d.ts.map +1 -1
- package/dist/server/engine/http/router.js +185 -34
- package/dist/server/engine/http/router.js.map +1 -1
- package/dist/server/engine/types.d.ts +2 -1
- package/dist/server/engine/types.d.ts.map +1 -1
- package/dist/server/routes/apis.d.ts.map +1 -1
- package/dist/server/routes/apis.js +6 -0
- package/dist/server/routes/apis.js.map +1 -1
- package/dist/server/routes/config.d.ts.map +1 -1
- package/dist/server/routes/config.js +25 -1
- package/dist/server/routes/config.js.map +1 -1
- package/dist/server/routes/lambdas.d.ts.map +1 -1
- package/dist/server/routes/lambdas.js +12 -0
- package/dist/server/routes/lambdas.js.map +1 -1
- package/dist/server/routes/services.d.ts.map +1 -1
- package/dist/server/routes/services.js +66 -5
- package/dist/server/routes/services.js.map +1 -1
- package/dist/server/runtime/runtime-worker.js +40 -3
- package/dist/server/runtime/runtime-worker.js.map +1 -1
- package/dist/server/services/api-gateway-events.d.ts +14 -0
- package/dist/server/services/api-gateway-events.d.ts.map +1 -1
- package/dist/server/services/api-gateway-events.js +114 -8
- package/dist/server/services/api-gateway-events.js.map +1 -1
- package/dist/server/services/artifact-staleness.d.ts +20 -0
- package/dist/server/services/artifact-staleness.d.ts.map +1 -0
- package/dist/server/services/artifact-staleness.js +115 -0
- package/dist/server/services/artifact-staleness.js.map +1 -0
- package/dist/server/services/authorizer-service.d.ts +34 -5
- package/dist/server/services/authorizer-service.d.ts.map +1 -1
- package/dist/server/services/authorizer-service.js +0 -0
- package/dist/server/services/authorizer-service.js.map +1 -1
- package/dist/server/services/cache-manager.d.ts +5 -0
- package/dist/server/services/cache-manager.d.ts.map +1 -1
- package/dist/server/services/cache-manager.js.map +1 -1
- package/dist/server/services/cfn-intrinsics.d.ts +63 -0
- package/dist/server/services/cfn-intrinsics.d.ts.map +1 -0
- package/dist/server/services/cfn-intrinsics.js +403 -0
- package/dist/server/services/cfn-intrinsics.js.map +1 -0
- package/dist/server/services/cfn-physical-values.d.ts +51 -0
- package/dist/server/services/cfn-physical-values.d.ts.map +1 -0
- package/dist/server/services/cfn-physical-values.js +186 -0
- package/dist/server/services/cfn-physical-values.js.map +1 -0
- package/dist/server/services/cloudformation-parser.d.ts +26 -3
- package/dist/server/services/cloudformation-parser.d.ts.map +1 -1
- package/dist/server/services/cloudformation-parser.js +79 -7
- package/dist/server/services/cloudformation-parser.js.map +1 -1
- package/dist/server/services/config-manager.d.ts +49 -2
- package/dist/server/services/config-manager.d.ts.map +1 -1
- package/dist/server/services/config-manager.js +206 -24
- package/dist/server/services/config-manager.js.map +1 -1
- package/dist/server/services/gateway-manager.d.ts.map +1 -1
- package/dist/server/services/gateway-manager.js +52 -20
- package/dist/server/services/gateway-manager.js.map +1 -1
- package/dist/server/services/lambda-runtime-manager.d.ts +2 -0
- package/dist/server/services/lambda-runtime-manager.d.ts.map +1 -1
- package/dist/server/services/lambda-runtime-manager.js +49 -15
- package/dist/server/services/lambda-runtime-manager.js.map +1 -1
- package/dist/server/services/raw-api-assembler.d.ts +1 -1
- package/dist/server/services/raw-api-assembler.d.ts.map +1 -1
- package/dist/server/services/raw-api-assembler.js +18 -8
- package/dist/server/services/raw-api-assembler.js.map +1 -1
- package/dist/server/services/resource-provisioner.d.ts +31 -1
- package/dist/server/services/resource-provisioner.d.ts.map +1 -1
- package/dist/server/services/resource-provisioner.js +257 -25
- package/dist/server/services/resource-provisioner.js.map +1 -1
- package/dist/server/services/s3-explorer.js +1 -1
- package/dist/server/services/seed-manager.d.ts.map +1 -1
- package/dist/server/services/seed-manager.js +11 -6
- package/dist/server/services/seed-manager.js.map +1 -1
- package/dist/server/services/serverless-state-parser.d.ts +45 -2
- package/dist/server/services/serverless-state-parser.d.ts.map +1 -1
- package/dist/server/services/serverless-state-parser.js +217 -29
- package/dist/server/services/serverless-state-parser.js.map +1 -1
- package/dist/server/services/service-graph.d.ts +76 -0
- package/dist/server/services/service-graph.d.ts.map +1 -0
- package/dist/server/services/service-graph.js +914 -0
- package/dist/server/services/service-graph.js.map +1 -0
- package/dist/server/services/service-registrar.d.ts +17 -1
- package/dist/server/services/service-registrar.d.ts.map +1 -1
- package/dist/server/services/service-registrar.js +190 -66
- package/dist/server/services/service-registrar.js.map +1 -1
- package/dist/server/services/service-scanner.d.ts +7 -2
- package/dist/server/services/service-scanner.d.ts.map +1 -1
- package/dist/server/services/service-scanner.js +53 -7
- package/dist/server/services/service-scanner.js.map +1 -1
- package/dist/server/services/source-watcher.d.ts +1 -0
- package/dist/server/services/source-watcher.d.ts.map +1 -1
- package/dist/server/services/source-watcher.js +19 -1
- package/dist/server/services/source-watcher.js.map +1 -1
- package/dist/server/services/worker-runtime.d.ts +82 -0
- package/dist/server/services/worker-runtime.d.ts.map +1 -0
- package/dist/server/services/worker-runtime.js +303 -0
- package/dist/server/services/worker-runtime.js.map +1 -0
- package/dist/ui/assets/ApisPage-BkW-QZ0X.js +1 -0
- package/dist/ui/assets/BucketDetailPage-CJwTQJh7.js +1 -0
- package/dist/ui/assets/BucketsPage-B1xPVhZC.js +1 -0
- package/dist/ui/assets/DynamoPage-DvbpwZDc.js +1 -0
- package/dist/ui/assets/DynamoTablePage-xn3Lq3so.js +3 -0
- package/dist/ui/assets/LambdaDetailPage-BhUyO77H.js +4 -0
- package/dist/ui/assets/LambdasPage-CUF_4rOH.js +1 -0
- package/dist/ui/assets/OnboardingPage-U49THUzu.js +1 -0
- package/dist/ui/assets/OpenSearchCollectionPage-BJTVNCq4.js +1 -0
- package/dist/ui/assets/OpenSearchPage-4s-egkB9.js +1 -0
- package/dist/ui/assets/OverviewPage-DJeJQktG.js +1 -0
- package/dist/ui/assets/{OverviewPage-C1pBJ8aP.css → OverviewPage-DjQETmb_.css} +1 -1
- package/dist/ui/assets/QueueDetailPage-DWsy5Yt8.js +1 -0
- package/dist/ui/assets/QueuesPage-BZJSqman.js +1 -0
- package/dist/ui/assets/SecretsPage-YC9ua54G.js +1 -0
- package/dist/ui/assets/ServiceDetailPage-Cum5frP4.js +2 -0
- package/dist/ui/assets/ServicesPage-IpaPDuqx.js +2 -0
- package/dist/ui/assets/SettingsPage-BiCRU8O6.js +1 -0
- package/dist/ui/assets/index-BDlrAJhi.css +1 -0
- package/dist/ui/assets/index-BouKSS43.js +38 -0
- package/dist/ui/assets/resourceIcons-DZs28nbs.js +1 -0
- package/dist/ui/index.html +5 -2
- package/package.json +1 -1
- package/dist/ui/assets/ApisPage-BOuimwWz.js +0 -1
- package/dist/ui/assets/BucketDetailPage-vLYwhoQt.js +0 -1
- package/dist/ui/assets/BucketsPage-Cu5lU1Hp.js +0 -1
- package/dist/ui/assets/DynamoPage-y3TTEPUL.js +0 -1
- package/dist/ui/assets/DynamoTablePage-BnmSRZjc.js +0 -3
- package/dist/ui/assets/LambdaDetailPage-B5q_d27t.js +0 -4
- package/dist/ui/assets/LambdasPage-CJpRoEDx.js +0 -1
- package/dist/ui/assets/OnboardingPage-D02dymQi.js +0 -1
- package/dist/ui/assets/OpenSearchCollectionPage-B_wWTlfL.js +0 -1
- package/dist/ui/assets/OpenSearchPage-BLEgfF_g.js +0 -1
- package/dist/ui/assets/OverviewPage-EdJWiAt6.js +0 -1
- package/dist/ui/assets/QueueDetailPage-ByEYHvHe.js +0 -1
- package/dist/ui/assets/QueuesPage-CvwYHSll.js +0 -1
- package/dist/ui/assets/SecretsPage-DASw8Ntf.js +0 -1
- package/dist/ui/assets/ServiceDetailPage-BMsUDg6L.js +0 -2
- package/dist/ui/assets/ServicesPage-2JuiIiRr.js +0 -2
- package/dist/ui/assets/SettingsPage-DStO4bgN.js +0 -1
- package/dist/ui/assets/index-BIEznsi4.js +0 -37
- package/dist/ui/assets/index-BRMvNP3Y.css +0 -1
- package/dist/ui/assets/resourceIcons-baecADXA.js +0 -1
|
@@ -13,6 +13,83 @@ const OWNER_ID = '75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba0
|
|
|
13
13
|
const OWNER_DISPLAY_NAME = 'webfile';
|
|
14
14
|
const DEFAULT_CONTENT_TYPE = 'binary/octet-stream';
|
|
15
15
|
const METADATA_HEADER_PREFIX = 'x-amz-meta-';
|
|
16
|
+
const SSE_ALGORITHMS = ['AES256', 'aws:kms', 'aws:kms:dsse'];
|
|
17
|
+
// Since January 2023 AWS applies SSE-S3 (AES256) to every bucket that has no
|
|
18
|
+
// explicit default-encryption rule, and GetBucketEncryption reports that rule
|
|
19
|
+
// instead of the old ServerSideEncryptionConfigurationNotFoundError — so a
|
|
20
|
+
// bucket without a stored configuration (and every bucket persisted before
|
|
21
|
+
// this field existed) answers with this default.
|
|
22
|
+
const DEFAULT_BUCKET_ENCRYPTION = { sseAlgorithm: 'AES256', bucketKeyEnabled: false };
|
|
23
|
+
// Since April 2023 every new AWS bucket is created with all four Block Public
|
|
24
|
+
// Access settings on, and GetPublicAccessBlock reports them. Only an explicit
|
|
25
|
+
// DeletePublicAccessBlock makes the call answer
|
|
26
|
+
// NoSuchPublicAccessBlockConfiguration.
|
|
27
|
+
const DEFAULT_PUBLIC_ACCESS_BLOCK = {
|
|
28
|
+
blockPublicAcls: true,
|
|
29
|
+
ignorePublicAcls: true,
|
|
30
|
+
blockPublicPolicy: true,
|
|
31
|
+
restrictPublicBuckets: true,
|
|
32
|
+
};
|
|
33
|
+
// Request header (PutObject / CopyObject REPLACE) ↔ record field for the
|
|
34
|
+
// stored system metadata. The same names are accepted as presigned-POST form
|
|
35
|
+
// fields, and objectHeaders() emits them under these canonical spellings.
|
|
36
|
+
const SYSTEM_METADATA_HEADERS = [
|
|
37
|
+
['contentDisposition', 'Content-Disposition'],
|
|
38
|
+
['cacheControl', 'Cache-Control'],
|
|
39
|
+
['contentEncoding', 'Content-Encoding'],
|
|
40
|
+
['contentLanguage', 'Content-Language'],
|
|
41
|
+
['expires', 'Expires'],
|
|
42
|
+
];
|
|
43
|
+
const SSE_HEADER = 'x-amz-server-side-encryption';
|
|
44
|
+
const SSE_KMS_KEY_HEADER = 'x-amz-server-side-encryption-aws-kms-key-id';
|
|
45
|
+
const SSE_BUCKET_KEY_HEADER = 'x-amz-server-side-encryption-bucket-key-enabled';
|
|
46
|
+
// Query keys that turn a bucket request into a subresource operation, mapped
|
|
47
|
+
// to the stem of the AWS operation name (Put/Get/Delete + stem). A request
|
|
48
|
+
// carrying one of these must NEVER fall through to CreateBucket/DeleteBucket:
|
|
49
|
+
// a PutBucketTagging answered as CreateBucket is a 409, and a
|
|
50
|
+
// DeleteBucketTagging answered as DeleteBucket destroys the (empty) bucket.
|
|
51
|
+
// Keys the engine does not serve answer NotImplemented (501, as AWS does for
|
|
52
|
+
// an operation it does not support) — see handleBucketSubresource().
|
|
53
|
+
const BUCKET_SUBRESOURCES = {
|
|
54
|
+
accelerate: 'BucketAccelerateConfiguration',
|
|
55
|
+
acl: 'BucketAcl',
|
|
56
|
+
analytics: 'BucketAnalyticsConfiguration',
|
|
57
|
+
cors: 'BucketCors',
|
|
58
|
+
delete: 'Objects',
|
|
59
|
+
encryption: 'BucketEncryption',
|
|
60
|
+
'intelligent-tiering': 'BucketIntelligentTieringConfiguration',
|
|
61
|
+
inventory: 'BucketInventoryConfiguration',
|
|
62
|
+
lifecycle: 'BucketLifecycleConfiguration',
|
|
63
|
+
location: 'BucketLocation',
|
|
64
|
+
logging: 'BucketLogging',
|
|
65
|
+
metadataTable: 'BucketMetadataTableConfiguration',
|
|
66
|
+
metrics: 'BucketMetricsConfiguration',
|
|
67
|
+
notification: 'BucketNotificationConfiguration',
|
|
68
|
+
'object-lock': 'ObjectLockConfiguration',
|
|
69
|
+
ownershipControls: 'BucketOwnershipControls',
|
|
70
|
+
policy: 'BucketPolicy',
|
|
71
|
+
policyStatus: 'BucketPolicyStatus',
|
|
72
|
+
publicAccessBlock: 'PublicAccessBlock',
|
|
73
|
+
replication: 'BucketReplication',
|
|
74
|
+
requestPayment: 'BucketRequestPayment',
|
|
75
|
+
tagging: 'BucketTagging',
|
|
76
|
+
versioning: 'BucketVersioning',
|
|
77
|
+
versions: 'ObjectVersions',
|
|
78
|
+
website: 'BucketWebsite',
|
|
79
|
+
};
|
|
80
|
+
// Same guard for objects: PUT /bucket/key?tagging must not overwrite the
|
|
81
|
+
// object with the tagging XML, DELETE ...?tagging must not delete it, and
|
|
82
|
+
// GET ...?acl must not return the body. None of these is served yet.
|
|
83
|
+
const OBJECT_SUBRESOURCES = {
|
|
84
|
+
acl: 'ObjectAcl',
|
|
85
|
+
attributes: 'ObjectAttributes',
|
|
86
|
+
'legal-hold': 'ObjectLegalHold',
|
|
87
|
+
restore: 'Object',
|
|
88
|
+
retention: 'ObjectRetention',
|
|
89
|
+
select: 'ObjectContent',
|
|
90
|
+
tagging: 'ObjectTagging',
|
|
91
|
+
torrent: 'ObjectTorrent',
|
|
92
|
+
};
|
|
16
93
|
export class S3Emulator {
|
|
17
94
|
service = 's3';
|
|
18
95
|
ctx;
|
|
@@ -107,24 +184,14 @@ export class S3Emulator {
|
|
|
107
184
|
// Bucket-level operations
|
|
108
185
|
// -------------------------------------------------------------------------
|
|
109
186
|
async handleBucket(req, bucket) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return this.putBucketCors(bucket, req);
|
|
187
|
+
const subresource = findSubresource(req.query, BUCKET_SUBRESOURCES);
|
|
188
|
+
if (subresource !== undefined)
|
|
189
|
+
return this.handleBucketSubresource(req, bucket, subresource);
|
|
190
|
+
// From here on the request carries no subresource: a bare PUT is
|
|
191
|
+
// CreateBucket and a bare DELETE is DeleteBucket.
|
|
192
|
+
if (req.method === 'PUT')
|
|
117
193
|
return this.createBucket(bucket, req);
|
|
118
|
-
}
|
|
119
194
|
if (req.method === 'GET') {
|
|
120
|
-
if ('location' in req.query)
|
|
121
|
-
return this.getBucketLocation(bucket);
|
|
122
|
-
if ('versioning' in req.query)
|
|
123
|
-
return this.getBucketVersioning(bucket);
|
|
124
|
-
if ('notification' in req.query)
|
|
125
|
-
return this.getBucketNotification(bucket);
|
|
126
|
-
if ('cors' in req.query)
|
|
127
|
-
return this.getBucketCors(bucket);
|
|
128
195
|
if (req.query['list-type'] === '2')
|
|
129
196
|
return this.listObjectsV2(bucket, req.query);
|
|
130
197
|
throw notImplementedOperation('s3', 'ListObjects (v1) — request with list-type=2');
|
|
@@ -133,19 +200,75 @@ export class S3Emulator {
|
|
|
133
200
|
this.requireBucket(bucket);
|
|
134
201
|
return { status: 200 };
|
|
135
202
|
}
|
|
136
|
-
if (req.method === 'DELETE')
|
|
137
|
-
if ('cors' in req.query)
|
|
138
|
-
return this.deleteBucketCors(bucket);
|
|
203
|
+
if (req.method === 'DELETE')
|
|
139
204
|
return this.deleteBucket(bucket);
|
|
140
|
-
}
|
|
141
|
-
if (req.method === 'POST' && 'delete' in req.query)
|
|
142
|
-
return this.deleteObjects(bucket, req);
|
|
143
205
|
// Browser form upload (presigned POST): multipart/form-data to the bucket.
|
|
144
206
|
if (req.method === 'POST' && multipartBoundary(req.headers['content-type'])) {
|
|
145
207
|
return this.postObject(bucket, req);
|
|
146
208
|
}
|
|
147
209
|
throw notImplementedOperation('s3', `${req.method} /<bucket>`);
|
|
148
210
|
}
|
|
211
|
+
async handleBucketSubresource(req, bucket, subresource) {
|
|
212
|
+
switch (`${req.method} ${subresource}`) {
|
|
213
|
+
case 'GET location':
|
|
214
|
+
return this.getBucketLocation(bucket);
|
|
215
|
+
case 'PUT versioning':
|
|
216
|
+
return this.putBucketVersioning(bucket, req);
|
|
217
|
+
case 'GET versioning':
|
|
218
|
+
return this.getBucketVersioning(bucket);
|
|
219
|
+
case 'PUT notification':
|
|
220
|
+
return this.putBucketNotification(bucket, req);
|
|
221
|
+
case 'GET notification':
|
|
222
|
+
return this.getBucketNotification(bucket);
|
|
223
|
+
case 'PUT cors':
|
|
224
|
+
return this.putBucketCors(bucket, req);
|
|
225
|
+
case 'GET cors':
|
|
226
|
+
return this.getBucketCors(bucket);
|
|
227
|
+
case 'DELETE cors':
|
|
228
|
+
return this.deleteBucketCors(bucket);
|
|
229
|
+
case 'POST delete':
|
|
230
|
+
return this.deleteObjects(bucket, req);
|
|
231
|
+
case 'PUT encryption':
|
|
232
|
+
return this.putBucketEncryption(bucket, req);
|
|
233
|
+
case 'GET encryption':
|
|
234
|
+
return this.getBucketEncryption(bucket);
|
|
235
|
+
case 'DELETE encryption':
|
|
236
|
+
return this.updateBucket(bucket, { encryption: undefined });
|
|
237
|
+
case 'PUT tagging':
|
|
238
|
+
return this.putBucketTagging(bucket, req);
|
|
239
|
+
case 'GET tagging':
|
|
240
|
+
return this.getBucketTagging(bucket);
|
|
241
|
+
case 'DELETE tagging':
|
|
242
|
+
return this.updateBucket(bucket, { tags: undefined });
|
|
243
|
+
case 'PUT publicAccessBlock':
|
|
244
|
+
return this.putPublicAccessBlock(bucket, req);
|
|
245
|
+
case 'GET publicAccessBlock':
|
|
246
|
+
return this.getPublicAccessBlock(bucket);
|
|
247
|
+
case 'DELETE publicAccessBlock':
|
|
248
|
+
return this.updateBucket(bucket, { publicAccessBlock: null });
|
|
249
|
+
case 'PUT policy':
|
|
250
|
+
return this.putBucketPolicy(bucket, req);
|
|
251
|
+
case 'GET policy':
|
|
252
|
+
return this.getBucketPolicy(bucket);
|
|
253
|
+
case 'DELETE policy':
|
|
254
|
+
return this.updateBucket(bucket, { policy: undefined });
|
|
255
|
+
default:
|
|
256
|
+
throw subresourceNotImplemented(req.method, subresource, BUCKET_SUBRESOURCES);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Shared tail of the Delete* configuration calls: AWS answers 204 even when
|
|
260
|
+
// nothing was configured. Fields set to undefined are dropped from the
|
|
261
|
+
// persisted record; DeleteBucketEncryption therefore reverts the bucket to
|
|
262
|
+
// the SSE-S3 default, exactly like AWS.
|
|
263
|
+
updateBucket(bucket, changes) {
|
|
264
|
+
const record = { ...this.requireBucket(bucket), ...changes };
|
|
265
|
+
for (const field of Object.keys(changes)) {
|
|
266
|
+
if (record[field] === undefined)
|
|
267
|
+
delete record[field];
|
|
268
|
+
}
|
|
269
|
+
this.buckets.set(bucket, record);
|
|
270
|
+
return { status: 204 };
|
|
271
|
+
}
|
|
149
272
|
createBucket(bucket, req) {
|
|
150
273
|
if (this.buckets.get(bucket)) {
|
|
151
274
|
throw s3Error('BucketAlreadyOwnedByYou', 'Your previous request to create the named bucket succeeded and you already own it.', 409);
|
|
@@ -236,6 +359,85 @@ export class S3Emulator {
|
|
|
236
359
|
this.buckets.set(bucket, rest);
|
|
237
360
|
return { status: 204 };
|
|
238
361
|
}
|
|
362
|
+
putBucketEncryption(bucket, req) {
|
|
363
|
+
const record = this.requireBucket(bucket);
|
|
364
|
+
this.buckets.set(bucket, { ...record, encryption: parseEncryptionConfiguration(req.body) });
|
|
365
|
+
return { status: 200 };
|
|
366
|
+
}
|
|
367
|
+
getBucketEncryption(bucket) {
|
|
368
|
+
const { encryption = DEFAULT_BUCKET_ENCRYPTION } = this.requireBucket(bucket);
|
|
369
|
+
const byDefault = tag('SSEAlgorithm', encryption.sseAlgorithm) +
|
|
370
|
+
(encryption.kmsKeyId !== undefined ? tag('KMSMasterKeyID', encryption.kmsKeyId) : '');
|
|
371
|
+
const rule = tag('Rule', tag('ApplyServerSideEncryptionByDefault', byDefault, { escape: false }) +
|
|
372
|
+
tag('BucketKeyEnabled', encryption.bucketKeyEnabled ? 'true' : 'false'), { escape: false });
|
|
373
|
+
return xmlResponse(200, tag('ServerSideEncryptionConfiguration', rule, { attrs: { xmlns: S3_XMLNS }, escape: false }));
|
|
374
|
+
}
|
|
375
|
+
putBucketTagging(bucket, req) {
|
|
376
|
+
const record = this.requireBucket(bucket);
|
|
377
|
+
const tags = parseTagging(req.body);
|
|
378
|
+
const { tags: previous, ...untagged } = record;
|
|
379
|
+
void previous;
|
|
380
|
+
// An empty TagSet leaves the bucket untagged — Get then answers NoSuchTagSet.
|
|
381
|
+
this.buckets.set(bucket, tags.length > 0 ? { ...untagged, tags } : untagged);
|
|
382
|
+
return { status: 204 };
|
|
383
|
+
}
|
|
384
|
+
getBucketTagging(bucket) {
|
|
385
|
+
const { tags } = this.requireBucket(bucket);
|
|
386
|
+
if (!tags)
|
|
387
|
+
throw s3Error('NoSuchTagSet', 'The TagSet does not exist', 404);
|
|
388
|
+
const tagXml = tags.map(t => tag('Tag', tag('Key', t.key) + tag('Value', t.value), { escape: false })).join('');
|
|
389
|
+
return xmlResponse(200, tag('Tagging', tag('TagSet', tagXml, { escape: false }), { attrs: { xmlns: S3_XMLNS }, escape: false }));
|
|
390
|
+
}
|
|
391
|
+
putPublicAccessBlock(bucket, req) {
|
|
392
|
+
const record = this.requireBucket(bucket);
|
|
393
|
+
const root = parseBodyXml(req.body);
|
|
394
|
+
// Omitted flags are false, as in AWS (the SDK only serializes set members).
|
|
395
|
+
const flag = (name) => (childText(root, name) ?? '').toLowerCase() === 'true';
|
|
396
|
+
this.buckets.set(bucket, {
|
|
397
|
+
...record,
|
|
398
|
+
publicAccessBlock: {
|
|
399
|
+
blockPublicAcls: flag('BlockPublicAcls'),
|
|
400
|
+
ignorePublicAcls: flag('IgnorePublicAcls'),
|
|
401
|
+
blockPublicPolicy: flag('BlockPublicPolicy'),
|
|
402
|
+
restrictPublicBuckets: flag('RestrictPublicBuckets'),
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
return { status: 200 };
|
|
406
|
+
}
|
|
407
|
+
getPublicAccessBlock(bucket) {
|
|
408
|
+
const { publicAccessBlock = DEFAULT_PUBLIC_ACCESS_BLOCK } = this.requireBucket(bucket);
|
|
409
|
+
if (publicAccessBlock === null) {
|
|
410
|
+
throw s3Error('NoSuchPublicAccessBlockConfiguration', 'The public access block configuration was not found', 404);
|
|
411
|
+
}
|
|
412
|
+
const content = tag('BlockPublicAcls', String(publicAccessBlock.blockPublicAcls)) +
|
|
413
|
+
tag('IgnorePublicAcls', String(publicAccessBlock.ignorePublicAcls)) +
|
|
414
|
+
tag('BlockPublicPolicy', String(publicAccessBlock.blockPublicPolicy)) +
|
|
415
|
+
tag('RestrictPublicBuckets', String(publicAccessBlock.restrictPublicBuckets));
|
|
416
|
+
return xmlResponse(200, tag('PublicAccessBlockConfiguration', content, { attrs: { xmlns: S3_XMLNS }, escape: false }));
|
|
417
|
+
}
|
|
418
|
+
// The policy is stored verbatim (AWS re-serializes it, but SDKs and IaC
|
|
419
|
+
// compare it as parsed JSON). It is never evaluated: the engine does not
|
|
420
|
+
// authorize requests.
|
|
421
|
+
putBucketPolicy(bucket, req) {
|
|
422
|
+
const record = this.requireBucket(bucket);
|
|
423
|
+
const policy = req.body.toString('utf8');
|
|
424
|
+
try {
|
|
425
|
+
const parsed = JSON.parse(policy);
|
|
426
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
427
|
+
throw new Error('not an object');
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
throw s3Error('MalformedPolicy', 'Policies must be valid JSON and the first byte must be \'{\'', 400);
|
|
431
|
+
}
|
|
432
|
+
this.buckets.set(bucket, { ...record, policy });
|
|
433
|
+
return { status: 204 };
|
|
434
|
+
}
|
|
435
|
+
getBucketPolicy(bucket) {
|
|
436
|
+
const { policy } = this.requireBucket(bucket);
|
|
437
|
+
if (policy === undefined)
|
|
438
|
+
throw s3Error('NoSuchBucketPolicy', 'The bucket policy does not exist', 404);
|
|
439
|
+
return { status: 200, headers: { 'Content-Type': 'application/json' }, body: policy };
|
|
440
|
+
}
|
|
239
441
|
async listObjectsV2(bucket, query) {
|
|
240
442
|
this.requireBucket(bucket);
|
|
241
443
|
const index = await this.index(bucket);
|
|
@@ -339,6 +541,9 @@ export class S3Emulator {
|
|
|
339
541
|
// Object-level operations
|
|
340
542
|
// -------------------------------------------------------------------------
|
|
341
543
|
async handleObject(req, bucket, key) {
|
|
544
|
+
const subresource = findSubresource(req.query, OBJECT_SUBRESOURCES);
|
|
545
|
+
if (subresource !== undefined)
|
|
546
|
+
throw subresourceNotImplemented(req.method, subresource, OBJECT_SUBRESOURCES);
|
|
342
547
|
if (req.method === 'PUT') {
|
|
343
548
|
if (req.headers['x-amz-copy-source'])
|
|
344
549
|
return this.copyObject(bucket, key, req);
|
|
@@ -366,14 +571,16 @@ export class S3Emulator {
|
|
|
366
571
|
blobPath,
|
|
367
572
|
contentType: req.headers['content-type'] ?? DEFAULT_CONTENT_TYPE,
|
|
368
573
|
metadata: metadataFromHeaders(req.headers),
|
|
574
|
+
...systemMetadataFromHeaders(req.headers),
|
|
369
575
|
storageClass: 'STANDARD',
|
|
576
|
+
sse: resolveSse(name => req.headers[name], bucketRecord),
|
|
370
577
|
};
|
|
371
578
|
index.put(key, record);
|
|
372
579
|
if (previous && previous.blobPath !== blobPath) {
|
|
373
580
|
await this.deleteBlobIfUnreferenced(index, previous.blobPath);
|
|
374
581
|
}
|
|
375
582
|
this.queueObjectEvent(bucketRecord.region, bucket, 's3:ObjectCreated:Put', key, record.size, eTag);
|
|
376
|
-
return { status: 200, headers: { ETag: `"${eTag}"
|
|
583
|
+
return { status: 200, headers: { ETag: `"${eTag}"`, ...sseHeaders(record) } };
|
|
377
584
|
}
|
|
378
585
|
// Presigned POST (browser form upload): the object key and options travel as
|
|
379
586
|
// form fields, the bytes as a `file` part. The POST policy/signature fields
|
|
@@ -397,6 +604,8 @@ export class S3Emulator {
|
|
|
397
604
|
const key = keyTemplate.replace(/\$\{filename\}/g, () => uploadName);
|
|
398
605
|
if (!key)
|
|
399
606
|
throw s3Error('InvalidArgument', 'The specified key is not valid.', 400);
|
|
607
|
+
// Validate the SSE fields before any byte is written.
|
|
608
|
+
const sse = resolveSse(lookup, bucketRecord);
|
|
400
609
|
const index = await this.index(bucket);
|
|
401
610
|
const eTag = md5Hex(file.data);
|
|
402
611
|
const blobPath = await this.ctx.store.writeBlob(`s3/${bucket}/blobs`, file.data);
|
|
@@ -408,14 +617,19 @@ export class S3Emulator {
|
|
|
408
617
|
blobPath,
|
|
409
618
|
contentType: lookup('content-type') ?? file.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
410
619
|
metadata: metadataFromFields(fields),
|
|
620
|
+
// Same names as the PutObject headers, as form fields (case-insensitive).
|
|
621
|
+
...systemMetadataFrom((_field, header) => lookup(header)),
|
|
411
622
|
storageClass: 'STANDARD',
|
|
623
|
+
sse,
|
|
412
624
|
};
|
|
413
625
|
index.put(key, record);
|
|
414
626
|
if (previous && previous.blobPath !== blobPath) {
|
|
415
627
|
await this.deleteBlobIfUnreferenced(index, previous.blobPath);
|
|
416
628
|
}
|
|
417
629
|
this.queueObjectEvent(bucketRecord.region, bucket, 's3:ObjectCreated:Post', key, record.size, eTag);
|
|
418
|
-
|
|
630
|
+
const response = this.postResponse(bucket, key, eTag, fields);
|
|
631
|
+
response.headers = { ...response.headers, ...sseHeaders(record) };
|
|
632
|
+
return response;
|
|
419
633
|
}
|
|
420
634
|
postResponse(bucket, key, eTag, fields) {
|
|
421
635
|
const value = (name) => fields.find(field => field.name.toLowerCase() === name.toLowerCase() && field.filename === undefined)?.data.toString('utf8');
|
|
@@ -458,6 +672,10 @@ export class S3Emulator {
|
|
|
458
672
|
const sourceRecord = sourceIndex.get(sourceKey);
|
|
459
673
|
if (!sourceRecord)
|
|
460
674
|
throw s3Error('NoSuchKey', 'The specified key does not exist.', 404);
|
|
675
|
+
// Encryption is not metadata: whatever the directive, the copy is
|
|
676
|
+
// encrypted per the request's SSE headers or, absent those, the
|
|
677
|
+
// DESTINATION bucket's default — never inherited from the source (AWS).
|
|
678
|
+
const sse = resolveSse(name => req.headers[name], bucketRecord);
|
|
461
679
|
const data = await this.ctx.store.readBlob(sourceRecord.blobPath);
|
|
462
680
|
const index = await this.index(bucket);
|
|
463
681
|
const blobPath = await this.ctx.store.writeBlob(`s3/${bucket}/blobs`, data);
|
|
@@ -471,7 +689,11 @@ export class S3Emulator {
|
|
|
471
689
|
blobPath,
|
|
472
690
|
contentType: replaceMetadata ? req.headers['content-type'] ?? DEFAULT_CONTENT_TYPE : sourceRecord.contentType,
|
|
473
691
|
metadata: replaceMetadata ? metadataFromHeaders(req.headers) : { ...sourceRecord.metadata },
|
|
692
|
+
// System metadata follows the directive like user metadata does: COPY
|
|
693
|
+
// keeps the source's, REPLACE takes only what the request carries.
|
|
694
|
+
...(replaceMetadata ? systemMetadataFromHeaders(req.headers) : systemMetadataFrom(field => sourceRecord[field])),
|
|
474
695
|
storageClass: 'STANDARD',
|
|
696
|
+
sse,
|
|
475
697
|
};
|
|
476
698
|
index.put(key, record);
|
|
477
699
|
if (previous && previous.blobPath !== blobPath) {
|
|
@@ -482,7 +704,9 @@ export class S3Emulator {
|
|
|
482
704
|
attrs: { xmlns: S3_XMLNS },
|
|
483
705
|
escape: false,
|
|
484
706
|
});
|
|
485
|
-
|
|
707
|
+
const response = xmlResponse(200, result);
|
|
708
|
+
response.headers = { ...response.headers, ...sseHeaders(record) };
|
|
709
|
+
return response;
|
|
486
710
|
}
|
|
487
711
|
async getObject(bucket, key, req) {
|
|
488
712
|
const record = await this.requireObject(bucket, key);
|
|
@@ -634,18 +858,138 @@ function metadataFromFields(fields) {
|
|
|
634
858
|
}
|
|
635
859
|
return metadata;
|
|
636
860
|
}
|
|
861
|
+
// Collect the stored system metadata (Content-Disposition, Cache-Control, …)
|
|
862
|
+
// through a lookup keyed by record field and canonical header name; empty
|
|
863
|
+
// values are dropped, as S3 does not store an empty header.
|
|
864
|
+
function systemMetadataFrom(lookup) {
|
|
865
|
+
const metadata = {};
|
|
866
|
+
for (const [field, header] of SYSTEM_METADATA_HEADERS) {
|
|
867
|
+
const value = lookup(field, header);
|
|
868
|
+
if (value)
|
|
869
|
+
metadata[field] = value;
|
|
870
|
+
}
|
|
871
|
+
return metadata;
|
|
872
|
+
}
|
|
873
|
+
function systemMetadataFromHeaders(headers) {
|
|
874
|
+
return systemMetadataFrom((_field, header) => headers[header.toLowerCase()]);
|
|
875
|
+
}
|
|
876
|
+
// Encryption for a new object (PutObject, presigned POST, CopyObject). The
|
|
877
|
+
// lookup is keyed by the lowercase x-amz-* name (a request header, or a POST
|
|
878
|
+
// form field of the same name). With no SSE header the bucket's default
|
|
879
|
+
// encryption applies — SSE-S3 unless PutBucketEncryption set another rule —
|
|
880
|
+
// which is what AWS reports for every object since 2023.
|
|
881
|
+
function resolveSse(lookup, bucket) {
|
|
882
|
+
const algorithm = lookup(SSE_HEADER);
|
|
883
|
+
const kmsKeyId = lookup(SSE_KMS_KEY_HEADER);
|
|
884
|
+
const bucketKey = lookup(SSE_BUCKET_KEY_HEADER);
|
|
885
|
+
if (algorithm && !SSE_ALGORITHMS.includes(algorithm)) {
|
|
886
|
+
throw s3Error('InvalidArgument', 'The encryption method specified is not supported', 400);
|
|
887
|
+
}
|
|
888
|
+
if (kmsKeyId && (!algorithm || algorithm === 'AES256')) {
|
|
889
|
+
throw s3Error('InvalidArgument', `Server Side Encryption with AWS KMS managed key requires HTTP header ${SSE_HEADER} : aws:kms`, 400);
|
|
890
|
+
}
|
|
891
|
+
if (!algorithm) {
|
|
892
|
+
const { sseAlgorithm, kmsKeyId: defaultKey, bucketKeyEnabled } = bucket.encryption ?? DEFAULT_BUCKET_ENCRYPTION;
|
|
893
|
+
if (sseAlgorithm === 'AES256')
|
|
894
|
+
return { sseAlgorithm };
|
|
895
|
+
return { sseAlgorithm, ...(defaultKey !== undefined ? { kmsKeyId: defaultKey } : {}), bucketKeyEnabled: bucketKeyEnabled === true };
|
|
896
|
+
}
|
|
897
|
+
const sseAlgorithm = algorithm;
|
|
898
|
+
if (sseAlgorithm === 'AES256')
|
|
899
|
+
return { sseAlgorithm };
|
|
900
|
+
// An explicit aws:kms without a key id means the AWS managed key (aws/s3)
|
|
901
|
+
// — not the bucket default's key — so no key id is recorded. S3 Bucket
|
|
902
|
+
// Keys fall back to the bucket-level setting when the header is omitted.
|
|
903
|
+
return {
|
|
904
|
+
sseAlgorithm,
|
|
905
|
+
...(kmsKeyId ? { kmsKeyId } : {}),
|
|
906
|
+
bucketKeyEnabled: bucketKey !== undefined ? bucketKey.toLowerCase() === 'true' : bucket.encryption?.bucketKeyEnabled === true,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function sseHeaders(record) {
|
|
910
|
+
const sse = record.sse ?? DEFAULT_BUCKET_ENCRYPTION;
|
|
911
|
+
const headers = { [SSE_HEADER]: sse.sseAlgorithm };
|
|
912
|
+
if (sse.kmsKeyId !== undefined)
|
|
913
|
+
headers[SSE_KMS_KEY_HEADER] = sse.kmsKeyId;
|
|
914
|
+
if (sse.bucketKeyEnabled)
|
|
915
|
+
headers[SSE_BUCKET_KEY_HEADER] = 'true';
|
|
916
|
+
return headers;
|
|
917
|
+
}
|
|
637
918
|
function objectHeaders(record) {
|
|
638
919
|
const headers = {
|
|
639
920
|
'Content-Type': record.contentType,
|
|
640
921
|
ETag: `"${record.eTag}"`,
|
|
641
922
|
'Last-Modified': new Date(record.lastModified).toUTCString(),
|
|
642
923
|
'Accept-Ranges': 'bytes',
|
|
924
|
+
...sseHeaders(record),
|
|
643
925
|
};
|
|
926
|
+
for (const [field, header] of SYSTEM_METADATA_HEADERS) {
|
|
927
|
+
const value = record[field];
|
|
928
|
+
if (value !== undefined)
|
|
929
|
+
headers[header] = value;
|
|
930
|
+
}
|
|
644
931
|
for (const [name, value] of Object.entries(record.metadata)) {
|
|
645
932
|
headers[`${METADATA_HEADER_PREFIX}${name}`] = value;
|
|
646
933
|
}
|
|
647
934
|
return headers;
|
|
648
935
|
}
|
|
936
|
+
// First query key naming a known subresource (own keys only, so a query
|
|
937
|
+
// param such as `constructor` never matches an Object.prototype member).
|
|
938
|
+
function findSubresource(query, known) {
|
|
939
|
+
return Object.keys(query).find(key => Object.prototype.hasOwnProperty.call(known, key));
|
|
940
|
+
}
|
|
941
|
+
// Operations whose AWS name is not Verb + stem.
|
|
942
|
+
const IRREGULAR_OPERATION_NAMES = {
|
|
943
|
+
'GET versions': 'ListObjectVersions',
|
|
944
|
+
'POST restore': 'RestoreObject',
|
|
945
|
+
'POST select': 'SelectObjectContent',
|
|
946
|
+
};
|
|
947
|
+
function subresourceNotImplemented(method, subresource, known) {
|
|
948
|
+
const verb = method.charAt(0) + method.slice(1).toLowerCase();
|
|
949
|
+
const operation = IRREGULAR_OPERATION_NAMES[`${method} ${subresource}`] ?? `${verb}${known[subresource]}`;
|
|
950
|
+
return s3Error('NotImplemented', `s3.${operation} (${method} ?${subresource}) is not implemented by the LSS self engine — see docs/SELF_ENGINE.md#coverage`, 501);
|
|
951
|
+
}
|
|
952
|
+
// PutBucketEncryption body. AWS accepts exactly one Rule; the engine keeps
|
|
953
|
+
// the first and rejects a rule without a supported SSEAlgorithm the way AWS
|
|
954
|
+
// does (MalformedXML).
|
|
955
|
+
function parseEncryptionConfiguration(body) {
|
|
956
|
+
const root = parseBodyXml(body);
|
|
957
|
+
const rule = childrenNamed(root, 'Rule')[0];
|
|
958
|
+
const byDefault = rule && childrenNamed(rule, 'ApplyServerSideEncryptionByDefault')[0];
|
|
959
|
+
const algorithm = byDefault && childText(byDefault, 'SSEAlgorithm');
|
|
960
|
+
if (!algorithm || !SSE_ALGORITHMS.includes(algorithm)) {
|
|
961
|
+
throw s3Error('MalformedXML', 'The XML you provided was not well-formed or did not validate against our published schema', 400);
|
|
962
|
+
}
|
|
963
|
+
const kmsKeyId = childText(byDefault, 'KMSMasterKeyID');
|
|
964
|
+
if (kmsKeyId && algorithm === 'AES256') {
|
|
965
|
+
throw s3Error('InvalidArgument', 'a KMSMasterKeyID is not applicable if the default sse algorithm is not aws:kms or aws:kms:dsse', 400);
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
sseAlgorithm: algorithm,
|
|
969
|
+
...(kmsKeyId ? { kmsKeyId } : {}),
|
|
970
|
+
bucketKeyEnabled: (childText(rule, 'BucketKeyEnabled') ?? '').toLowerCase() === 'true',
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
// PutBucketTagging body: <Tagging><TagSet><Tag><Key/><Value/></Tag>…</TagSet>.
|
|
974
|
+
function parseTagging(body) {
|
|
975
|
+
const root = parseBodyXml(body);
|
|
976
|
+
const tagSet = childrenNamed(root, 'TagSet')[0];
|
|
977
|
+
if (!tagSet) {
|
|
978
|
+
throw s3Error('MalformedXML', 'The XML you provided was not well-formed or did not validate against our published schema', 400);
|
|
979
|
+
}
|
|
980
|
+
const tags = [];
|
|
981
|
+
const seen = new Set();
|
|
982
|
+
for (const node of childrenNamed(tagSet, 'Tag')) {
|
|
983
|
+
const key = childText(node, 'Key');
|
|
984
|
+
if (!key)
|
|
985
|
+
throw s3Error('InvalidTag', 'The TagKey you have provided is invalid', 400);
|
|
986
|
+
if (seen.has(key))
|
|
987
|
+
throw s3Error('InvalidTag', 'Cannot provide multiple Tags with the same key', 400);
|
|
988
|
+
seen.add(key);
|
|
989
|
+
tags.push({ key, value: childText(node, 'Value') ?? '' });
|
|
990
|
+
}
|
|
991
|
+
return tags;
|
|
992
|
+
}
|
|
649
993
|
// GET/HEAD response-header overrides carried as `response-*` query params (the
|
|
650
994
|
// presigned-URL mechanism for forcing a download filename / content type). AWS
|
|
651
995
|
// echoes each present param into the matching response header — this is what a
|