@stowage/adapter-s3 0.0.0 → 0.2.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 +167 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +2107 -0
- package/package.json +43 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,2107 @@
|
|
|
1
|
+
import { StorageError, XmlSyntaxError, checkUserMetadata, decodeUserMetadataValue, encodeUserMetadataValue, errorCodeForStatus, invalidKeyReason, isStorageError, isTransientStatus, parseXml, rangeBoundsRefusal, rangeCoversWhole, rangeHeader, rangeStartRefusal, readEnvironment, uploadStream, wholeSizeOf, withRetry } from "@stowage/core";
|
|
2
|
+
//#region src/storage-error.ts
|
|
3
|
+
function s3Error(bucket, fields) {
|
|
4
|
+
return new StorageError({
|
|
5
|
+
...fields,
|
|
6
|
+
bucket,
|
|
7
|
+
provider: "s3"
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The same failure told against the storage it happened in. A credential resolver is
|
|
12
|
+
* written outside the adapter and knows neither bucket nor operation, so the error it
|
|
13
|
+
* throws arrives without them and is re-issued here rather than reaching a caller with
|
|
14
|
+
* the placeholders it was built from (spec 4.10).
|
|
15
|
+
*/
|
|
16
|
+
function inStorage(failure, bucket, operation, key) {
|
|
17
|
+
if (!isStorageError(failure)) return failure;
|
|
18
|
+
return s3Error(bucket, {
|
|
19
|
+
...fieldsOf(failure),
|
|
20
|
+
operation,
|
|
21
|
+
key: key ?? failure.key
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** The same failure, counting the attempts made where the one that failed does not know. */
|
|
25
|
+
function withAttemptsMade(failure, attempts) {
|
|
26
|
+
return s3Error(failure.bucket, {
|
|
27
|
+
...fieldsOf(failure),
|
|
28
|
+
attempts
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Every field of `StorageErrorFields` is named below, so a field added to that type has
|
|
33
|
+
* to be added here too or it is dropped on the way through.
|
|
34
|
+
*/
|
|
35
|
+
function fieldsOf(failure) {
|
|
36
|
+
return {
|
|
37
|
+
code: failure.code,
|
|
38
|
+
message: failure.message,
|
|
39
|
+
operation: failure.operation,
|
|
40
|
+
key: failure.key,
|
|
41
|
+
attempts: failure.attempts,
|
|
42
|
+
status: failure.status,
|
|
43
|
+
providerCode: failure.providerCode,
|
|
44
|
+
requestId: failure.requestId,
|
|
45
|
+
retryable: failure.retryable,
|
|
46
|
+
capability: failure.capability,
|
|
47
|
+
cause: failure.cause
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/options.ts
|
|
52
|
+
const operationOptionKeys = ["signal"];
|
|
53
|
+
const putOptionKeys = [
|
|
54
|
+
...operationOptionKeys,
|
|
55
|
+
"contentType",
|
|
56
|
+
"userMetadata"
|
|
57
|
+
];
|
|
58
|
+
const getOptionKeys = [...operationOptionKeys, "range"];
|
|
59
|
+
const presignGetOptionKeys = [
|
|
60
|
+
"expiresIn",
|
|
61
|
+
"responseContentType",
|
|
62
|
+
"responseContentDisposition",
|
|
63
|
+
"responseCacheControl",
|
|
64
|
+
"responseExpires"
|
|
65
|
+
];
|
|
66
|
+
const presignPutOptionKeys = [
|
|
67
|
+
"expiresIn",
|
|
68
|
+
"contentType",
|
|
69
|
+
"contentLength"
|
|
70
|
+
];
|
|
71
|
+
const listOptionKeys = [
|
|
72
|
+
...operationOptionKeys,
|
|
73
|
+
"prefix",
|
|
74
|
+
"delimiter",
|
|
75
|
+
"pageSize",
|
|
76
|
+
"cursor"
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* Refuses an option key the spec does not list (spec 4.3). TypeScript catches one at the
|
|
80
|
+
* call site; this catches the rest.
|
|
81
|
+
*/
|
|
82
|
+
function requireKnownOptions(bucket, options, known, operation) {
|
|
83
|
+
if (options === void 0) return;
|
|
84
|
+
for (const key of Object.keys(options)) {
|
|
85
|
+
if (known.includes(key)) continue;
|
|
86
|
+
throw optionError$1(bucket, key, "is not one this storage takes", operation);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function optionError$1(bucket, option, expectation, operation) {
|
|
90
|
+
return s3Error(bucket, {
|
|
91
|
+
code: "InvalidOption",
|
|
92
|
+
message: `The option \`${option}\` ${expectation}`,
|
|
93
|
+
operation,
|
|
94
|
+
attempts: 0
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/configuration.ts
|
|
99
|
+
const adapterOptionKeys = [
|
|
100
|
+
"bucket",
|
|
101
|
+
"region",
|
|
102
|
+
"endpoint",
|
|
103
|
+
"forcePathStyle",
|
|
104
|
+
"credentials",
|
|
105
|
+
"retry",
|
|
106
|
+
"multipart"
|
|
107
|
+
];
|
|
108
|
+
const retryOptionKeys = ["maxAttempts"];
|
|
109
|
+
const multipartOptionKeys = ["partSize", "concurrency"];
|
|
110
|
+
const mebibyte = 1048576;
|
|
111
|
+
const defaultMaxAttempts = 3;
|
|
112
|
+
const defaultPartSize = 8 * mebibyte;
|
|
113
|
+
const defaultConcurrency = 4;
|
|
114
|
+
const maxAttemptsRange = {
|
|
115
|
+
least: 1,
|
|
116
|
+
most: 3
|
|
117
|
+
};
|
|
118
|
+
const partSizeRange = {
|
|
119
|
+
least: 5 * mebibyte,
|
|
120
|
+
most: 5120 * mebibyte
|
|
121
|
+
};
|
|
122
|
+
const concurrencyRange = {
|
|
123
|
+
least: 1,
|
|
124
|
+
most: 16
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* IPv4 loopback is the whole `127.0.0.0/8` block and IPv6 loopback the single `::1`.
|
|
128
|
+
* `localhost` stands beside them because RFC 6761 binds the name to one of the two, which
|
|
129
|
+
* is what makes it an address spec 7.1 accepts rather than a host that might be anywhere.
|
|
130
|
+
*/
|
|
131
|
+
const loopbackHosts = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/u;
|
|
132
|
+
/**
|
|
133
|
+
* Spec 7.1: every option is validated where the storage is constructed, an unknown key
|
|
134
|
+
* and a value outside its range are `InvalidOption` naming the key, and no value is
|
|
135
|
+
* clamped onto the range it missed.
|
|
136
|
+
*/
|
|
137
|
+
function readConfiguration(options) {
|
|
138
|
+
const bucket = typeof options.bucket === "string" ? options.bucket : "";
|
|
139
|
+
requireKnownOptions(bucket, options, adapterOptionKeys, "s3Storage");
|
|
140
|
+
requireFilled$1(bucket, options.bucket, "bucket");
|
|
141
|
+
requireFilled$1(bucket, options.region, "region");
|
|
142
|
+
if (options.credentials === void 0) throw optionError(bucket, "credentials", "is required: v0.1 sends no unsigned request");
|
|
143
|
+
const forcePathStyle = readFlag(bucket, options.forcePathStyle, "forcePathStyle");
|
|
144
|
+
const endpoint = readEndpoint(bucket, options, forcePathStyle);
|
|
145
|
+
return {
|
|
146
|
+
bucket: options.bucket,
|
|
147
|
+
region: options.region,
|
|
148
|
+
...endpoint,
|
|
149
|
+
forcePathStyle,
|
|
150
|
+
credentials: options.credentials,
|
|
151
|
+
maxAttempts: readMaxAttempts(bucket, options.retry),
|
|
152
|
+
...readMultipart(bucket, options.multipart)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Spec 7.1: no endpoint addresses AWS S3, a configured one is an absolute URL without
|
|
157
|
+
* userinfo, query and fragment, and `http:` is accepted for a loopback host alone.
|
|
158
|
+
* Addressing is virtual-hosted unless `forcePathStyle` moves the bucket into the path.
|
|
159
|
+
*/
|
|
160
|
+
function readEndpoint(bucket, options, forcePathStyle) {
|
|
161
|
+
if (options.endpoint === void 0) return {
|
|
162
|
+
protocol: "https:",
|
|
163
|
+
host: hostFor(`s3.${options.region}.amazonaws.com`, options.bucket, forcePathStyle),
|
|
164
|
+
basePath: ""
|
|
165
|
+
};
|
|
166
|
+
if (typeof options.endpoint !== "string") throw optionError(bucket, "endpoint", "is no absolute URL");
|
|
167
|
+
const parsed = URL.parse(options.endpoint);
|
|
168
|
+
if (parsed === null) throw optionError(bucket, "endpoint", "is no absolute URL");
|
|
169
|
+
if (parsed.username !== "" || parsed.password !== "") throw optionError(bucket, "endpoint", "carries userinfo");
|
|
170
|
+
if (parsed.search !== "") throw optionError(bucket, "endpoint", "carries a query");
|
|
171
|
+
if (parsed.hash !== "") throw optionError(bucket, "endpoint", "carries a fragment");
|
|
172
|
+
if (parsed.protocol !== "https:" && !isLoopbackHttp(parsed)) throw optionError(bucket, "endpoint", "is neither `https:` nor `http:` to a loopback host");
|
|
173
|
+
return {
|
|
174
|
+
protocol: parsed.protocol,
|
|
175
|
+
host: hostFor(parsed.host, options.bucket, forcePathStyle),
|
|
176
|
+
basePath: parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/u, "")
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function isLoopbackHttp(endpoint) {
|
|
180
|
+
return endpoint.protocol === "http:" && loopbackHosts.test(endpoint.hostname);
|
|
181
|
+
}
|
|
182
|
+
function hostFor(host, bucket, forcePathStyle) {
|
|
183
|
+
return forcePathStyle ? host : `${bucket}.${host}`;
|
|
184
|
+
}
|
|
185
|
+
function readMaxAttempts(bucket, retry) {
|
|
186
|
+
if (retry === false) return 1;
|
|
187
|
+
if (retry === void 0) return defaultMaxAttempts;
|
|
188
|
+
requireGroup(bucket, retry, "retry");
|
|
189
|
+
requireKnownOptions(bucket, retry, retryOptionKeys, "s3Storage");
|
|
190
|
+
return readInRange(bucket, retry.maxAttempts, "maxAttempts", maxAttemptsRange, defaultMaxAttempts);
|
|
191
|
+
}
|
|
192
|
+
function readMultipart(bucket, multipart) {
|
|
193
|
+
if (multipart !== void 0) {
|
|
194
|
+
requireGroup(bucket, multipart, "multipart");
|
|
195
|
+
requireKnownOptions(bucket, multipart, multipartOptionKeys, "s3Storage");
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
partSize: readInRange(bucket, multipart?.partSize, "partSize", partSizeRange, defaultPartSize),
|
|
199
|
+
concurrency: readInRange(bucket, multipart?.concurrency, "concurrency", concurrencyRange, defaultConcurrency)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function readInRange(bucket, value, option, range, fallback) {
|
|
203
|
+
if (value === void 0) return fallback;
|
|
204
|
+
if (!Number.isInteger(value) || value < range.least || value > range.most) throw optionError(bucket, option, `takes the integers ${range.least} to ${range.most}`);
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
function readFlag(bucket, value, option) {
|
|
208
|
+
if (value === void 0) return false;
|
|
209
|
+
if (typeof value !== "boolean") throw optionError(bucket, option, "takes a boolean");
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A group of options is an object. Without this, `Object.keys` reads `retry: true` as a
|
|
214
|
+
* group with no key and hands back the default, and `retry: null` throws a `TypeError`
|
|
215
|
+
* where spec 7.1 asks for `InvalidOption`.
|
|
216
|
+
*/
|
|
217
|
+
function requireGroup(bucket, group, option) {
|
|
218
|
+
if (typeof group === "object" && group !== null) return;
|
|
219
|
+
throw optionError(bucket, option, "takes a group of options");
|
|
220
|
+
}
|
|
221
|
+
function requireFilled$1(bucket, value, option) {
|
|
222
|
+
if (typeof value === "string" && value !== "") return;
|
|
223
|
+
throw optionError(bucket, option, "is empty");
|
|
224
|
+
}
|
|
225
|
+
function optionError(bucket, option, expectation) {
|
|
226
|
+
return optionError$1(bucket, option, expectation, "s3Storage");
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/provider-code.ts
|
|
230
|
+
/**
|
|
231
|
+
* The table of spec 7.9, holding both vendors' strings: a code recognized here decides
|
|
232
|
+
* the error code alone, and an unrecognized one falls to the status mapping of spec 4.10.
|
|
233
|
+
* Whether the condition is transient is never read from here — ADR 0013 decides that by
|
|
234
|
+
* the status, so that an unknown `5xx` is treated no worse than one stowage has heard of.
|
|
235
|
+
*/
|
|
236
|
+
const providerCodes = /* @__PURE__ */ new Map([
|
|
237
|
+
["NoSuchKey", "NotFound"],
|
|
238
|
+
["NoSuchBucket", "NotFound"],
|
|
239
|
+
["AccessDenied", "AccessDenied"],
|
|
240
|
+
["InvalidAccessKeyId", "InvalidCredentials"],
|
|
241
|
+
["SignatureDoesNotMatch", "InvalidCredentials"],
|
|
242
|
+
["Unauthorized", "InvalidCredentials"],
|
|
243
|
+
["ExpiredToken", "Expired"],
|
|
244
|
+
["ExpiredRequest", "Expired"],
|
|
245
|
+
["RequestTimeTooSkewed", "InvalidRequest"],
|
|
246
|
+
["InvalidRange", "InvalidRequest"],
|
|
247
|
+
["InvalidRequest", "InvalidRequest"],
|
|
248
|
+
["InvalidArgument", "InvalidRequest"],
|
|
249
|
+
["MetadataTooLarge", "InvalidRequest"],
|
|
250
|
+
["EntityTooLarge", "InvalidRequest"],
|
|
251
|
+
["EntityTooSmall", "InvalidRequest"],
|
|
252
|
+
["InvalidPart", "InvalidRequest"],
|
|
253
|
+
["InvalidPartOrder", "InvalidRequest"],
|
|
254
|
+
["BadDigest", "InvalidRequest"],
|
|
255
|
+
["MalformedXML", "InvalidRequest"],
|
|
256
|
+
["InvalidDigest", "InvalidRequest"],
|
|
257
|
+
["InvalidObjectName", "InvalidKey"],
|
|
258
|
+
["KeyTooLongError", "InvalidKey"],
|
|
259
|
+
["NoSuchUpload", "ProviderError"],
|
|
260
|
+
["SlowDown", "ProviderError"],
|
|
261
|
+
["TooManyRequests", "ProviderError"],
|
|
262
|
+
["ServiceUnavailable", "ProviderError"],
|
|
263
|
+
["InternalError", "ProviderError"],
|
|
264
|
+
["RequestTimeout", "ProviderError"]
|
|
265
|
+
]);
|
|
266
|
+
const permanentRedirect = 301;
|
|
267
|
+
const badRequest = 400;
|
|
268
|
+
/** The longest key AWS S3 and R2 hold, in UTF-8 bytes, above which they answer `KeyTooLongError`. */
|
|
269
|
+
const longestHeldKey = 1024;
|
|
270
|
+
const utf8$4 = new TextEncoder();
|
|
271
|
+
/**
|
|
272
|
+
* What the provider's answer means, decided by its own code where the table recognizes
|
|
273
|
+
* one and by the status where it does not. The message is the provider's word for word
|
|
274
|
+
* (spec 4.10), except where spec 7.9 has the failure name the option that is wrong: a
|
|
275
|
+
* caller can act on `region` and on `cursor`, and cannot on a message about either.
|
|
276
|
+
*/
|
|
277
|
+
function readProviderFailure(answer) {
|
|
278
|
+
const said = answer.providerMessage ?? `The provider answered ${answer.status} to \`${answer.method}\``;
|
|
279
|
+
if (answer.status === permanentRedirect || answer.providerCode === "PermanentRedirect") return {
|
|
280
|
+
code: "InvalidOption",
|
|
281
|
+
message: `The option \`region\` is not the bucket's${answer.bucketRegion === void 0 ? "" : `, which is \`${answer.bucketRegion}\``}: ${said}`
|
|
282
|
+
};
|
|
283
|
+
if (answer.method === "HEAD" && answer.status === badRequest && answer.key !== void 0 && utf8$4.encode(answer.key).byteLength > longestHeldKey) return {
|
|
284
|
+
code: "InvalidKey",
|
|
285
|
+
message: `The key is longer than the ${longestHeldKey} bytes the provider holds: ${said}`
|
|
286
|
+
};
|
|
287
|
+
if (answer.providerCode === "InvalidArgument" && answer.operation === "list" && answer.hasContinuationToken) return {
|
|
288
|
+
code: "InvalidOption",
|
|
289
|
+
message: `The option \`cursor\` is not one the provider continued from: ${said}`
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
code: (answer.providerCode === void 0 ? void 0 : providerCodes.get(answer.providerCode)) ?? errorCodeForStatus(answer.status) ?? "ProviderError",
|
|
293
|
+
message: said
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The codes of the table a provider answers with a transient status: the five the table
|
|
298
|
+
* leaves to the status, as spec 7.9 notes beside them.
|
|
299
|
+
*/
|
|
300
|
+
const transientProviderCodes = /* @__PURE__ */ new Set([
|
|
301
|
+
"SlowDown",
|
|
302
|
+
"TooManyRequests",
|
|
303
|
+
"ServiceUnavailable",
|
|
304
|
+
"InternalError",
|
|
305
|
+
"RequestTimeout"
|
|
306
|
+
]);
|
|
307
|
+
/**
|
|
308
|
+
* A failure the provider reported inside a `200`, such as one key of a `DeleteObjects` or
|
|
309
|
+
* a `CopyObject` that failed after the answer began. No status speaks for it, so the code
|
|
310
|
+
* decides alone; whether it is transient is read off the code, since spec 4.7 has the
|
|
311
|
+
* entry carry `retryable` for a caller who repeats it. Nothing here is repeated: ADR 0013
|
|
312
|
+
* lets no provider code into the retry group.
|
|
313
|
+
*/
|
|
314
|
+
function readEmbeddedFailure(providerCode, providerMessage) {
|
|
315
|
+
return {
|
|
316
|
+
code: providerCodes.get(providerCode) ?? "ProviderError",
|
|
317
|
+
message: providerMessage,
|
|
318
|
+
retryable: transientProviderCodes.has(providerCode)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/answer-document.ts
|
|
323
|
+
/**
|
|
324
|
+
* The root of the document a `200` carries, read through the parser of spec 7.4. S3 may
|
|
325
|
+
* answer a `CopyObject` or a `DeleteObjects` with `200` and an `<Error>` in the body once
|
|
326
|
+
* the answer has begun, so a root of that name is the failure it reports, told with the
|
|
327
|
+
* provider's code and message, and not repeated (ADR 0013 lets no code into the group).
|
|
328
|
+
*/
|
|
329
|
+
async function readAnswerDocument(request, response, expectedRoot) {
|
|
330
|
+
const requestId = response.headers.get("x-amz-request-id") ?? void 0;
|
|
331
|
+
const context = {
|
|
332
|
+
operation: request.operation,
|
|
333
|
+
key: request.key,
|
|
334
|
+
attempts: 1,
|
|
335
|
+
status: response.status,
|
|
336
|
+
requestId
|
|
337
|
+
};
|
|
338
|
+
const root = parseAnswer(request, await readBody$1(request, response, context), context);
|
|
339
|
+
if (root.name === "Error") throw embeddedFailure(request, context, root);
|
|
340
|
+
if (root.name !== expectedRoot) throw malformed$1(request, context, `a <${root.name}> where a <${expectedRoot}> belongs`);
|
|
341
|
+
return root;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The failure an `<Error>` element inside a `200` reports: a whole answer's, or one key's
|
|
345
|
+
* of a `DeleteObjects`. No status speaks for it, so its code decides alone.
|
|
346
|
+
*/
|
|
347
|
+
function embeddedFailure(request, context, element) {
|
|
348
|
+
const providerCode = textOf$1(element, "Code") ?? "";
|
|
349
|
+
const failure = readEmbeddedFailure(providerCode, textOf$1(element, "Message") ?? `The provider failed ${request.subject}: ${providerCode}`);
|
|
350
|
+
return s3Error(request.bucket, {
|
|
351
|
+
...context,
|
|
352
|
+
code: failure.code,
|
|
353
|
+
message: failure.message,
|
|
354
|
+
providerCode: providerCode === "" ? void 0 : providerCode,
|
|
355
|
+
retryable: failure.retryable
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
function textOf$1(element, name) {
|
|
359
|
+
return element.children.find((child) => child.name === name)?.text;
|
|
360
|
+
}
|
|
361
|
+
async function readBody$1(request, response, context) {
|
|
362
|
+
try {
|
|
363
|
+
return await response.text();
|
|
364
|
+
} catch (failure) {
|
|
365
|
+
if (failure instanceof Error && failure.name === "AbortError") throw failure;
|
|
366
|
+
throw s3Error(request.bucket, {
|
|
367
|
+
...context,
|
|
368
|
+
code: "NetworkError",
|
|
369
|
+
message: `The answer to ${request.subject} broke while it was read: ${String(failure)}`,
|
|
370
|
+
retryable: true,
|
|
371
|
+
cause: failure
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function parseAnswer(request, body, context) {
|
|
376
|
+
try {
|
|
377
|
+
return parseXml(body);
|
|
378
|
+
} catch (failure) {
|
|
379
|
+
if (failure instanceof XmlSyntaxError) throw malformed$1(request, context, `a document outside the XML stowage reads: ${failure.message}`, failure);
|
|
380
|
+
throw failure;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function malformed$1(request, context, what, cause) {
|
|
384
|
+
return s3Error(request.bucket, {
|
|
385
|
+
...context,
|
|
386
|
+
code: "ProviderError",
|
|
387
|
+
message: `The provider answered ${request.subject} with ${what}`,
|
|
388
|
+
cause
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/canonical.ts
|
|
393
|
+
/** What `encodeURIComponent` leaves alone and RFC 3986 counts as reserved. */
|
|
394
|
+
const reservedByEncodeUriComponent = /[!'()*]/g;
|
|
395
|
+
/** Runs of whitespace inside a header value, which SigV4 folds to one space. */
|
|
396
|
+
const whitespaceRun = /\s+/g;
|
|
397
|
+
function encodeRfc3986(value) {
|
|
398
|
+
return encodeURIComponent(value).replace(reservedByEncodeUriComponent, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The canonical URI: the path percent-encoded segment by segment, so that a slash stays
|
|
402
|
+
* a slash and `#`, `%`, `?`, `+`, a space and everything above ASCII travel encoded
|
|
403
|
+
* (spec 7.4). S3 encodes the path once and normalizes nothing, which is why no `URL` is
|
|
404
|
+
* built from it: the constructor folds a `..` segment away and decodes what it was
|
|
405
|
+
* handed, and both would sign a path other than the one the request carries.
|
|
406
|
+
*/
|
|
407
|
+
function encodePath(path) {
|
|
408
|
+
return path.split("/").map(encodeRfc3986).join("/");
|
|
409
|
+
}
|
|
410
|
+
/** The canonical query string: every pair encoded, sorted by name and then by value. */
|
|
411
|
+
function encodeQuery(query) {
|
|
412
|
+
return query.map(([name, value]) => [encodeRfc3986(name), encodeRfc3986(value)]).toSorted(compareFields).map(([name, value]) => `${name}=${value}`).join("&");
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The canonical headers: names folded to lower case, values trimmed with their inner
|
|
416
|
+
* runs of whitespace folded to one space, fields of one name joined in the order they
|
|
417
|
+
* arrived, and the whole sorted by name.
|
|
418
|
+
*/
|
|
419
|
+
function canonicalHeaders(fields) {
|
|
420
|
+
const joined = /* @__PURE__ */ new Map();
|
|
421
|
+
for (const [name, value] of fields) {
|
|
422
|
+
const folded = name.toLowerCase();
|
|
423
|
+
const trimmed = value.trim().replace(whitespaceRun, " ");
|
|
424
|
+
const held = joined.get(folded);
|
|
425
|
+
joined.set(folded, held === void 0 ? trimmed : `${held},${trimmed}`);
|
|
426
|
+
}
|
|
427
|
+
const sorted = [...joined].toSorted(compareFields);
|
|
428
|
+
return {
|
|
429
|
+
lines: sorted.map(([name, value]) => `${name}:${value}\n`).join(""),
|
|
430
|
+
names: sorted.map(([name]) => name).join(";")
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function compareFields(one, other) {
|
|
434
|
+
if (one[0] !== other[0]) return one[0] < other[0] ? -1 : 1;
|
|
435
|
+
if (one[1] !== other[1]) return one[1] < other[1] ? -1 : 1;
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/range.ts
|
|
440
|
+
/** Refuses bounds the spec does not allow, before any request goes out (spec 4.3). */
|
|
441
|
+
function requireRange(bucket, range) {
|
|
442
|
+
const refusal = rangeBoundsRefusal(range);
|
|
443
|
+
if (refusal !== void 0) throw s3Error(bucket, {
|
|
444
|
+
...refusal,
|
|
445
|
+
operation: "get",
|
|
446
|
+
attempts: 0
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* A provider that answers a ranged `GET` with `200` sent the whole object instead, which
|
|
451
|
+
* RFC 9110 allows. Where the range covers the object, that is the body asked for. For an
|
|
452
|
+
* object the range starts beyond, which is how S3 answers a range on an empty object, it is
|
|
453
|
+
* the refusal spec 4.3 names; for any other it is a body the caller did not ask for.
|
|
454
|
+
*/
|
|
455
|
+
function wholeAnswerFailure(bucket, key, range, size) {
|
|
456
|
+
if (rangeCoversWhole(range, size)) return void 0;
|
|
457
|
+
const refusal = rangeStartRefusal(range, size, key);
|
|
458
|
+
if (refusal !== void 0) return s3Error(bucket, {
|
|
459
|
+
...refusal,
|
|
460
|
+
operation: "get",
|
|
461
|
+
key,
|
|
462
|
+
attempts: 1
|
|
463
|
+
});
|
|
464
|
+
return s3Error(bucket, {
|
|
465
|
+
code: "ProviderError",
|
|
466
|
+
message: `The provider answered a range of the object under ${JSON.stringify(key)} with the whole of it`,
|
|
467
|
+
operation: "get",
|
|
468
|
+
key,
|
|
469
|
+
attempts: 1
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/user-metadata.ts
|
|
474
|
+
const headerPrefix = "x-amz-meta-";
|
|
475
|
+
/**
|
|
476
|
+
* The header fields `userMetadata` travels in, refused before the request is signed in the
|
|
477
|
+
* order of spec 4.3, which reads the refusals off what the storage declares. A key outside
|
|
478
|
+
* ASCII is refused rather than sent, because R2 strips it on the way out and a write would
|
|
479
|
+
* lose it silently (ADR 0014).
|
|
480
|
+
*/
|
|
481
|
+
function userMetadataHeaders(bucket, userMetadata, key, capabilities) {
|
|
482
|
+
const check = checkUserMetadata(userMetadata, capabilities);
|
|
483
|
+
if ("refusal" in check) throw s3Error(bucket, {
|
|
484
|
+
...check.refusal,
|
|
485
|
+
operation: "put",
|
|
486
|
+
key,
|
|
487
|
+
attempts: 0
|
|
488
|
+
});
|
|
489
|
+
return {
|
|
490
|
+
headers: Object.entries(check.held).map(([name, value]) => [`${headerPrefix}${name}`, encodeUserMetadataValue(value)]),
|
|
491
|
+
held: check.held
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The user metadata a `GET` or a `HEAD` response carries. AWS decodes an encoded word
|
|
496
|
+
* before it stores the value and encodes it again on the way out, in a form of its own
|
|
497
|
+
* choosing, so every form RFC 2047 allows is read and not only the one written.
|
|
498
|
+
*/
|
|
499
|
+
function readUserMetadata(headers) {
|
|
500
|
+
const held = Object.create(null);
|
|
501
|
+
for (const [name, value] of headers) {
|
|
502
|
+
if (!name.startsWith(headerPrefix)) continue;
|
|
503
|
+
held[name.slice(11)] = decodeUserMetadataValue(value);
|
|
504
|
+
}
|
|
505
|
+
return Object.freeze(held);
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region src/description.ts
|
|
509
|
+
const defaultContentType = "application/octet-stream";
|
|
510
|
+
/** The description a `GET` or a `HEAD` response carries in its headers (spec 4.4). */
|
|
511
|
+
function describeResponse(bucket, key, operation, response) {
|
|
512
|
+
return {
|
|
513
|
+
key,
|
|
514
|
+
size: sizeOf$1(bucket, key, operation, response),
|
|
515
|
+
lastModified: lastModifiedOf(bucket, key, operation, response),
|
|
516
|
+
etag: etagOf$1(response),
|
|
517
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
518
|
+
userMetadata: readUserMetadata(response.headers)
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* What `put` wrote, described from what it sent: a `PutObject` answer carries the entity
|
|
523
|
+
* tag and the time the provider accepted the object, and neither length nor type. Spec
|
|
524
|
+
* 4.4 has that time come from the provider, so an answer without one is reported rather
|
|
525
|
+
* than dated from this clock. `CompleteMultipartUpload` carries its entity tag in the
|
|
526
|
+
* body instead, and hands it in as `etag`.
|
|
527
|
+
*/
|
|
528
|
+
function describeWrite(bucket, key, size, contentType, userMetadata, response, etag = etagOf$1(response)) {
|
|
529
|
+
const accepted = Date.parse(response.headers.get("date") ?? "");
|
|
530
|
+
if (Number.isNaN(accepted)) throw incomplete(bucket, key, "put", "no time it was accepted");
|
|
531
|
+
return {
|
|
532
|
+
key,
|
|
533
|
+
size,
|
|
534
|
+
lastModified: new Date(accepted),
|
|
535
|
+
etag,
|
|
536
|
+
contentType,
|
|
537
|
+
userMetadata
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function etagOf$1(response) {
|
|
541
|
+
const etag = response.headers.get("etag");
|
|
542
|
+
if (etag === null) return void 0;
|
|
543
|
+
return unquotedEtag(etag);
|
|
544
|
+
}
|
|
545
|
+
function unquotedEtag(etag) {
|
|
546
|
+
return etag.replace(/^"|"$/gu, "");
|
|
547
|
+
}
|
|
548
|
+
function sizeOf$1(bucket, key, operation, response) {
|
|
549
|
+
if (response.status === 206) {
|
|
550
|
+
const size = wholeSizeOf(response.headers.get("content-range"));
|
|
551
|
+
if (size === void 0) throw incomplete(bucket, key, operation, "no size of the whole object");
|
|
552
|
+
return size;
|
|
553
|
+
}
|
|
554
|
+
const header = response.headers.get("content-length");
|
|
555
|
+
if (header === null || header.trim() === "") throw incomplete(bucket, key, operation, "no length");
|
|
556
|
+
const length = Number(header);
|
|
557
|
+
if (!Number.isInteger(length) || length < 0) throw incomplete(bucket, key, operation, "no length");
|
|
558
|
+
return length;
|
|
559
|
+
}
|
|
560
|
+
function lastModifiedOf(bucket, key, operation, response) {
|
|
561
|
+
const modified = Date.parse(response.headers.get("last-modified") ?? "");
|
|
562
|
+
if (Number.isNaN(modified)) throw incomplete(bucket, key, operation, "no last-modified time");
|
|
563
|
+
return new Date(modified);
|
|
564
|
+
}
|
|
565
|
+
function incomplete(bucket, key, operation, missing) {
|
|
566
|
+
return s3Error(bucket, {
|
|
567
|
+
code: "ProviderError",
|
|
568
|
+
message: `The provider described the object under ${JSON.stringify(key)} with ${missing}`,
|
|
569
|
+
operation,
|
|
570
|
+
key,
|
|
571
|
+
attempts: 1
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/credentials.ts
|
|
576
|
+
/** The three fields spec 7.1 names, and the whole set a resolved credential may carry. */
|
|
577
|
+
const credentialFields = /* @__PURE__ */ new Set([
|
|
578
|
+
"accessKeyId",
|
|
579
|
+
"secretAccessKey",
|
|
580
|
+
"sessionToken"
|
|
581
|
+
]);
|
|
582
|
+
const accessKeyIdVariable = "AWS_ACCESS_KEY_ID";
|
|
583
|
+
const secretAccessKeyVariable = "AWS_SECRET_ACCESS_KEY";
|
|
584
|
+
const sessionTokenVariable = "AWS_SESSION_TOKEN";
|
|
585
|
+
/**
|
|
586
|
+
* Spec 7.3: the credential is resolved before every request that is signed and nothing
|
|
587
|
+
* is cached between calls, so a rotation the resolver performs reaches the next request.
|
|
588
|
+
*/
|
|
589
|
+
async function resolveCredentials(source, options) {
|
|
590
|
+
return validate(typeof source === "function" ? await source(options) : source);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A resolver, passed as `credentials: fromEnv` rather than called, so that a rotated
|
|
594
|
+
* `AWS_SESSION_TOKEN` reaches the next request. Reading the three variables again is
|
|
595
|
+
* what a refresh is here, so the options it is handed decide nothing (ADR 0007).
|
|
596
|
+
*/
|
|
597
|
+
function fromEnv(_options) {
|
|
598
|
+
const accessKeyId = readEnvironment(accessKeyIdVariable);
|
|
599
|
+
const secretAccessKey = readEnvironment(secretAccessKeyVariable);
|
|
600
|
+
const sessionToken = readEnvironment(sessionTokenVariable);
|
|
601
|
+
if (accessKeyId === "") throw emptyVariable(accessKeyIdVariable);
|
|
602
|
+
if (secretAccessKey === "") throw emptyVariable(secretAccessKeyVariable);
|
|
603
|
+
return sessionToken === "" ? {
|
|
604
|
+
accessKeyId,
|
|
605
|
+
secretAccessKey
|
|
606
|
+
} : {
|
|
607
|
+
accessKeyId,
|
|
608
|
+
secretAccessKey,
|
|
609
|
+
sessionToken
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Spec 7.3: both required fields are non-empty strings and every key of the resolved
|
|
614
|
+
* object is one of the three, checked before signing rather than a round trip later.
|
|
615
|
+
*/
|
|
616
|
+
function validate(credentials) {
|
|
617
|
+
if (typeof credentials !== "object" || credentials === null) throw refusal("The resolved credential is not an object");
|
|
618
|
+
for (const field of Object.keys(credentials)) if (!credentialFields.has(field)) throw refusal(`The credential field \`${field}\` is not one of the three S3 takes`);
|
|
619
|
+
requireFilled(credentials.accessKeyId, "accessKeyId");
|
|
620
|
+
requireFilled(credentials.secretAccessKey, "secretAccessKey");
|
|
621
|
+
return credentials;
|
|
622
|
+
}
|
|
623
|
+
function requireFilled(value, field) {
|
|
624
|
+
if (typeof value === "string" && value !== "") return;
|
|
625
|
+
throw refusal(`The credential field \`${field}\` is empty`);
|
|
626
|
+
}
|
|
627
|
+
function emptyVariable(name) {
|
|
628
|
+
return refusal(`The environment variable \`${name}\` is empty`);
|
|
629
|
+
}
|
|
630
|
+
function refusal(message) {
|
|
631
|
+
return s3Error("", {
|
|
632
|
+
code: "InvalidCredentials",
|
|
633
|
+
message,
|
|
634
|
+
operation: "credentials",
|
|
635
|
+
attempts: 0
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/xml.ts
|
|
640
|
+
/**
|
|
641
|
+
* The five entities XML defines without a DTD. The core's parser keeps its table to itself,
|
|
642
|
+
* and `readErrorDocument` decodes a failure's message without the parser.
|
|
643
|
+
*/
|
|
644
|
+
const predefinedEntities = /* @__PURE__ */ new Map([
|
|
645
|
+
["amp", "&"],
|
|
646
|
+
["lt", "<"],
|
|
647
|
+
["gt", ">"],
|
|
648
|
+
["quot", "\""],
|
|
649
|
+
["apos", "'"]
|
|
650
|
+
]);
|
|
651
|
+
const xmlEscapes = {
|
|
652
|
+
"&": "&",
|
|
653
|
+
"<": "<",
|
|
654
|
+
">": ">",
|
|
655
|
+
"\"": """,
|
|
656
|
+
"'": "'"
|
|
657
|
+
};
|
|
658
|
+
/** Text as it stands inside an element of a request document stowage writes. */
|
|
659
|
+
function escapeXml(text) {
|
|
660
|
+
return text.replaceAll(/[&<>"']/gu, (character) => xmlEscapes[character] ?? character);
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/error-document.ts
|
|
664
|
+
/**
|
|
665
|
+
* The `Code` and `Message` a provider answers a failed request with. The parser spec 7.4
|
|
666
|
+
* puts in front of a listing reads a structure; a failure needs two texts out of one flat
|
|
667
|
+
* element, and a body that is no error document — an HTML page from a proxy in between,
|
|
668
|
+
* a body the provider left empty — leaves both unset rather than failing on its way to
|
|
669
|
+
* reporting a failure.
|
|
670
|
+
*/
|
|
671
|
+
function readErrorDocument(body) {
|
|
672
|
+
return {
|
|
673
|
+
code: textOf(body, "Code"),
|
|
674
|
+
message: textOf(body, "Message")
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function textOf(body, element) {
|
|
678
|
+
const text = new RegExp(`<${element}>([^<]*)</${element}>`, "u").exec(body)?.[1];
|
|
679
|
+
return text === void 0 ? void 0 : decodeEntities(text);
|
|
680
|
+
}
|
|
681
|
+
function decodeEntities(text) {
|
|
682
|
+
return text.replaceAll(/&(#x[\da-f]+|#\d+|[a-z]+);/giu, (entity, name) => {
|
|
683
|
+
if (name.startsWith("#")) {
|
|
684
|
+
const codePoint = Number(name.startsWith("#x") ? `0x${name.slice(2)}` : name.slice(1));
|
|
685
|
+
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 1114111 ? String.fromCodePoint(codePoint) : entity;
|
|
686
|
+
}
|
|
687
|
+
return predefinedEntities.get(name.toLowerCase()) ?? entity;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/hash.ts
|
|
692
|
+
const utf8$3 = new TextEncoder();
|
|
693
|
+
/**
|
|
694
|
+
* ADR 0009: Web Crypto defines SHA-256 as one shot over a `BufferSource` and has no
|
|
695
|
+
* incremental form, so the adapter holds what it hashes whole.
|
|
696
|
+
*/
|
|
697
|
+
async function sha256Hex(data) {
|
|
698
|
+
return hex(await crypto.subtle.digest("SHA-256", bytesOf$1(data)));
|
|
699
|
+
}
|
|
700
|
+
async function hmacSha256(key, data) {
|
|
701
|
+
const imported = await crypto.subtle.importKey("raw", key, {
|
|
702
|
+
name: "HMAC",
|
|
703
|
+
hash: "SHA-256"
|
|
704
|
+
}, false, ["sign"]);
|
|
705
|
+
return await crypto.subtle.sign("HMAC", imported, utf8$3.encode(data));
|
|
706
|
+
}
|
|
707
|
+
function hex(buffer) {
|
|
708
|
+
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
709
|
+
}
|
|
710
|
+
function bytesOf$1(data) {
|
|
711
|
+
return typeof data === "string" ? utf8$3.encode(data) : data;
|
|
712
|
+
}
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/sign.ts
|
|
715
|
+
const signingAlgorithm = "AWS4-HMAC-SHA256";
|
|
716
|
+
async function signRequest(request) {
|
|
717
|
+
const amzDate = amzDateOf(request.date);
|
|
718
|
+
const scope = scopeOf(request, amzDate);
|
|
719
|
+
const toSend = [
|
|
720
|
+
...request.headers,
|
|
721
|
+
["x-amz-date", amzDate],
|
|
722
|
+
...sessionTokenField(request.credentials)
|
|
723
|
+
];
|
|
724
|
+
const canonical = canonicalHeaders([...toSend, ["host", request.host]]);
|
|
725
|
+
const signed = await signCanonical(request, {
|
|
726
|
+
query: request.query,
|
|
727
|
+
headers: canonical,
|
|
728
|
+
payloadHash: request.payloadHash,
|
|
729
|
+
amzDate,
|
|
730
|
+
scope
|
|
731
|
+
});
|
|
732
|
+
const authorization = `${signingAlgorithm} Credential=${request.credentials.accessKeyId}/${scope}, SignedHeaders=${canonical.names}, Signature=${signed.signature}`;
|
|
733
|
+
return {
|
|
734
|
+
headers: [...toSend, ["authorization", authorization]],
|
|
735
|
+
...signed
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Spec 7.4 and ADR 0011: the payload hash a presigned URL signs, which excludes the body
|
|
740
|
+
* from the signature because the signer never sees it. It is written here and nowhere
|
|
741
|
+
* else, so a request the adapter sends itself has no way to carry it.
|
|
742
|
+
*/
|
|
743
|
+
const unsignedPayload = "UNSIGNED-PAYLOAD";
|
|
744
|
+
/**
|
|
745
|
+
* SigV4 query signing: the authorization travels in the query rather than in headers, so
|
|
746
|
+
* that a client holding no credential can send the request. The headers it binds are
|
|
747
|
+
* signed and not handed back, because whoever calls the URL sends them.
|
|
748
|
+
*/
|
|
749
|
+
async function presignRequest(request) {
|
|
750
|
+
const amzDate = amzDateOf(request.date);
|
|
751
|
+
const scope = scopeOf(request, amzDate);
|
|
752
|
+
const canonical = canonicalHeaders([...request.headers, ["host", request.host]]);
|
|
753
|
+
const query = [
|
|
754
|
+
...request.query,
|
|
755
|
+
["X-Amz-Algorithm", signingAlgorithm],
|
|
756
|
+
["X-Amz-Credential", `${request.credentials.accessKeyId}/${scope}`],
|
|
757
|
+
["X-Amz-Date", amzDate],
|
|
758
|
+
["X-Amz-Expires", String(request.expiresIn)],
|
|
759
|
+
["X-Amz-SignedHeaders", canonical.names],
|
|
760
|
+
...sessionTokenParameter(request.credentials)
|
|
761
|
+
];
|
|
762
|
+
const signed = await signCanonical(request, {
|
|
763
|
+
query,
|
|
764
|
+
headers: canonical,
|
|
765
|
+
payloadHash: unsignedPayload,
|
|
766
|
+
amzDate,
|
|
767
|
+
scope
|
|
768
|
+
});
|
|
769
|
+
return {
|
|
770
|
+
query: [...query, ["X-Amz-Signature", signed.signature]],
|
|
771
|
+
canonicalRequest: signed.canonicalRequest,
|
|
772
|
+
stringToSign: signed.stringToSign
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
async function signCanonical(request, input) {
|
|
776
|
+
const canonicalRequest = [
|
|
777
|
+
request.method,
|
|
778
|
+
encodePath(request.path),
|
|
779
|
+
encodeQuery(input.query),
|
|
780
|
+
input.headers.lines,
|
|
781
|
+
input.headers.names,
|
|
782
|
+
input.payloadHash
|
|
783
|
+
].join("\n");
|
|
784
|
+
const stringToSign = [
|
|
785
|
+
signingAlgorithm,
|
|
786
|
+
input.amzDate,
|
|
787
|
+
input.scope,
|
|
788
|
+
await sha256Hex(canonicalRequest)
|
|
789
|
+
].join("\n");
|
|
790
|
+
return {
|
|
791
|
+
canonicalRequest,
|
|
792
|
+
stringToSign,
|
|
793
|
+
signature: hex(await hmacSha256(await signingKey(request, input.amzDate.slice(0, 8)), stringToSign))
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function scopeOf(request, amzDate) {
|
|
797
|
+
return `${amzDate.slice(0, 8)}/${request.region}/${request.service}/aws4_request`;
|
|
798
|
+
}
|
|
799
|
+
/** `20150830T123600Z`, which is what `x-amz-date` and the credential scope are written in. */
|
|
800
|
+
function amzDateOf(date) {
|
|
801
|
+
return `${date.toISOString().replaceAll(/[.:-]/gu, "").slice(0, 15)}Z`;
|
|
802
|
+
}
|
|
803
|
+
function sessionTokenField(credentials) {
|
|
804
|
+
if (credentials.sessionToken === void 0) return [];
|
|
805
|
+
return [["x-amz-security-token", credentials.sessionToken]];
|
|
806
|
+
}
|
|
807
|
+
function sessionTokenParameter(credentials) {
|
|
808
|
+
if (credentials.sessionToken === void 0) return [];
|
|
809
|
+
return [["X-Amz-Security-Token", credentials.sessionToken]];
|
|
810
|
+
}
|
|
811
|
+
async function signingKey(request, dateStamp) {
|
|
812
|
+
return await hmacSha256(await hmacSha256(await hmacSha256(await hmacSha256(new TextEncoder().encode(`AWS4${request.credentials.secretAccessKey}`), dateStamp), request.region), request.service), "aws4_request");
|
|
813
|
+
}
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/request.ts
|
|
816
|
+
const emptyBody = /* @__PURE__ */ new Uint8Array(0);
|
|
817
|
+
/**
|
|
818
|
+
* Spec 4.4 reads `size` off `Content-Length`, and a provider that compresses on the offer
|
|
819
|
+
* `fetch` makes of its own on Node, Bun and Deno drops that header: R2 does for JSON and
|
|
820
|
+
* text. It is sent unsigned, because it concerns the transfer rather than what the
|
|
821
|
+
* provider acts on, and a hop that rewrites it would otherwise break the signature.
|
|
822
|
+
*/
|
|
823
|
+
const identityEncoding = ["accept-encoding", "identity"];
|
|
824
|
+
const service = "s3";
|
|
825
|
+
/**
|
|
826
|
+
* One S3 request, answered by the response the provider sent or rejected with the failure
|
|
827
|
+
* it reported. Spec 7.4 computes the payload hash once and signs every attempt again; the
|
|
828
|
+
* loop of spec 7.5 lives in `@stowage/core`, as the one definition of the budget and the
|
|
829
|
+
* curve that a third-party adapter reads too.
|
|
830
|
+
*/
|
|
831
|
+
async function send(configuration, request) {
|
|
832
|
+
const payloadHash = await sha256Hex(request.body ?? emptyBody);
|
|
833
|
+
let requestsSent = 0;
|
|
834
|
+
try {
|
|
835
|
+
return await withRetry(async () => {
|
|
836
|
+
try {
|
|
837
|
+
return await attemptWithRefresh(configuration, request, payloadHash);
|
|
838
|
+
} catch (failure) {
|
|
839
|
+
if (!isStorageError(failure)) throw failure;
|
|
840
|
+
requestsSent += failure.attempts;
|
|
841
|
+
if (request.repeatWithoutResponse === false && receivedNoResponse(failure)) throw new Unrepeated(withAttemptsMade(failure, requestsSent));
|
|
842
|
+
throw failure;
|
|
843
|
+
}
|
|
844
|
+
}, {
|
|
845
|
+
maxAttempts: configuration.maxAttempts,
|
|
846
|
+
signal: request.signal
|
|
847
|
+
});
|
|
848
|
+
} catch (thrown) {
|
|
849
|
+
if (thrown instanceof Unrepeated) throw thrown.failure;
|
|
850
|
+
throw thrown;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* A failure carried past `withRetry`, which repeats every `retryable` `StorageError` it
|
|
855
|
+
* sees; the one that must not be repeated still reaches the caller as `retryable`,
|
|
856
|
+
* because spec 4.10 has that flag state the condition and not what stowage did about it.
|
|
857
|
+
*/
|
|
858
|
+
var Unrepeated = class {
|
|
859
|
+
failure;
|
|
860
|
+
constructor(failure) {
|
|
861
|
+
this.failure = failure;
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
function receivedNoResponse(failure) {
|
|
865
|
+
return failure.code === "NetworkError" && failure.status === void 0;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* One attempt, which spec 7.3 has cost a second request where the provider answered
|
|
869
|
+
* `Expired`: the credential is resolved again under `forceRefresh` and the request goes
|
|
870
|
+
* out without a delay, because no wait makes a credential fresher. `retry: false` does
|
|
871
|
+
* not switch that repeat off, so an attempt costs one request or two and an operation at
|
|
872
|
+
* most six (ADR 0013).
|
|
873
|
+
*/
|
|
874
|
+
async function attemptWithRefresh(configuration, request, payloadHash) {
|
|
875
|
+
try {
|
|
876
|
+
return await attemptOnce(configuration, request, payloadHash, false, 1);
|
|
877
|
+
} catch (failure) {
|
|
878
|
+
if (!isStorageError(failure) || failure.code !== "Expired") throw failure;
|
|
879
|
+
return await attemptOnce(configuration, request, payloadHash, true, 2);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** One signed request, which is the attempt CONTEXT.md names and what a repeat repeats. */
|
|
883
|
+
async function attemptOnce(configuration, request, payloadHash, forceRefresh, attempts) {
|
|
884
|
+
const path = pathOf(configuration, request.key);
|
|
885
|
+
const query = request.query ?? [];
|
|
886
|
+
const credentials = await resolveCredentials(configuration.credentials, { forceRefresh }).catch((failure) => {
|
|
887
|
+
throw inStorage(failure, configuration.bucket, request.operation, request.key);
|
|
888
|
+
});
|
|
889
|
+
const signed = await signRequest({
|
|
890
|
+
method: request.method,
|
|
891
|
+
host: configuration.host,
|
|
892
|
+
path,
|
|
893
|
+
query,
|
|
894
|
+
headers: [...request.headers ?? [], ["x-amz-content-sha256", payloadHash]],
|
|
895
|
+
payloadHash,
|
|
896
|
+
credentials,
|
|
897
|
+
region: configuration.region,
|
|
898
|
+
service,
|
|
899
|
+
date: /* @__PURE__ */ new Date()
|
|
900
|
+
});
|
|
901
|
+
const url = urlOf(configuration, path, query);
|
|
902
|
+
let response;
|
|
903
|
+
try {
|
|
904
|
+
response = await fetch(url, {
|
|
905
|
+
method: request.method,
|
|
906
|
+
headers: [...signed.headers, identityEncoding].map(([name, value]) => [name, value]),
|
|
907
|
+
body: request.body,
|
|
908
|
+
signal: request.signal
|
|
909
|
+
});
|
|
910
|
+
} catch (failure) {
|
|
911
|
+
throw transportFailure(configuration, request, failure, attempts);
|
|
912
|
+
}
|
|
913
|
+
if (response.ok) return response;
|
|
914
|
+
throw await failureOf(configuration, request, response, attempts);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Spec 7.1: the bucket is addressed virtual-hosted through the host, and path-style
|
|
918
|
+
* through the first segment of the path. The key follows as it stands; `encodePath` is
|
|
919
|
+
* what percent-encodes it, on the URL and in the signature alike.
|
|
920
|
+
*/
|
|
921
|
+
function pathOf(configuration, key) {
|
|
922
|
+
const prefix = configuration.forcePathStyle ? `${configuration.basePath}/${configuration.bucket}` : configuration.basePath;
|
|
923
|
+
if (key === void 0) return prefix === "" ? "/" : prefix;
|
|
924
|
+
return `${prefix}/${key}`;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The URL a request is sent to, its path and query encoded as SigV4 signs them: the
|
|
928
|
+
* provider rebuilds the canonical request from what the URL carries, so the two may not
|
|
929
|
+
* differ by a single escape.
|
|
930
|
+
*/
|
|
931
|
+
function urlOf(configuration, path, query) {
|
|
932
|
+
const search = query.length === 0 ? "" : `?${encodeQuery(query)}`;
|
|
933
|
+
return `${configuration.protocol}//${configuration.host}${encodePath(path)}${search}`;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Spec 7.9: the provider's own code decides where the table recognizes one, the status of
|
|
937
|
+
* spec 4.10 decides where it does not, and the status alone decides whether the condition
|
|
938
|
+
* is transient. `status`, `providerCode`, `requestId` and the provider's message travel
|
|
939
|
+
* along, which is what makes a failure traceable at the provider.
|
|
940
|
+
*/
|
|
941
|
+
async function failureOf(configuration, request, response, attempts) {
|
|
942
|
+
const document = await readFailure(request, response);
|
|
943
|
+
const failure = readProviderFailure({
|
|
944
|
+
status: response.status,
|
|
945
|
+
operation: request.operation,
|
|
946
|
+
method: request.method,
|
|
947
|
+
key: request.key,
|
|
948
|
+
hasContinuationToken: request.query?.some(([name]) => name === "continuation-token"),
|
|
949
|
+
providerCode: document.code,
|
|
950
|
+
providerMessage: document.message,
|
|
951
|
+
bucketRegion: response.headers.get("x-amz-bucket-region") ?? void 0
|
|
952
|
+
});
|
|
953
|
+
return s3Error(configuration.bucket, {
|
|
954
|
+
code: failure.code,
|
|
955
|
+
message: failure.message,
|
|
956
|
+
operation: request.operation,
|
|
957
|
+
key: request.key,
|
|
958
|
+
attempts,
|
|
959
|
+
status: response.status,
|
|
960
|
+
providerCode: document.code,
|
|
961
|
+
requestId: response.headers.get("x-amz-request-id") ?? void 0,
|
|
962
|
+
retryable: isTransientStatus(response.status)
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Spec 7.9: `HEAD` carries no body, so `stat` and `exists` report the status alone. Every
|
|
967
|
+
* other failed request is answered with the provider's error document, and reading it to
|
|
968
|
+
* the end is also what releases the connection the next attempt needs.
|
|
969
|
+
*/
|
|
970
|
+
async function readFailure(request, response) {
|
|
971
|
+
if (request.method === "HEAD") {
|
|
972
|
+
await response.body?.cancel();
|
|
973
|
+
return {};
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
return readErrorDocument(await response.text());
|
|
977
|
+
} catch {
|
|
978
|
+
return {};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function transportFailure(configuration, request, failure, attempts) {
|
|
982
|
+
if (failure instanceof Error && failure.name === "AbortError") return failure;
|
|
983
|
+
return s3Error(configuration.bucket, {
|
|
984
|
+
code: "NetworkError",
|
|
985
|
+
message: `The request received no response: ${String(failure)}`,
|
|
986
|
+
operation: request.operation,
|
|
987
|
+
key: request.key,
|
|
988
|
+
attempts,
|
|
989
|
+
retryable: true,
|
|
990
|
+
cause: failure
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/copy.ts
|
|
995
|
+
/**
|
|
996
|
+
* Spec 7.8: one `CopyObject`, with the provider's refusal of a source too large for it as
|
|
997
|
+
* the answer rather than a fallback to `UploadPartCopy` (ADR 0016). S3's default
|
|
998
|
+
* directive keeps the source's content type and user metadata, which spec 4.11 asks of a
|
|
999
|
+
* copy. Its answer names neither the size nor the user metadata of what it wrote, so a `HEAD`
|
|
1000
|
+
* of the destination describes it.
|
|
1001
|
+
*/
|
|
1002
|
+
async function copyObject(configuration, from, to, operation, signal) {
|
|
1003
|
+
const { bucket } = configuration;
|
|
1004
|
+
signal?.throwIfAborted();
|
|
1005
|
+
const response = await send(configuration, {
|
|
1006
|
+
method: "PUT",
|
|
1007
|
+
operation,
|
|
1008
|
+
key: to,
|
|
1009
|
+
headers: [["x-amz-copy-source", encodePath(`/${bucket}/${from}`)]],
|
|
1010
|
+
signal
|
|
1011
|
+
}).catch((failure) => {
|
|
1012
|
+
if (isStorageError(failure) && failure.providerCode === "NoSuchKey") throw inStorage(failure, bucket, operation, from);
|
|
1013
|
+
throw failure;
|
|
1014
|
+
});
|
|
1015
|
+
await readAnswerDocument({
|
|
1016
|
+
bucket,
|
|
1017
|
+
operation,
|
|
1018
|
+
key: to,
|
|
1019
|
+
subject: "the copy"
|
|
1020
|
+
}, response, "CopyObjectResult");
|
|
1021
|
+
return describeResponse(bucket, to, operation, await send(configuration, {
|
|
1022
|
+
method: "HEAD",
|
|
1023
|
+
operation,
|
|
1024
|
+
key: to,
|
|
1025
|
+
signal
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/key.ts
|
|
1030
|
+
/** The error the key violating the rule is reported as, or `undefined` where it holds. */
|
|
1031
|
+
function keyError(bucket, key, rule, operation) {
|
|
1032
|
+
const reason = invalidKeyReason(key, rule);
|
|
1033
|
+
if (reason === void 0) return void 0;
|
|
1034
|
+
return s3Error(bucket, {
|
|
1035
|
+
code: "InvalidKey",
|
|
1036
|
+
message: `The key ${JSON.stringify(key)} ${reason}`,
|
|
1037
|
+
operation,
|
|
1038
|
+
key,
|
|
1039
|
+
attempts: 0
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
function requireKey(bucket, key, rule, operation) {
|
|
1043
|
+
const error = keyError(bucket, key, rule, operation);
|
|
1044
|
+
if (error !== void 0) throw error;
|
|
1045
|
+
}
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/cursor.ts
|
|
1048
|
+
const cursorTag = "stowage-s3-1:";
|
|
1049
|
+
const unitDigits = 4;
|
|
1050
|
+
const hexPosition = new RegExp(`^(?:[\\da-f]{${unitDigits}})*$`);
|
|
1051
|
+
/** The provider's continuation token, as the opaque string that continues from there. */
|
|
1052
|
+
function encodeCursor(continuationToken) {
|
|
1053
|
+
return btoa(`${cursorTag}${hexOf(continuationToken)}`);
|
|
1054
|
+
}
|
|
1055
|
+
/** The token the cursor continues from, or `undefined` for one this adapter did not produce. */
|
|
1056
|
+
function decodeCursor(cursor) {
|
|
1057
|
+
let decoded;
|
|
1058
|
+
try {
|
|
1059
|
+
decoded = atob(cursor);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (!decoded.startsWith(cursorTag)) return void 0;
|
|
1064
|
+
const position = decoded.slice(13);
|
|
1065
|
+
if (position.length === 0 || !hexPosition.test(position)) return void 0;
|
|
1066
|
+
const token = tokenOf(position);
|
|
1067
|
+
return token.isWellFormed() ? token : void 0;
|
|
1068
|
+
}
|
|
1069
|
+
function hexOf(token) {
|
|
1070
|
+
const units = [];
|
|
1071
|
+
for (let index = 0; index < token.length; index += 1) units.push(token.charCodeAt(index).toString(16).padStart(unitDigits, "0"));
|
|
1072
|
+
return units.join("");
|
|
1073
|
+
}
|
|
1074
|
+
function tokenOf(position) {
|
|
1075
|
+
const units = [];
|
|
1076
|
+
for (let index = 0; index < position.length; index += unitDigits) units.push(String.fromCharCode(Number.parseInt(position.slice(index, index + unitDigits), 16)));
|
|
1077
|
+
return units.join("");
|
|
1078
|
+
}
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/listing-document.ts
|
|
1081
|
+
/**
|
|
1082
|
+
* What the provider listed, read through the parser of ADR 0003. Spec 4.6 makes an entry
|
|
1083
|
+
* that arrives without a key, a size or a last-modified time a `ProviderError`, and
|
|
1084
|
+
* spec 7.4 a document outside the subset the parser reads; both leave the page unread
|
|
1085
|
+
* rather than hand the caller a value made up for what was missing.
|
|
1086
|
+
*/
|
|
1087
|
+
function readListingDocument(answer, body) {
|
|
1088
|
+
const root = parse(answer, body);
|
|
1089
|
+
if (root.name !== "ListBucketResult") throw malformed(answer, `a <${root.name}> where a <ListBucketResult> belongs`);
|
|
1090
|
+
const readName = nameReader(answer, textOf$1(root, "EncodingType"));
|
|
1091
|
+
return {
|
|
1092
|
+
objects: childrenNamed(root, "Contents").map((entry) => readEntry(answer, entry, readName)),
|
|
1093
|
+
prefixes: childrenNamed(root, "CommonPrefixes").map((prefix) => {
|
|
1094
|
+
const text = textOf$1(prefix, "Prefix");
|
|
1095
|
+
if (text === void 0 || text === "") throw malformed(answer, "a pseudo-directory with no prefix");
|
|
1096
|
+
return readName(text);
|
|
1097
|
+
}),
|
|
1098
|
+
continuationToken: continuationOf(answer, root)
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Spec 7.4: under `EncodingType` `url` a key and a prefix arrive as percent-encoded UTF-8,
|
|
1103
|
+
* a space in them possibly as `+`, which S3 never sends for a `+` of the key itself. The
|
|
1104
|
+
* continuation token and the elements the answer echoes are not names this reads.
|
|
1105
|
+
*/
|
|
1106
|
+
function nameReader(answer, encodingType) {
|
|
1107
|
+
if (encodingType !== "url") return (text) => text;
|
|
1108
|
+
return (text) => {
|
|
1109
|
+
try {
|
|
1110
|
+
return decodeURIComponent(text.replaceAll("+", " "));
|
|
1111
|
+
} catch (failure) {
|
|
1112
|
+
if (!(failure instanceof URIError)) throw failure;
|
|
1113
|
+
throw malformed(answer, `the name ${JSON.stringify(text)}, which does not decode`, failure);
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
function parse(answer, body) {
|
|
1118
|
+
try {
|
|
1119
|
+
return parseXml(body);
|
|
1120
|
+
} catch (failure) {
|
|
1121
|
+
if (failure instanceof XmlSyntaxError) throw malformed(answer, `a document outside the XML stowage reads: ${failure.message}`, failure);
|
|
1122
|
+
throw failure;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
function readEntry(answer, entry, readName) {
|
|
1126
|
+
const text = textOf$1(entry, "Key");
|
|
1127
|
+
if (text === void 0 || text === "") throw malformed(answer, "an object with no key");
|
|
1128
|
+
const key = readName(text);
|
|
1129
|
+
const size = sizeOf(textOf$1(entry, "Size"));
|
|
1130
|
+
if (size === void 0) throw malformed(answer, `the object under ${JSON.stringify(key)} with no size`);
|
|
1131
|
+
const lastModified = Date.parse(textOf$1(entry, "LastModified") ?? "");
|
|
1132
|
+
if (Number.isNaN(lastModified)) throw malformed(answer, `the object under ${JSON.stringify(key)} with no last-modified time`);
|
|
1133
|
+
return {
|
|
1134
|
+
key,
|
|
1135
|
+
size,
|
|
1136
|
+
lastModified: new Date(lastModified),
|
|
1137
|
+
etag: etagOf(textOf$1(entry, "ETag"))
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
const decimalDigits = /^\d+$/u;
|
|
1141
|
+
function sizeOf(text) {
|
|
1142
|
+
if (text === void 0 || !decimalDigits.test(text)) return void 0;
|
|
1143
|
+
const size = Number(text);
|
|
1144
|
+
return Number.isSafeInteger(size) ? size : void 0;
|
|
1145
|
+
}
|
|
1146
|
+
function etagOf(text) {
|
|
1147
|
+
if (text === void 0 || text === "") return void 0;
|
|
1148
|
+
return unquotedEtag(text);
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* The token the next page continues from. A listing the provider calls truncated and
|
|
1152
|
+
* hands no token for would end early without a word, so that is malformed too.
|
|
1153
|
+
*/
|
|
1154
|
+
function continuationOf(answer, root) {
|
|
1155
|
+
const truncated = textOf$1(root, "IsTruncated");
|
|
1156
|
+
if (truncated === "false") return void 0;
|
|
1157
|
+
if (truncated !== "true") throw malformed(answer, "no word on whether the listing is complete");
|
|
1158
|
+
const token = textOf$1(root, "NextContinuationToken");
|
|
1159
|
+
if (token === void 0 || token === "") throw malformed(answer, "a listing it calls incomplete and no position to continue from");
|
|
1160
|
+
return token;
|
|
1161
|
+
}
|
|
1162
|
+
function childrenNamed(element, name) {
|
|
1163
|
+
return element.children.filter((child) => child.name === name);
|
|
1164
|
+
}
|
|
1165
|
+
function malformed(answer, what, cause) {
|
|
1166
|
+
return s3Error(answer.bucket, {
|
|
1167
|
+
code: "ProviderError",
|
|
1168
|
+
message: `The provider answered the listing with ${what}`,
|
|
1169
|
+
operation: answer.operation,
|
|
1170
|
+
attempts: 1,
|
|
1171
|
+
status: answer.status,
|
|
1172
|
+
requestId: answer.requestId,
|
|
1173
|
+
cause
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/listing.ts
|
|
1178
|
+
const defaultPageSize = 1e3;
|
|
1179
|
+
const maxPageSize = 1e3;
|
|
1180
|
+
/**
|
|
1181
|
+
* Spec 4.6: a listing sends no request until it is read, so an option it refuses reaches
|
|
1182
|
+
* the caller from `page()` and from the iteration and not from `list`.
|
|
1183
|
+
*/
|
|
1184
|
+
function createListing(configuration, options) {
|
|
1185
|
+
return {
|
|
1186
|
+
async page() {
|
|
1187
|
+
const document = await requestPage(configuration, readListRequest(configuration.bucket, options));
|
|
1188
|
+
return {
|
|
1189
|
+
objects: document.objects,
|
|
1190
|
+
prefixes: document.prefixes,
|
|
1191
|
+
cursor: document.continuationToken === void 0 ? void 0 : encodeCursor(document.continuationToken)
|
|
1192
|
+
};
|
|
1193
|
+
},
|
|
1194
|
+
async *[Symbol.asyncIterator]() {
|
|
1195
|
+
for await (const document of walkPages(configuration, readListRequest(configuration.bucket, options))) yield* document.objects;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Every page of the listing from where the request starts to its end, which `list`
|
|
1201
|
+
* iterates and `deleteAll` deletes page by page as it arrives.
|
|
1202
|
+
*/
|
|
1203
|
+
async function* walkPages(configuration, request) {
|
|
1204
|
+
for (let { continuationToken } = request;;) {
|
|
1205
|
+
const document = await requestPage(configuration, {
|
|
1206
|
+
...request,
|
|
1207
|
+
continuationToken
|
|
1208
|
+
});
|
|
1209
|
+
if (document.continuationToken !== void 0 && document.continuationToken === continuationToken) throw s3Error(configuration.bucket, {
|
|
1210
|
+
code: "ProviderError",
|
|
1211
|
+
message: "The provider repeated the continuation token it was sent",
|
|
1212
|
+
operation: request.operation,
|
|
1213
|
+
attempts: 1
|
|
1214
|
+
});
|
|
1215
|
+
yield document;
|
|
1216
|
+
if (document.continuationToken === void 0) return;
|
|
1217
|
+
continuationToken = document.continuationToken;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/** Everything spec 4.11 has `list` refuse without asking the provider. */
|
|
1221
|
+
function readListRequest(bucket, options) {
|
|
1222
|
+
requireKnownOptions(bucket, options, listOptionKeys, "list");
|
|
1223
|
+
const prefix = options?.prefix ?? "";
|
|
1224
|
+
requireKey(bucket, prefix, "prefix", "list");
|
|
1225
|
+
const pageSize = options?.pageSize ?? defaultPageSize;
|
|
1226
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1e3) throw optionError$1(bucket, "pageSize", `takes a whole number from 1 to ${maxPageSize}`, "list");
|
|
1227
|
+
if (options?.delimiter === "") throw optionError$1(bucket, "delimiter", "takes at least one character", "list");
|
|
1228
|
+
const cursor = options?.cursor;
|
|
1229
|
+
const continuationToken = cursor === void 0 ? void 0 : decodeCursor(cursor);
|
|
1230
|
+
if (cursor !== void 0 && continuationToken === void 0) throw optionError$1(bucket, "cursor", "takes a cursor this storage handed out", "list");
|
|
1231
|
+
return {
|
|
1232
|
+
operation: "list",
|
|
1233
|
+
prefix,
|
|
1234
|
+
delimiter: options?.delimiter,
|
|
1235
|
+
pageSize,
|
|
1236
|
+
continuationToken,
|
|
1237
|
+
signal: options?.signal
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
async function requestPage(configuration, request) {
|
|
1241
|
+
request.signal?.throwIfAborted();
|
|
1242
|
+
const query = [
|
|
1243
|
+
["list-type", "2"],
|
|
1244
|
+
["max-keys", String(request.pageSize)],
|
|
1245
|
+
["encoding-type", "url"]
|
|
1246
|
+
];
|
|
1247
|
+
if (request.prefix !== "") query.push(["prefix", request.prefix]);
|
|
1248
|
+
if (request.delimiter !== void 0) query.push(["delimiter", request.delimiter]);
|
|
1249
|
+
if (request.continuationToken !== void 0) query.push(["continuation-token", request.continuationToken]);
|
|
1250
|
+
const response = await send(configuration, {
|
|
1251
|
+
method: "GET",
|
|
1252
|
+
operation: request.operation,
|
|
1253
|
+
query,
|
|
1254
|
+
signal: request.signal
|
|
1255
|
+
});
|
|
1256
|
+
const answer = {
|
|
1257
|
+
bucket: configuration.bucket,
|
|
1258
|
+
operation: request.operation,
|
|
1259
|
+
status: response.status,
|
|
1260
|
+
requestId: response.headers.get("x-amz-request-id") ?? void 0
|
|
1261
|
+
};
|
|
1262
|
+
return readListingDocument(answer, await readBody(answer, response));
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Spec 7.5 repeats a transport failure that received no response; this one received its
|
|
1266
|
+
* response and broke in the body, which spec 4.5 leaves unresumed for `get` as well.
|
|
1267
|
+
*/
|
|
1268
|
+
async function readBody(answer, response) {
|
|
1269
|
+
try {
|
|
1270
|
+
return await response.text();
|
|
1271
|
+
} catch (failure) {
|
|
1272
|
+
if (failure instanceof Error && failure.name === "AbortError") throw failure;
|
|
1273
|
+
throw s3Error(answer.bucket, {
|
|
1274
|
+
code: "NetworkError",
|
|
1275
|
+
message: `The listing broke while it was read: ${String(failure)}`,
|
|
1276
|
+
operation: answer.operation,
|
|
1277
|
+
attempts: 1,
|
|
1278
|
+
status: answer.status,
|
|
1279
|
+
requestId: answer.requestId,
|
|
1280
|
+
retryable: true,
|
|
1281
|
+
cause: failure
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
//#endregion
|
|
1286
|
+
//#region src/md5.ts
|
|
1287
|
+
/** The per-round shift amounts of RFC 1321, section 3.4. */
|
|
1288
|
+
const shifts = [
|
|
1289
|
+
7,
|
|
1290
|
+
12,
|
|
1291
|
+
17,
|
|
1292
|
+
22,
|
|
1293
|
+
7,
|
|
1294
|
+
12,
|
|
1295
|
+
17,
|
|
1296
|
+
22,
|
|
1297
|
+
7,
|
|
1298
|
+
12,
|
|
1299
|
+
17,
|
|
1300
|
+
22,
|
|
1301
|
+
7,
|
|
1302
|
+
12,
|
|
1303
|
+
17,
|
|
1304
|
+
22,
|
|
1305
|
+
5,
|
|
1306
|
+
9,
|
|
1307
|
+
14,
|
|
1308
|
+
20,
|
|
1309
|
+
5,
|
|
1310
|
+
9,
|
|
1311
|
+
14,
|
|
1312
|
+
20,
|
|
1313
|
+
5,
|
|
1314
|
+
9,
|
|
1315
|
+
14,
|
|
1316
|
+
20,
|
|
1317
|
+
5,
|
|
1318
|
+
9,
|
|
1319
|
+
14,
|
|
1320
|
+
20,
|
|
1321
|
+
4,
|
|
1322
|
+
11,
|
|
1323
|
+
16,
|
|
1324
|
+
23,
|
|
1325
|
+
4,
|
|
1326
|
+
11,
|
|
1327
|
+
16,
|
|
1328
|
+
23,
|
|
1329
|
+
4,
|
|
1330
|
+
11,
|
|
1331
|
+
16,
|
|
1332
|
+
23,
|
|
1333
|
+
4,
|
|
1334
|
+
11,
|
|
1335
|
+
16,
|
|
1336
|
+
23,
|
|
1337
|
+
6,
|
|
1338
|
+
10,
|
|
1339
|
+
15,
|
|
1340
|
+
21,
|
|
1341
|
+
6,
|
|
1342
|
+
10,
|
|
1343
|
+
15,
|
|
1344
|
+
21,
|
|
1345
|
+
6,
|
|
1346
|
+
10,
|
|
1347
|
+
15,
|
|
1348
|
+
21,
|
|
1349
|
+
6,
|
|
1350
|
+
10,
|
|
1351
|
+
15,
|
|
1352
|
+
21
|
|
1353
|
+
];
|
|
1354
|
+
/** `floor(abs(sin(i + 1)) × 2^32)`, the table T of RFC 1321, section 3.4. */
|
|
1355
|
+
const sines = Array.from({ length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 2 ** 32) >>> 0);
|
|
1356
|
+
const blockBytes = 64;
|
|
1357
|
+
/**
|
|
1358
|
+
* The base64 of the MD5 digest, which is the form `Content-MD5` carries. `DeleteObjects`
|
|
1359
|
+
* is the one request AWS refuses without an integrity header, and ADR 0009 sends no
|
|
1360
|
+
* `x-amz-checksum-*`; Web Crypto offers no MD5 and `node:crypto` is not on every runtime
|
|
1361
|
+
* of spec 2, so the digest is computed here.
|
|
1362
|
+
*/
|
|
1363
|
+
function md5Base64(message) {
|
|
1364
|
+
const digest = md5(message);
|
|
1365
|
+
return btoa(String.fromCharCode(...digest));
|
|
1366
|
+
}
|
|
1367
|
+
function md5(message) {
|
|
1368
|
+
const padded = pad(message);
|
|
1369
|
+
const words = new DataView(padded.buffer);
|
|
1370
|
+
const state = [
|
|
1371
|
+
1732584193,
|
|
1372
|
+
4023233417,
|
|
1373
|
+
2562383102,
|
|
1374
|
+
271733878
|
|
1375
|
+
];
|
|
1376
|
+
for (let offset = 0; offset < padded.byteLength; offset += blockBytes) compress(state, words, offset);
|
|
1377
|
+
const digest = /* @__PURE__ */ new Uint8Array(16);
|
|
1378
|
+
const view = new DataView(digest.buffer);
|
|
1379
|
+
for (const [index, word] of state.entries()) view.setUint32(index * 4, word, true);
|
|
1380
|
+
return digest;
|
|
1381
|
+
}
|
|
1382
|
+
/** A `1` bit, zeros up to 56 bytes into a block, and the bit length as 64 bits, low first. */
|
|
1383
|
+
function pad(message) {
|
|
1384
|
+
const length = Math.ceil((message.byteLength + 9) / blockBytes) * blockBytes;
|
|
1385
|
+
const padded = new Uint8Array(length);
|
|
1386
|
+
const view = new DataView(padded.buffer);
|
|
1387
|
+
const bits = message.byteLength * 8;
|
|
1388
|
+
padded.set(message);
|
|
1389
|
+
padded[message.byteLength] = 128;
|
|
1390
|
+
view.setUint32(length - 8, bits >>> 0, true);
|
|
1391
|
+
view.setUint32(length - 4, Math.floor(bits / 2 ** 32), true);
|
|
1392
|
+
return padded;
|
|
1393
|
+
}
|
|
1394
|
+
function compress(state, words, offset) {
|
|
1395
|
+
let [a, b, c, d] = state;
|
|
1396
|
+
for (let round = 0; round < 64; round += 1) {
|
|
1397
|
+
let mixed;
|
|
1398
|
+
let word;
|
|
1399
|
+
if (round < 16) {
|
|
1400
|
+
mixed = b & c | ~b & d;
|
|
1401
|
+
word = round;
|
|
1402
|
+
} else if (round < 32) {
|
|
1403
|
+
mixed = d & b | ~d & c;
|
|
1404
|
+
word = (5 * round + 1) % 16;
|
|
1405
|
+
} else if (round < 48) {
|
|
1406
|
+
mixed = b ^ c ^ d;
|
|
1407
|
+
word = (3 * round + 5) % 16;
|
|
1408
|
+
} else {
|
|
1409
|
+
mixed = c ^ (b | ~d);
|
|
1410
|
+
word = 7 * round % 16;
|
|
1411
|
+
}
|
|
1412
|
+
const sum = a + mixed + (sines[round] ?? 0) + words.getUint32(offset + word * 4, true) >>> 0;
|
|
1413
|
+
const shift = shifts[round] ?? 0;
|
|
1414
|
+
a = d;
|
|
1415
|
+
d = c;
|
|
1416
|
+
c = b;
|
|
1417
|
+
b = b + (sum << shift | sum >>> 32 - shift) >>> 0;
|
|
1418
|
+
}
|
|
1419
|
+
state[0] = state[0] + a >>> 0;
|
|
1420
|
+
state[1] = state[1] + b >>> 0;
|
|
1421
|
+
state[2] = state[2] + c >>> 0;
|
|
1422
|
+
state[3] = state[3] + d >>> 0;
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/delete.ts
|
|
1426
|
+
/** What one `DeleteObjects` names at most, and so what spec 7.1 sends one request per. */
|
|
1427
|
+
const keysPerRequest = 1e3;
|
|
1428
|
+
/**
|
|
1429
|
+
* The characters of a key XML 1.0 carries neither raw nor as a reference, which is what
|
|
1430
|
+
* is left of those outside its `Char` once spec 4.8 refused the controls (ADR 0027).
|
|
1431
|
+
*/
|
|
1432
|
+
const outsideXmlChar = /[\uFFFE\uFFFF]/u;
|
|
1433
|
+
/**
|
|
1434
|
+
* Spec 4.7 rejects the call for a failure that says nothing about the key: no response, a
|
|
1435
|
+
* credential the provider refuses, a bucket that is absent or lives in another region. A
|
|
1436
|
+
* missing key answers `204`, so `NotFound` names the bucket. What remains is what
|
|
1437
|
+
* `DeleteObjects` reports per key, `AccessDenied` among it.
|
|
1438
|
+
*/
|
|
1439
|
+
const requestWideCodes = /* @__PURE__ */ new Set([
|
|
1440
|
+
"NetworkError",
|
|
1441
|
+
"InvalidCredentials",
|
|
1442
|
+
"Expired",
|
|
1443
|
+
"NotFound",
|
|
1444
|
+
"InvalidOption"
|
|
1445
|
+
]);
|
|
1446
|
+
/**
|
|
1447
|
+
* Spec 7.9 files a skewed clock under `InvalidRequest`, which otherwise concerns what one
|
|
1448
|
+
* request asked for, and every request after it would be signed against the same clock.
|
|
1449
|
+
*/
|
|
1450
|
+
const requestWideProviderCodes = /* @__PURE__ */ new Set(["RequestTimeTooSkewed"]);
|
|
1451
|
+
function failsTheRequestAsAWhole(failure) {
|
|
1452
|
+
return requestWideCodes.has(failure.code) || failure.providerCode !== void 0 && requestWideProviderCodes.has(failure.providerCode);
|
|
1453
|
+
}
|
|
1454
|
+
const utf8$2 = new TextEncoder();
|
|
1455
|
+
/**
|
|
1456
|
+
* Spec 4.7: every key is reported, the invalid ones as `InvalidKey` without being sent,
|
|
1457
|
+
* and a failure of a request as a whole rejects the call instead of filling the report.
|
|
1458
|
+
*/
|
|
1459
|
+
async function deleteKeys(configuration, keys, call) {
|
|
1460
|
+
const failed = [];
|
|
1461
|
+
const batched = [];
|
|
1462
|
+
const alone = [];
|
|
1463
|
+
for (const key of keys) {
|
|
1464
|
+
const refusal = keyError(configuration.bucket, key, "addressable", call.operation);
|
|
1465
|
+
if (refusal !== void 0) failed.push(refusal);
|
|
1466
|
+
else if (outsideXmlChar.test(key)) alone.push(key);
|
|
1467
|
+
else batched.push(key);
|
|
1468
|
+
}
|
|
1469
|
+
for (let offset = 0; offset < batched.length; offset += keysPerRequest) {
|
|
1470
|
+
const slice = batched.slice(offset, offset + keysPerRequest);
|
|
1471
|
+
failed.push(...await deleteBatch(configuration, slice, call));
|
|
1472
|
+
}
|
|
1473
|
+
for (const key of alone) {
|
|
1474
|
+
const failure = await deleteAlone(configuration, key, call);
|
|
1475
|
+
if (failure !== void 0) failed.push(failure);
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
requested: keys.length,
|
|
1479
|
+
failed
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Spec 4.11: every object below the prefix, listed a page at a time and each page deleted
|
|
1484
|
+
* as it arrives, so the call holds one page of keys whatever the prefix holds.
|
|
1485
|
+
*/
|
|
1486
|
+
async function deleteBelow(configuration, prefix, signal) {
|
|
1487
|
+
const operation = "deleteAll";
|
|
1488
|
+
const failed = [];
|
|
1489
|
+
let requested = 0;
|
|
1490
|
+
for await (const page of walkPages(configuration, {
|
|
1491
|
+
operation,
|
|
1492
|
+
prefix,
|
|
1493
|
+
pageSize: maxPageSize,
|
|
1494
|
+
signal
|
|
1495
|
+
})) {
|
|
1496
|
+
const report = await deleteKeys(configuration, page.objects.map((entry) => entry.key), {
|
|
1497
|
+
operation,
|
|
1498
|
+
signal
|
|
1499
|
+
});
|
|
1500
|
+
requested += report.requested;
|
|
1501
|
+
failed.push(...report.failed);
|
|
1502
|
+
}
|
|
1503
|
+
return {
|
|
1504
|
+
requested,
|
|
1505
|
+
failed
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
async function deleteBatch(configuration, keys, call) {
|
|
1509
|
+
if (keys.length === 0) return [];
|
|
1510
|
+
call.signal?.throwIfAborted();
|
|
1511
|
+
const body = utf8$2.encode(deleteDocument(keys));
|
|
1512
|
+
const response = await send(configuration, {
|
|
1513
|
+
method: "POST",
|
|
1514
|
+
operation: call.operation,
|
|
1515
|
+
query: [["delete", ""]],
|
|
1516
|
+
headers: [["content-type", "application/xml"], ["content-md5", md5Base64(body)]],
|
|
1517
|
+
body,
|
|
1518
|
+
signal: call.signal
|
|
1519
|
+
});
|
|
1520
|
+
const requestId = response.headers.get("x-amz-request-id") ?? void 0;
|
|
1521
|
+
const answered = {
|
|
1522
|
+
bucket: configuration.bucket,
|
|
1523
|
+
operation: call.operation,
|
|
1524
|
+
subject: "the deletion"
|
|
1525
|
+
};
|
|
1526
|
+
return (await readAnswerDocument(answered, response, "DeleteResult")).children.filter((child) => child.name === "Error").map((entry) => keyFailure(answered, entry, requestId));
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Spec 7.4: a key no `DeleteObjects` body can carry travels percent-encoded in the path,
|
|
1530
|
+
* where no XML parser at the provider sees it, on the budget of a request of its own.
|
|
1531
|
+
*/
|
|
1532
|
+
async function deleteAlone(configuration, key, call) {
|
|
1533
|
+
call.signal?.throwIfAborted();
|
|
1534
|
+
try {
|
|
1535
|
+
await (await send(configuration, {
|
|
1536
|
+
method: "DELETE",
|
|
1537
|
+
operation: call.operation,
|
|
1538
|
+
key,
|
|
1539
|
+
signal: call.signal
|
|
1540
|
+
})).body?.cancel();
|
|
1541
|
+
return;
|
|
1542
|
+
} catch (failure) {
|
|
1543
|
+
if (isStorageError(failure) && !failsTheRequestAsAWhole(failure)) return failure;
|
|
1544
|
+
throw failure;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
/** `Quiet` has the provider answer with the keys it failed alone. */
|
|
1548
|
+
function deleteDocument(keys) {
|
|
1549
|
+
return `<?xml version="1.0" encoding="UTF-8"?><Delete><Quiet>true</Quiet>${keys.map((key) => `<Object><Key>${escapeXml(key)}</Key></Object>`).join("")}</Delete>`;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* One key the provider failed, told as the answer's failure is, without the `200` that
|
|
1553
|
+
* spoke for the whole request. Spec 4.7 has every entry carry its key, so an entry the
|
|
1554
|
+
* provider names no key for leaves the answer unread rather than reported keyless.
|
|
1555
|
+
*/
|
|
1556
|
+
function keyFailure(request, entry, requestId) {
|
|
1557
|
+
const key = textOf$1(entry, "Key");
|
|
1558
|
+
if (key === void 0 || key === "") throw s3Error(request.bucket, {
|
|
1559
|
+
code: "ProviderError",
|
|
1560
|
+
message: `The provider answered ${request.subject} with a failed key it did not name`,
|
|
1561
|
+
operation: request.operation,
|
|
1562
|
+
attempts: 1,
|
|
1563
|
+
requestId
|
|
1564
|
+
});
|
|
1565
|
+
return embeddedFailure(request, {
|
|
1566
|
+
operation: request.operation,
|
|
1567
|
+
key,
|
|
1568
|
+
attempts: 1,
|
|
1569
|
+
requestId
|
|
1570
|
+
}, entry);
|
|
1571
|
+
}
|
|
1572
|
+
//#endregion
|
|
1573
|
+
//#region src/presign.ts
|
|
1574
|
+
/** The ceiling SigV4 query signing sets on `X-Amz-Expires`, a week in seconds. */
|
|
1575
|
+
const longestLifetime = 604800;
|
|
1576
|
+
/** Each override of spec 7.10 and the query parameter S3 answers as its response header. */
|
|
1577
|
+
const responseOverrides = [
|
|
1578
|
+
["responseContentType", "response-content-type"],
|
|
1579
|
+
["responseContentDisposition", "response-content-disposition"],
|
|
1580
|
+
["responseCacheControl", "response-cache-control"],
|
|
1581
|
+
["responseExpires", "response-expires"]
|
|
1582
|
+
];
|
|
1583
|
+
/** Spec 7.10: `GetObject` on an addressable key, the overrides carried in the query. */
|
|
1584
|
+
async function presignGet(configuration, key, options) {
|
|
1585
|
+
const operation = "presignGet";
|
|
1586
|
+
requireKey(configuration.bucket, key, "addressable", operation);
|
|
1587
|
+
const given = readGroup(configuration.bucket, options, presignGetOptionKeys, operation);
|
|
1588
|
+
const expiresIn = readExpiresIn(configuration.bucket, given.expiresIn, operation);
|
|
1589
|
+
const query = [];
|
|
1590
|
+
for (const [option, parameter] of responseOverrides) {
|
|
1591
|
+
const value = given[option];
|
|
1592
|
+
if (value === void 0) continue;
|
|
1593
|
+
query.push([parameter, readText(configuration.bucket, value, option, operation)]);
|
|
1594
|
+
}
|
|
1595
|
+
return await presignedUrl(configuration, {
|
|
1596
|
+
method: "GET",
|
|
1597
|
+
operation,
|
|
1598
|
+
key,
|
|
1599
|
+
query,
|
|
1600
|
+
headers: [],
|
|
1601
|
+
expiresIn
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Spec 7.10: `PutObject` on a writable key, with the content type and the content length
|
|
1606
|
+
* bound through signed headers. Nothing else is signed in: no user metadata and no
|
|
1607
|
+
* checksum, which the browser would have to match exactly for a `403` that names nothing
|
|
1608
|
+
* (ADR 0011). Of the two, only the content type is handed back to send beside the body
|
|
1609
|
+
* (spec 4.13).
|
|
1610
|
+
*/
|
|
1611
|
+
async function presignPut(configuration, key, options) {
|
|
1612
|
+
const operation = "presignPut";
|
|
1613
|
+
requireKey(configuration.bucket, key, "writable", operation);
|
|
1614
|
+
const given = readGroup(configuration.bucket, options, presignPutOptionKeys, operation);
|
|
1615
|
+
const expiresIn = readExpiresIn(configuration.bucket, given.expiresIn, operation);
|
|
1616
|
+
const contentType = readText(configuration.bucket, given.contentType, "contentType", operation);
|
|
1617
|
+
const contentLength = readContentLength(configuration.bucket, given.contentLength, operation);
|
|
1618
|
+
return {
|
|
1619
|
+
url: await presignedUrl(configuration, {
|
|
1620
|
+
method: "PUT",
|
|
1621
|
+
operation,
|
|
1622
|
+
key,
|
|
1623
|
+
query: [],
|
|
1624
|
+
headers: [["content-type", contentType], ["content-length", contentLength]],
|
|
1625
|
+
expiresIn
|
|
1626
|
+
}),
|
|
1627
|
+
headers: { "content-type": contentType }
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Spec 7.10: nothing is sent, so the one failure left after the options is the
|
|
1632
|
+
* credential's. It is resolved for every URL, as for every request (spec 7.3).
|
|
1633
|
+
*/
|
|
1634
|
+
async function presignedUrl(configuration, request) {
|
|
1635
|
+
const credentials = await resolveCredentials(configuration.credentials, { forceRefresh: false }).catch((failure) => {
|
|
1636
|
+
if (isStorageError(failure) && failure.code === "InvalidCredentials") throw inStorage(failure, configuration.bucket, request.operation, request.key);
|
|
1637
|
+
throw s3Error(configuration.bucket, {
|
|
1638
|
+
code: "InvalidCredentials",
|
|
1639
|
+
message: "The credential resolver failed",
|
|
1640
|
+
operation: request.operation,
|
|
1641
|
+
key: request.key,
|
|
1642
|
+
attempts: 0,
|
|
1643
|
+
cause: failure
|
|
1644
|
+
});
|
|
1645
|
+
});
|
|
1646
|
+
const path = pathOf(configuration, request.key);
|
|
1647
|
+
return urlOf(configuration, path, (await presignRequest({
|
|
1648
|
+
method: request.method,
|
|
1649
|
+
host: configuration.host,
|
|
1650
|
+
path,
|
|
1651
|
+
query: request.query,
|
|
1652
|
+
headers: request.headers,
|
|
1653
|
+
credentials,
|
|
1654
|
+
region: configuration.region,
|
|
1655
|
+
service: "s3",
|
|
1656
|
+
date: /* @__PURE__ */ new Date(),
|
|
1657
|
+
expiresIn: request.expiresIn
|
|
1658
|
+
})).query);
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* The options as a group whose keys are all known. A caller outside TypeScript may hand
|
|
1662
|
+
* no group at all, which reads as one holding nothing, so the required `expiresIn` is
|
|
1663
|
+
* what the refusal names.
|
|
1664
|
+
*/
|
|
1665
|
+
function readGroup(bucket, options, known, operation) {
|
|
1666
|
+
if (typeof options !== "object" || options === null) return {};
|
|
1667
|
+
requireKnownOptions(bucket, options, known, operation);
|
|
1668
|
+
return { ...options };
|
|
1669
|
+
}
|
|
1670
|
+
/** ADR 0011: outside the week SigV4 allows, refused rather than signed for a `403`. */
|
|
1671
|
+
function readExpiresIn(bucket, value, operation) {
|
|
1672
|
+
if (typeof value === "number" && value >= 1 && value <= longestLifetime && Number.isInteger(value)) return value;
|
|
1673
|
+
throw optionError$1(bucket, "expiresIn", `takes the whole seconds 1 to ${longestLifetime}, the lifetime SigV4 allows`, operation);
|
|
1674
|
+
}
|
|
1675
|
+
function readContentLength(bucket, value, operation) {
|
|
1676
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) return BigInt(value).toString();
|
|
1677
|
+
throw optionError$1(bucket, "contentLength", "takes a finite, non-negative integer", operation);
|
|
1678
|
+
}
|
|
1679
|
+
function readText(bucket, value, option, operation) {
|
|
1680
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1681
|
+
throw optionError$1(bucket, option, "takes a non-empty string", operation);
|
|
1682
|
+
}
|
|
1683
|
+
//#endregion
|
|
1684
|
+
//#region src/stored-object.ts
|
|
1685
|
+
/**
|
|
1686
|
+
* Spec 4.5: the description and the body come out of one response, the body is read
|
|
1687
|
+
* once, and a stream that breaks after `get` resolved errors with a `StorageError`
|
|
1688
|
+
* whichever of the four readers took it.
|
|
1689
|
+
*/
|
|
1690
|
+
function createStoredObject(bucket, stat, response) {
|
|
1691
|
+
let read = false;
|
|
1692
|
+
const take = () => {
|
|
1693
|
+
if (read) throw s3Error(bucket, {
|
|
1694
|
+
code: "InvalidRequest",
|
|
1695
|
+
message: "The body of this stored object has already been read",
|
|
1696
|
+
operation: "get",
|
|
1697
|
+
key: stat.key,
|
|
1698
|
+
attempts: 0
|
|
1699
|
+
});
|
|
1700
|
+
read = true;
|
|
1701
|
+
};
|
|
1702
|
+
const broken = (failure) => s3Error(bucket, {
|
|
1703
|
+
code: "NetworkError",
|
|
1704
|
+
message: `The body of the object broke while it was read: ${String(failure)}`,
|
|
1705
|
+
operation: "get",
|
|
1706
|
+
key: stat.key,
|
|
1707
|
+
attempts: 1,
|
|
1708
|
+
retryable: true,
|
|
1709
|
+
requestId: response.headers.get("x-amz-request-id") ?? void 0,
|
|
1710
|
+
cause: failure
|
|
1711
|
+
});
|
|
1712
|
+
const asText = async () => {
|
|
1713
|
+
take();
|
|
1714
|
+
try {
|
|
1715
|
+
return await response.text();
|
|
1716
|
+
} catch (failure) {
|
|
1717
|
+
throw broken(failure);
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
return {
|
|
1721
|
+
stat,
|
|
1722
|
+
stream() {
|
|
1723
|
+
let body;
|
|
1724
|
+
try {
|
|
1725
|
+
take();
|
|
1726
|
+
body = response.body ?? emptyStream();
|
|
1727
|
+
} catch (refusal) {
|
|
1728
|
+
return new ReadableStream({ start(controller) {
|
|
1729
|
+
controller.error(refusal);
|
|
1730
|
+
} });
|
|
1731
|
+
}
|
|
1732
|
+
return reportingFailure(body, broken);
|
|
1733
|
+
},
|
|
1734
|
+
async bytes() {
|
|
1735
|
+
take();
|
|
1736
|
+
try {
|
|
1737
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1738
|
+
} catch (failure) {
|
|
1739
|
+
throw broken(failure);
|
|
1740
|
+
}
|
|
1741
|
+
},
|
|
1742
|
+
text: asText,
|
|
1743
|
+
async json() {
|
|
1744
|
+
return JSON.parse(await asText());
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
function emptyStream() {
|
|
1749
|
+
return new ReadableStream({ start(controller) {
|
|
1750
|
+
controller.close();
|
|
1751
|
+
} });
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* The same bytes, with a break of the response body reported as a `StorageError`. A
|
|
1755
|
+
* cancel reaches the response through the reader, which is what cancels the request
|
|
1756
|
+
* behind the stream (spec 4.5).
|
|
1757
|
+
*/
|
|
1758
|
+
function reportingFailure(body, asStorageError) {
|
|
1759
|
+
const reader = body.getReader();
|
|
1760
|
+
return new ReadableStream({
|
|
1761
|
+
async pull(controller) {
|
|
1762
|
+
try {
|
|
1763
|
+
const { done, value } = await reader.read();
|
|
1764
|
+
if (done) controller.close();
|
|
1765
|
+
else controller.enqueue(value);
|
|
1766
|
+
} catch (failure) {
|
|
1767
|
+
controller.error(asStorageError(failure));
|
|
1768
|
+
}
|
|
1769
|
+
},
|
|
1770
|
+
async cancel(reason) {
|
|
1771
|
+
await reader.cancel(reason);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
//#endregion
|
|
1776
|
+
//#region src/upload.ts
|
|
1777
|
+
/** The object's own headers, which a multipart upload sends with the request that starts it. */
|
|
1778
|
+
function objectHeaders(write) {
|
|
1779
|
+
return [["content-type", write.contentType], ...write.userMetadata.headers];
|
|
1780
|
+
}
|
|
1781
|
+
/** Spec 7.6: bytes the adapter holds go as one `PUT`, which it never splits. */
|
|
1782
|
+
async function putObject(configuration, write, bytes) {
|
|
1783
|
+
const response = await send(configuration, {
|
|
1784
|
+
method: "PUT",
|
|
1785
|
+
operation: "put",
|
|
1786
|
+
key: write.key,
|
|
1787
|
+
headers: objectHeaders(write),
|
|
1788
|
+
body: bytes,
|
|
1789
|
+
signal: write.signal
|
|
1790
|
+
});
|
|
1791
|
+
await response.body?.cancel();
|
|
1792
|
+
return describeWrite(configuration.bucket, write.key, bytes.byteLength, write.contentType, write.userMetadata.held, response);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Spec 7.6: a stream is read into parts of `partSize`, and one that ends within the first
|
|
1796
|
+
* goes as the `PUT` held bytes get.
|
|
1797
|
+
*/
|
|
1798
|
+
async function putStream(configuration, write, stream) {
|
|
1799
|
+
return await uploadStream(stream, {
|
|
1800
|
+
partSize: configuration.partSize,
|
|
1801
|
+
concurrency: configuration.concurrency,
|
|
1802
|
+
maxParts,
|
|
1803
|
+
bucket: configuration.bucket,
|
|
1804
|
+
provider: "s3",
|
|
1805
|
+
key: write.key,
|
|
1806
|
+
signal: write.signal
|
|
1807
|
+
}, {
|
|
1808
|
+
whole: async (bytes) => await putObject(configuration, write, bytes),
|
|
1809
|
+
multipart: async (sendParts) => await multipartUpload(configuration, write, sendParts)
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
const s3Namespace = "http://s3.amazonaws.com/doc/2006-03-01/";
|
|
1813
|
+
/** The provider's limit on both sides, which ADR 0016 leaves no option to lift. */
|
|
1814
|
+
const maxParts = 1e4;
|
|
1815
|
+
/**
|
|
1816
|
+
* Spec 7.6: a multipart upload aborts itself once it failed, after the parts in flight
|
|
1817
|
+
* and the source stream were canceled.
|
|
1818
|
+
*/
|
|
1819
|
+
async function multipartUpload(configuration, write, sendParts) {
|
|
1820
|
+
const uploadId = await createUpload(configuration, write);
|
|
1821
|
+
let sent;
|
|
1822
|
+
try {
|
|
1823
|
+
sent = await sendParts(async (index, bytes, signal) => {
|
|
1824
|
+
const number = index + 1;
|
|
1825
|
+
return {
|
|
1826
|
+
number,
|
|
1827
|
+
etag: await uploadPart(configuration, {
|
|
1828
|
+
...write,
|
|
1829
|
+
signal
|
|
1830
|
+
}, uploadId, number, bytes)
|
|
1831
|
+
};
|
|
1832
|
+
});
|
|
1833
|
+
} catch (failure) {
|
|
1834
|
+
await abortUpload(configuration, write, uploadId);
|
|
1835
|
+
throw failure;
|
|
1836
|
+
}
|
|
1837
|
+
return await completeUpload(configuration, write, uploadId, sent.results, sent.size);
|
|
1838
|
+
}
|
|
1839
|
+
async function createUpload(configuration, write) {
|
|
1840
|
+
const response = await send(configuration, {
|
|
1841
|
+
method: "POST",
|
|
1842
|
+
operation: "put",
|
|
1843
|
+
key: write.key,
|
|
1844
|
+
query: [["uploads", ""]],
|
|
1845
|
+
headers: objectHeaders(write),
|
|
1846
|
+
signal: write.signal
|
|
1847
|
+
});
|
|
1848
|
+
const subject = "the start of the upload";
|
|
1849
|
+
const uploadId = textOf$1(await readAnswerDocument(answeredRequest(configuration, write, subject), response, "InitiateMultipartUploadResult"), "UploadId");
|
|
1850
|
+
if (uploadId === void 0 || uploadId === "") throw incompleteAnswer(configuration, write, response, subject, "no upload id");
|
|
1851
|
+
return uploadId;
|
|
1852
|
+
}
|
|
1853
|
+
async function uploadPart(configuration, write, uploadId, number, bytes) {
|
|
1854
|
+
const response = await send(configuration, {
|
|
1855
|
+
method: "PUT",
|
|
1856
|
+
operation: "put",
|
|
1857
|
+
key: write.key,
|
|
1858
|
+
query: [["partNumber", String(number)], ["uploadId", uploadId]],
|
|
1859
|
+
body: bytes,
|
|
1860
|
+
signal: write.signal
|
|
1861
|
+
});
|
|
1862
|
+
await response.body?.cancel();
|
|
1863
|
+
const etag = response.headers.get("etag");
|
|
1864
|
+
if (etag === null || etag === "") throw incompleteAnswer(configuration, write, response, `part ${number}`, "no entity tag");
|
|
1865
|
+
return etag;
|
|
1866
|
+
}
|
|
1867
|
+
async function completeUpload(configuration, write, uploadId, uploaded, size) {
|
|
1868
|
+
let response;
|
|
1869
|
+
let document;
|
|
1870
|
+
try {
|
|
1871
|
+
response = await send(configuration, {
|
|
1872
|
+
method: "POST",
|
|
1873
|
+
operation: "put",
|
|
1874
|
+
key: write.key,
|
|
1875
|
+
query: [["uploadId", uploadId]],
|
|
1876
|
+
headers: [["content-type", "application/xml"]],
|
|
1877
|
+
body: utf8$1.encode(completeDocument(uploaded)),
|
|
1878
|
+
signal: write.signal,
|
|
1879
|
+
repeatWithoutResponse: false
|
|
1880
|
+
});
|
|
1881
|
+
document = await readAnswerDocument(answeredRequest(configuration, write, "the completion of the upload"), response, "CompleteMultipartUploadResult");
|
|
1882
|
+
} catch (failure) {
|
|
1883
|
+
if (!mayHaveCommitted(failure, write.signal)) await abortUpload(configuration, write, uploadId);
|
|
1884
|
+
throw failure;
|
|
1885
|
+
}
|
|
1886
|
+
const etag = textOf$1(document, "ETag");
|
|
1887
|
+
return describeWrite(configuration.bucket, write.key, size, write.contentType, write.userMetadata.held, response, etag === void 0 ? void 0 : unquotedEtag(etag));
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Spec 7.7 leaves a completion that received no response unaborted, because an abort
|
|
1891
|
+
* could meet a commit still on its way. The same holds for a `200` that broke before its
|
|
1892
|
+
* body said how the commit went, since S3 sends that status before it has decided, and
|
|
1893
|
+
* for the caller's abort while the completion was out: the request may have arrived.
|
|
1894
|
+
* Only the provider's answer that it did not commit is aborted.
|
|
1895
|
+
*/
|
|
1896
|
+
function mayHaveCommitted(failure, signal) {
|
|
1897
|
+
if (signal?.aborted) return true;
|
|
1898
|
+
return isStorageError(failure) && failure.code === "NetworkError";
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Spec 7.6: sent without a signal, because the caller's may be the one that just fired,
|
|
1902
|
+
* and never reported, because the failure that led here is what the caller is owed. What
|
|
1903
|
+
* a failed abort leaves behind is removed by the lifecycle rule of spec 7.2.
|
|
1904
|
+
*/
|
|
1905
|
+
async function abortUpload(configuration, write, uploadId) {
|
|
1906
|
+
try {
|
|
1907
|
+
await (await send(configuration, {
|
|
1908
|
+
method: "DELETE",
|
|
1909
|
+
operation: "put",
|
|
1910
|
+
key: write.key,
|
|
1911
|
+
query: [["uploadId", uploadId]]
|
|
1912
|
+
})).body?.cancel();
|
|
1913
|
+
} catch {}
|
|
1914
|
+
}
|
|
1915
|
+
const utf8$1 = new TextEncoder();
|
|
1916
|
+
function completeDocument(uploaded) {
|
|
1917
|
+
const parts = uploaded.map(({ number, etag }) => `<Part><PartNumber>${number}</PartNumber><ETag>${escapeXml(etag)}</ETag></Part>`).join("");
|
|
1918
|
+
return `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload xmlns="${s3Namespace}">${parts}</CompleteMultipartUpload>`;
|
|
1919
|
+
}
|
|
1920
|
+
function answeredRequest(configuration, write, subject) {
|
|
1921
|
+
return {
|
|
1922
|
+
bucket: configuration.bucket,
|
|
1923
|
+
operation: "put",
|
|
1924
|
+
key: write.key,
|
|
1925
|
+
subject
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
function incompleteAnswer(configuration, write, response, subject, missing) {
|
|
1929
|
+
return s3Error(configuration.bucket, {
|
|
1930
|
+
code: "ProviderError",
|
|
1931
|
+
message: `The provider answered ${subject} with ${missing}`,
|
|
1932
|
+
operation: "put",
|
|
1933
|
+
key: write.key,
|
|
1934
|
+
attempts: 1,
|
|
1935
|
+
status: response.status,
|
|
1936
|
+
requestId: response.headers.get("x-amz-request-id") ?? void 0
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
//#endregion
|
|
1940
|
+
//#region src/index.ts
|
|
1941
|
+
function s3Storage(options) {
|
|
1942
|
+
return new SimpleStorageServiceStorage(options);
|
|
1943
|
+
}
|
|
1944
|
+
/** Spec 7.1, in the order `capabilityNames` of spec 4.9 lists the names. */
|
|
1945
|
+
const s3Capabilities = Object.freeze([
|
|
1946
|
+
"presignedUrls",
|
|
1947
|
+
"rangeReads",
|
|
1948
|
+
"userMetadata",
|
|
1949
|
+
"userMetadataTokenKeys"
|
|
1950
|
+
]);
|
|
1951
|
+
const utf8 = new TextEncoder();
|
|
1952
|
+
var SimpleStorageServiceStorage = class {
|
|
1953
|
+
provider = "s3";
|
|
1954
|
+
bucket;
|
|
1955
|
+
capabilities = s3Capabilities;
|
|
1956
|
+
#configuration;
|
|
1957
|
+
constructor(options) {
|
|
1958
|
+
this.#configuration = readConfiguration(options);
|
|
1959
|
+
this.bucket = this.#configuration.bucket;
|
|
1960
|
+
}
|
|
1961
|
+
async put(key, body, options) {
|
|
1962
|
+
try {
|
|
1963
|
+
return await this.#put(key, body, options);
|
|
1964
|
+
} catch (failure) {
|
|
1965
|
+
if (isStream(body) && !body.locked) await body.cancel(failure).catch(() => {});
|
|
1966
|
+
throw failure;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
async #put(key, body, options) {
|
|
1970
|
+
requireKey(this.bucket, key, "writable", "put");
|
|
1971
|
+
requireKnownOptions(this.bucket, options, putOptionKeys, "put");
|
|
1972
|
+
const write = {
|
|
1973
|
+
key,
|
|
1974
|
+
userMetadata: userMetadataHeaders(this.bucket, options?.userMetadata, key, this.capabilities),
|
|
1975
|
+
contentType: this.#readContentType(options?.contentType),
|
|
1976
|
+
signal: options?.signal
|
|
1977
|
+
};
|
|
1978
|
+
options?.signal?.throwIfAborted();
|
|
1979
|
+
if (isStream(body)) return await putStream(this.#configuration, write, body);
|
|
1980
|
+
return await putObject(this.#configuration, write, bytesOf(body));
|
|
1981
|
+
}
|
|
1982
|
+
async get(key, options) {
|
|
1983
|
+
requireKey(this.bucket, key, "addressable", "get");
|
|
1984
|
+
requireKnownOptions(this.bucket, options, getOptionKeys, "get");
|
|
1985
|
+
const range = options?.range;
|
|
1986
|
+
requireRange(this.bucket, range);
|
|
1987
|
+
options?.signal?.throwIfAborted();
|
|
1988
|
+
const response = await send(this.#configuration, {
|
|
1989
|
+
method: "GET",
|
|
1990
|
+
operation: "get",
|
|
1991
|
+
key,
|
|
1992
|
+
headers: range === void 0 ? [] : [["range", rangeHeader(range)]],
|
|
1993
|
+
signal: options?.signal
|
|
1994
|
+
});
|
|
1995
|
+
const stat = describeResponse(this.bucket, key, "get", response);
|
|
1996
|
+
const refusal = range === void 0 || response.status === 206 ? void 0 : wholeAnswerFailure(this.bucket, key, range, stat.size);
|
|
1997
|
+
if (refusal !== void 0) {
|
|
1998
|
+
await response.body?.cancel();
|
|
1999
|
+
throw refusal;
|
|
2000
|
+
}
|
|
2001
|
+
return createStoredObject(this.bucket, stat, response);
|
|
2002
|
+
}
|
|
2003
|
+
async stat(key, options) {
|
|
2004
|
+
return describeResponse(this.bucket, key, "stat", await this.#head(key, "stat", options));
|
|
2005
|
+
}
|
|
2006
|
+
async exists(key, options) {
|
|
2007
|
+
try {
|
|
2008
|
+
await this.#head(key, "exists", options);
|
|
2009
|
+
return true;
|
|
2010
|
+
} catch (failure) {
|
|
2011
|
+
if (isStorageError(failure) && failure.code === "NotFound") return false;
|
|
2012
|
+
throw failure;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
list(options) {
|
|
2016
|
+
return createListing(this.#configuration, options);
|
|
2017
|
+
}
|
|
2018
|
+
async delete(...keys) {
|
|
2019
|
+
return await deleteKeys(this.#configuration, keys, { operation: "delete" });
|
|
2020
|
+
}
|
|
2021
|
+
async deleteAll(prefix, options) {
|
|
2022
|
+
requireKey(this.bucket, prefix, "prefix", "deleteAll");
|
|
2023
|
+
requireKnownOptions(this.bucket, options, operationOptionKeys, "deleteAll");
|
|
2024
|
+
options?.signal?.throwIfAborted();
|
|
2025
|
+
return await deleteBelow(this.#configuration, prefix, options?.signal);
|
|
2026
|
+
}
|
|
2027
|
+
async copy(from, to, options) {
|
|
2028
|
+
this.#requireCopyKeys(from, to, options, "copy");
|
|
2029
|
+
return await copyObject(this.#configuration, from, to, "copy", options?.signal);
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Spec 4.10: `copy`, then the source deleted, each failure told as `move`'s. The
|
|
2033
|
+
* destination is described before the source goes, so a failing delete leaves both in
|
|
2034
|
+
* place and a repeated `move` is safe.
|
|
2035
|
+
*/
|
|
2036
|
+
async move(from, to, options) {
|
|
2037
|
+
this.#requireCopyKeys(from, to, options, "move");
|
|
2038
|
+
const written = await copyObject(this.#configuration, from, to, "move", options?.signal);
|
|
2039
|
+
await (await send(this.#configuration, {
|
|
2040
|
+
method: "DELETE",
|
|
2041
|
+
operation: "move",
|
|
2042
|
+
key: from,
|
|
2043
|
+
signal: options?.signal
|
|
2044
|
+
})).body?.cancel();
|
|
2045
|
+
return written;
|
|
2046
|
+
}
|
|
2047
|
+
async presignGet(key, options) {
|
|
2048
|
+
return await presignGet(this.#configuration, key, options);
|
|
2049
|
+
}
|
|
2050
|
+
async presignPut(key, options) {
|
|
2051
|
+
return await presignPut(this.#configuration, key, options);
|
|
2052
|
+
}
|
|
2053
|
+
async #head(key, operation, options) {
|
|
2054
|
+
requireKey(this.bucket, key, "addressable", operation);
|
|
2055
|
+
requireKnownOptions(this.bucket, options, operationOptionKeys, operation);
|
|
2056
|
+
options?.signal?.throwIfAborted();
|
|
2057
|
+
return await send(this.#configuration, {
|
|
2058
|
+
method: "HEAD",
|
|
2059
|
+
operation,
|
|
2060
|
+
key,
|
|
2061
|
+
signal: options?.signal
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Spec 4.8 checks both keys before acting on either, and spec 7.8 has a copy of a key
|
|
2066
|
+
* onto itself stop before it leaves the process; a `move` onto itself would otherwise
|
|
2067
|
+
* delete the one object it named.
|
|
2068
|
+
*/
|
|
2069
|
+
#requireCopyKeys(from, to, options, operation) {
|
|
2070
|
+
requireKey(this.bucket, from, "addressable", operation);
|
|
2071
|
+
requireKey(this.bucket, to, "writable", operation);
|
|
2072
|
+
requireKnownOptions(this.bucket, options, operationOptionKeys, operation);
|
|
2073
|
+
if (from !== to) return;
|
|
2074
|
+
throw s3Error(this.bucket, {
|
|
2075
|
+
code: "InvalidRequest",
|
|
2076
|
+
message: "A copy names one key as its source and another as its destination",
|
|
2077
|
+
operation,
|
|
2078
|
+
key: from,
|
|
2079
|
+
attempts: 0
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
#readContentType(contentType) {
|
|
2083
|
+
if (contentType === void 0) return defaultContentType;
|
|
2084
|
+
if (typeof contentType !== "string" || contentType === "") throw optionError$1(this.bucket, "contentType", "takes a non-empty string", "put");
|
|
2085
|
+
return contentType;
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
/** Spec 4.2: a string travels as its UTF-8 bytes. */
|
|
2089
|
+
function bytesOf(body) {
|
|
2090
|
+
return typeof body === "string" ? utf8.encode(body) : heldBytes(body);
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* The same bytes as a view Web Crypto takes: `BufferSource` rules out a view on a
|
|
2094
|
+
* `SharedArrayBuffer`, which is the one body copied rather than hashed and sent where it
|
|
2095
|
+
* lies. ADR 0009 already spends the memory of holding a body whole, and a copy of every
|
|
2096
|
+
* one of them would spend it twice.
|
|
2097
|
+
*/
|
|
2098
|
+
function heldBytes(body) {
|
|
2099
|
+
const { buffer } = body;
|
|
2100
|
+
if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer, body.byteOffset, body.byteLength);
|
|
2101
|
+
return new Uint8Array(body);
|
|
2102
|
+
}
|
|
2103
|
+
function isStream(body) {
|
|
2104
|
+
return typeof body !== "string" && !(body instanceof Uint8Array);
|
|
2105
|
+
}
|
|
2106
|
+
//#endregion
|
|
2107
|
+
export { fromEnv, s3Storage };
|