@poe-platform/safe-fs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/safe-fs/bridge/confinement.d.ts +3 -0
- package/dist/safe-fs/bridge/confinement.js +171 -0
- package/dist/safe-fs/bridge/filesystem.d.ts +88 -0
- package/dist/safe-fs/bridge/filesystem.js +411 -0
- package/dist/safe-fs/bridge/index.d.ts +6 -0
- package/dist/safe-fs/bridge/index.js +18 -0
- package/dist/safe-fs/bridge/stats.d.ts +4 -0
- package/dist/safe-fs/bridge/stats.js +37 -0
- package/dist/safe-fs/bridge/types.d.ts +75 -0
- package/dist/safe-fs/bridge/types.js +1 -0
- package/dist/safe-fs/bridge/values.d.ts +6 -0
- package/dist/safe-fs/bridge/values.js +65 -0
- package/dist/safe-fs/config/memory.d.ts +3 -0
- package/dist/safe-fs/config/memory.js +9 -0
- package/dist/safe-fs/config/real.d.ts +5 -0
- package/dist/safe-fs/config/real.js +16 -0
- package/dist/safe-fs/config.d.ts +15 -0
- package/dist/safe-fs/config.js +46 -0
- package/dist/safe-fs/config.node.d.ts +2 -0
- package/dist/safe-fs/config.node.js +22 -0
- package/dist/safe-fs/contracts/abort.d.ts +5 -0
- package/dist/safe-fs/contracts/abort.js +34 -0
- package/dist/safe-fs/contracts/cleanup.d.ts +1 -0
- package/dist/safe-fs/contracts/cleanup.js +9 -0
- package/dist/safe-fs/contracts/errors.d.ts +50 -0
- package/dist/safe-fs/contracts/errors.js +77 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +96 -0
- package/dist/safe-fs/contracts/filesystem.js +1 -0
- package/dist/safe-fs/contracts/index.d.ts +4 -0
- package/dist/safe-fs/contracts/index.js +4 -0
- package/dist/safe-fs/contracts/io.d.ts +8 -0
- package/dist/safe-fs/contracts/io.js +92 -0
- package/dist/safe-fs/contracts/path.d.ts +8 -0
- package/dist/safe-fs/contracts/path.js +8 -0
- package/dist/safe-fs/contracts/portable-path.d.ts +13 -0
- package/dist/safe-fs/contracts/portable-path.js +57 -0
- package/dist/safe-fs/contracts/virtual-path.d.ts +7 -0
- package/dist/safe-fs/contracts/virtual-path.js +66 -0
- package/dist/safe-fs/core.d.ts +12 -0
- package/dist/safe-fs/core.js +12 -0
- package/dist/safe-fs/fs/memory/index.d.ts +58 -0
- package/dist/safe-fs/fs/memory/index.js +532 -0
- package/dist/safe-fs/fs/mount/comparison.d.ts +19 -0
- package/dist/safe-fs/fs/mount/comparison.js +118 -0
- package/dist/safe-fs/fs/mount/identity.d.ts +2 -0
- package/dist/safe-fs/fs/mount/identity.js +12 -0
- package/dist/safe-fs/fs/mount/index.d.ts +44 -0
- package/dist/safe-fs/fs/mount/index.js +546 -0
- package/dist/safe-fs/fs/overlay/index.d.ts +73 -0
- package/dist/safe-fs/fs/overlay/index.js +927 -0
- package/dist/safe-fs/fs/readonly/index.d.ts +31 -0
- package/dist/safe-fs/fs/readonly/index.js +128 -0
- package/dist/safe-fs/fs/real/allocation.d.ts +1 -0
- package/dist/safe-fs/fs/real/allocation.js +8 -0
- package/dist/safe-fs/fs/real/index.d.ts +76 -0
- package/dist/safe-fs/fs/real/index.js +543 -0
- package/dist/safe-fs/fs/s3/authority.d.ts +7 -0
- package/dist/safe-fs/fs/s3/authority.js +31 -0
- package/dist/safe-fs/fs/s3/filesystem.d.ts +93 -0
- package/dist/safe-fs/fs/s3/filesystem.js +1024 -0
- package/dist/safe-fs/fs/s3/http/index.d.ts +2 -0
- package/dist/safe-fs/fs/s3/http/index.js +1 -0
- package/dist/safe-fs/fs/s3/http/request.d.ts +16 -0
- package/dist/safe-fs/fs/s3/http/request.js +196 -0
- package/dist/safe-fs/fs/s3/http/signature.d.ts +20 -0
- package/dist/safe-fs/fs/s3/http/signature.js +74 -0
- package/dist/safe-fs/fs/s3/http/transport.d.ts +3 -0
- package/dist/safe-fs/fs/s3/http/transport.js +455 -0
- package/dist/safe-fs/fs/s3/http/types.d.ts +30 -0
- package/dist/safe-fs/fs/s3/http/types.js +1 -0
- package/dist/safe-fs/fs/s3/http/xml.d.ts +11 -0
- package/dist/safe-fs/fs/s3/http/xml.js +188 -0
- package/dist/safe-fs/fs/s3/index.d.ts +6 -0
- package/dist/safe-fs/fs/s3/index.js +3 -0
- package/dist/safe-fs/fs/s3/mock.d.ts +48 -0
- package/dist/safe-fs/fs/s3/mock.js +233 -0
- package/dist/safe-fs/fs/s3/registry.d.ts +10 -0
- package/dist/safe-fs/fs/s3/registry.js +71 -0
- package/dist/safe-fs/fs/s3/transport.d.ts +103 -0
- package/dist/safe-fs/fs/s3/transport.js +28 -0
- package/dist/safe-fs/fs/webdav/index.d.ts +2 -0
- package/dist/safe-fs/fs/webdav/index.js +1 -0
- package/dist/safe-fs/fs/webdav/resource-id.d.ts +18 -0
- package/dist/safe-fs/fs/webdav/resource-id.js +109 -0
- package/dist/safe-fs/fs/webdav/webdav.d.ts +101 -0
- package/dist/safe-fs/fs/webdav/webdav.js +1226 -0
- package/dist/safe-fs/fs/webdav/xml.d.ts +15 -0
- package/dist/safe-fs/fs/webdav/xml.js +250 -0
- package/dist/safe-fs/index.d.ts +14 -0
- package/dist/safe-fs/index.js +12 -0
- package/dist/safe-fs/node/filesystem.d.ts +11 -0
- package/dist/safe-fs/node/filesystem.js +28 -0
- package/dist/safe-fs/node/index.d.ts +2 -0
- package/dist/safe-fs/node/index.js +1 -0
- package/dist/safe-fs/node/stats.d.ts +1 -0
- package/dist/safe-fs/node/stats.js +1 -0
- package/dist/safe-fs/node/values.d.ts +1 -0
- package/dist/safe-fs/node/values.js +1 -0
- package/dist/safe-fs/node-host.d.ts +1 -0
- package/dist/safe-fs/node-host.js +1 -0
- package/dist/safe-fs/node-unavailable.d.ts +1 -0
- package/dist/safe-fs/node-unavailable.js +1 -0
- package/dist/safe-fs/platform/browser.d.ts +12 -0
- package/dist/safe-fs/platform/browser.js +39 -0
- package/dist/safe-fs/platform/node.d.ts +13 -0
- package/dist/safe-fs/platform/node.js +26 -0
- package/package.json +108 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { S3ServiceError } from "../transport.js";
|
|
3
|
+
import { abortable, collect, limitedBody, scopeFor, sendRequest } from "./request.js";
|
|
4
|
+
import { canonicalQuery, headerValue, invalid, signRequest, uriEncode } from "./signature.js";
|
|
5
|
+
import { children, integer, malformed, parseXml, text, timestamp } from "./xml.js";
|
|
6
|
+
const empty = new Uint8Array();
|
|
7
|
+
function limit(value, fallback, name, maximum = 1024 * 1024 * 1024) {
|
|
8
|
+
const result = value ?? fallback;
|
|
9
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > maximum)
|
|
10
|
+
invalid(`invalid ${name}`);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
function boolean(value, fallback, name) {
|
|
14
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
15
|
+
invalid(`invalid ${name}`);
|
|
16
|
+
return value ?? fallback;
|
|
17
|
+
}
|
|
18
|
+
function bucketName(bucket) {
|
|
19
|
+
if (typeof bucket !== "string" || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)
|
|
20
|
+
|| bucket.includes("..") || bucket.includes(".-") || bucket.includes("-.") || isIP(bucket))
|
|
21
|
+
invalid("expected a standard S3 bucket name");
|
|
22
|
+
}
|
|
23
|
+
function objectKey(key) {
|
|
24
|
+
if (typeof key !== "string" || !key || Buffer.byteLength(key) > 1024)
|
|
25
|
+
invalid("object keys must contain 1..1024 UTF-8 bytes");
|
|
26
|
+
uriEncode(key);
|
|
27
|
+
}
|
|
28
|
+
function header(message, name) {
|
|
29
|
+
const values = message.headersDistinct[name];
|
|
30
|
+
if (values && values.length !== 1)
|
|
31
|
+
malformed(`duplicate ${name} header`);
|
|
32
|
+
return values?.[0];
|
|
33
|
+
}
|
|
34
|
+
function head(message) {
|
|
35
|
+
const length = header(message, "content-length");
|
|
36
|
+
const modified = header(message, "last-modified");
|
|
37
|
+
const etag = header(message, "etag");
|
|
38
|
+
const metadata = Object.create(null);
|
|
39
|
+
for (const name of Object.keys(message.headersDistinct)) {
|
|
40
|
+
if (name.startsWith("x-amz-meta-"))
|
|
41
|
+
metadata[name.slice(11)] = header(message, name);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...(length === undefined ? {} : { ContentLength: integer(length) }),
|
|
45
|
+
...(modified === undefined ? {} : { LastModified: timestamp(modified) }),
|
|
46
|
+
...(etag === undefined ? {} : { ETag: etag }),
|
|
47
|
+
Metadata: metadata,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function metadataHeaders(metadata) {
|
|
51
|
+
const headers = Object.create(null);
|
|
52
|
+
let size = 0;
|
|
53
|
+
for (const [name, value] of Object.entries(metadata ?? {})) {
|
|
54
|
+
const lower = name.toLowerCase();
|
|
55
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(lower) || `x-amz-meta-${lower}` in headers)
|
|
56
|
+
invalid("invalid or duplicate metadata key");
|
|
57
|
+
const normalized = headerValue(value);
|
|
58
|
+
size += Buffer.byteLength(lower) + Buffer.byteLength(normalized);
|
|
59
|
+
if (size > 2048)
|
|
60
|
+
invalid("metadata exceeds 2 KiB");
|
|
61
|
+
headers[`x-amz-meta-${lower}`] = normalized;
|
|
62
|
+
}
|
|
63
|
+
return headers;
|
|
64
|
+
}
|
|
65
|
+
function conditions(input) {
|
|
66
|
+
const headers = {};
|
|
67
|
+
if (input.IfMatch !== undefined) {
|
|
68
|
+
const value = headerValue(input.IfMatch);
|
|
69
|
+
if (!value)
|
|
70
|
+
invalid("empty IfMatch");
|
|
71
|
+
headers["if-match"] = value;
|
|
72
|
+
}
|
|
73
|
+
if (input.IfNoneMatch !== undefined) {
|
|
74
|
+
if (input.IfNoneMatch !== "*")
|
|
75
|
+
invalid("IfNoneMatch must be '*'");
|
|
76
|
+
headers["if-none-match"] = "*";
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
|
80
|
+
function requireCondition(enabled, present) {
|
|
81
|
+
if (present && !enabled)
|
|
82
|
+
throw new S3ServiceError("NotImplemented", 501, "conditional operation requires independently verified provider enforcement");
|
|
83
|
+
}
|
|
84
|
+
function copySource(value) {
|
|
85
|
+
if (typeof value !== "string" || value.length > 4096 || !/^\/?[A-Za-z0-9_.~!$&'()*+,;=:@%/-]+$/.test(value))
|
|
86
|
+
invalid("CopySource must already be URI encoded without a query");
|
|
87
|
+
const source = value.startsWith("/") ? value.slice(1) : value;
|
|
88
|
+
const slash = source.indexOf("/");
|
|
89
|
+
if (slash < 1)
|
|
90
|
+
invalid("CopySource requires bucket/key");
|
|
91
|
+
let bucket;
|
|
92
|
+
let key;
|
|
93
|
+
try {
|
|
94
|
+
bucket = decodeURIComponent(source.slice(0, slash));
|
|
95
|
+
key = decodeURIComponent(source.slice(slash + 1));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return invalid("invalid CopySource encoding");
|
|
99
|
+
}
|
|
100
|
+
bucketName(bucket);
|
|
101
|
+
objectKey(key);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function rangeHeader(value) {
|
|
105
|
+
if (value === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (typeof value !== "string" || !/^bytes=(?:[0-9]+-[0-9]*|-[0-9]+)$/.test(value))
|
|
108
|
+
invalid("unsupported byte range");
|
|
109
|
+
const [start, end] = value.slice(6).split("-");
|
|
110
|
+
if ((start && !Number.isSafeInteger(Number(start))) || (end && !Number.isSafeInteger(Number(end)))
|
|
111
|
+
|| (!start && Number(end) === 0) || (start && end && Number(start) > Number(end)))
|
|
112
|
+
invalid("invalid byte range");
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function checkRange(message, range, metadata) {
|
|
116
|
+
if (!range) {
|
|
117
|
+
if (message.statusCode !== 200)
|
|
118
|
+
malformed("unexpected GET status");
|
|
119
|
+
return metadata.ContentLength;
|
|
120
|
+
}
|
|
121
|
+
if (message.statusCode !== 206)
|
|
122
|
+
malformed("provider ignored byte range");
|
|
123
|
+
const match = /^bytes ([0-9]+)-([0-9]+)\/([0-9]+)$/.exec(header(message, "content-range") ?? "");
|
|
124
|
+
if (!match)
|
|
125
|
+
malformed("invalid Content-Range");
|
|
126
|
+
const start = integer(match[1]);
|
|
127
|
+
const end = integer(match[2]);
|
|
128
|
+
const total = integer(match[3]);
|
|
129
|
+
const [requestedStart, requestedEnd] = range.slice(6).split("-");
|
|
130
|
+
const expectedStart = requestedStart ? Number(requestedStart) : Math.max(0, total - Number(requestedEnd));
|
|
131
|
+
const expectedEnd = requestedStart && requestedEnd ? Math.min(total - 1, Number(requestedEnd)) : total - 1;
|
|
132
|
+
if (start !== expectedStart || end !== expectedEnd || start > end || end >= total
|
|
133
|
+
|| (metadata.ContentLength !== undefined && metadata.ContentLength !== end - start + 1))
|
|
134
|
+
malformed("response range differs from request");
|
|
135
|
+
return end - start + 1;
|
|
136
|
+
}
|
|
137
|
+
export function createS3HttpTransport(options) {
|
|
138
|
+
if (!options || typeof options.endpoint !== "string" || !/^https?:\/\/[^\\/?#]+\/?$/.test(options.endpoint))
|
|
139
|
+
invalid("endpoint must be an explicit HTTP(S) origin without path, credentials, query or fragment");
|
|
140
|
+
let endpoint;
|
|
141
|
+
try {
|
|
142
|
+
endpoint = new URL(options.endpoint);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return invalid("invalid endpoint");
|
|
146
|
+
}
|
|
147
|
+
if (endpoint.username || endpoint.password)
|
|
148
|
+
invalid("endpoint credentials are not allowed");
|
|
149
|
+
if (endpoint.protocol === "http:" && !boolean(options.allowInsecureHttp, false, "allowInsecureHttp"))
|
|
150
|
+
invalid("HTTP requires explicit allowInsecureHttp");
|
|
151
|
+
if (typeof options.region !== "string" || !/^[a-z0-9-]{1,64}$/.test(options.region))
|
|
152
|
+
invalid("explicit signing region required");
|
|
153
|
+
if (!options.credentials || (typeof options.credentials !== "function" && typeof options.credentials !== "object"))
|
|
154
|
+
invalid("explicit credentials required");
|
|
155
|
+
const credentials = typeof options.credentials === "function" ? options.credentials : Object.freeze({ ...options.credentials });
|
|
156
|
+
const addressing = options.addressingStyle ?? "path";
|
|
157
|
+
if (addressing !== "path" && addressing !== "virtual-hosted")
|
|
158
|
+
invalid("unsupported addressing style");
|
|
159
|
+
const listEncoding = options.listUrlEncoding ?? "percent";
|
|
160
|
+
if (listEncoding !== "percent" && listEncoding !== "form")
|
|
161
|
+
invalid("unsupported LIST URL encoding");
|
|
162
|
+
if (addressing === "virtual-hosted" && (isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) || endpoint.hostname === "localhost"))
|
|
163
|
+
invalid("virtual-hosted addressing requires a DNS endpoint");
|
|
164
|
+
const maxPut = limit(options.maxPutBytes, 64 * 1024 * 1024, "maxPutBytes");
|
|
165
|
+
const maxGet = limit(options.maxGetBytes, 64 * 1024 * 1024, "maxGetBytes");
|
|
166
|
+
const maxXml = limit(options.maxXmlBytes, 4 * 1024 * 1024, "maxXmlBytes", 16 * 1024 * 1024);
|
|
167
|
+
const timeout = limit(options.requestTimeoutMs, 30_000, "requestTimeoutMs", 2_147_483_647);
|
|
168
|
+
const enabledCopy = boolean(options.enableCopy, true, "enableCopy");
|
|
169
|
+
const verified = options.verifiedConditionalOperations;
|
|
170
|
+
const conditionalPut = boolean(verified?.put, false, "verifiedConditionalOperations.put");
|
|
171
|
+
const nativeConditionalCopy = boolean(verified?.copy, false, "verifiedConditionalOperations.copy");
|
|
172
|
+
const conditionalCopy = enabledCopy ? nativeConditionalCopy : conditionalPut;
|
|
173
|
+
const conditionalDelete = boolean(verified?.delete, false, "verifiedConditionalOperations.delete");
|
|
174
|
+
const clock = options.clock ?? (() => new Date());
|
|
175
|
+
const factory = options.request;
|
|
176
|
+
const region = options.region;
|
|
177
|
+
if (typeof clock !== "function" || (factory !== undefined && typeof factory !== "function"))
|
|
178
|
+
invalid("invalid clock or request factory");
|
|
179
|
+
const exchange = async (method, bucket, key, headers, query, body, requestOptions) => {
|
|
180
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
181
|
+
bucketName(bucket);
|
|
182
|
+
if (key !== undefined)
|
|
183
|
+
objectKey(key);
|
|
184
|
+
const hostname = addressing === "virtual-hosted" ? `${bucket}.${endpoint.hostname}` : endpoint.hostname;
|
|
185
|
+
const host = `${hostname}${endpoint.port ? `:${endpoint.port}` : ""}`;
|
|
186
|
+
const path = addressing === "virtual-hosted" ? `/${key === undefined ? "" : uriEncode(key, true)}`
|
|
187
|
+
: `/${bucket}${key === undefined ? "" : `/${uriEncode(key, true)}`}`;
|
|
188
|
+
const encodedQuery = canonicalQuery(query);
|
|
189
|
+
const target = `${path}${encodedQuery ? `?${encodedQuery}` : ""}`;
|
|
190
|
+
if (target.length > 16 * 1024)
|
|
191
|
+
invalid("request target exceeds 16 KiB");
|
|
192
|
+
const scope = scopeFor(requestOptions?.abortSignal, timeout);
|
|
193
|
+
try {
|
|
194
|
+
scope.signal.throwIfAborted();
|
|
195
|
+
const resolved = typeof credentials === "function" ? await abortable(Promise.resolve(credentials({ signal: scope.signal })), scope.signal) : credentials;
|
|
196
|
+
scope.signal.throwIfAborted();
|
|
197
|
+
const signed = signRequest({ method, path, query: encodedQuery,
|
|
198
|
+
headers: { ...headers, host, ...(method === "PUT" ? { "content-length": String(body.length) } : {}) },
|
|
199
|
+
body, region, credentials: resolved, date: clock() });
|
|
200
|
+
const response = await sendRequest({ protocol: endpoint.protocol, hostname: hostname.replace(/^\[|\]$/g, ""),
|
|
201
|
+
...(endpoint.port ? { port: endpoint.port } : {}), method, path: target, headers: signed.headers,
|
|
202
|
+
agent: false, maxHeaderSize: 16 * 1024, ...{ rejectUnauthorized: true } }, body, scope, factory);
|
|
203
|
+
const status = response.message.statusCode ?? 502;
|
|
204
|
+
if (status < 200 || status >= 300) {
|
|
205
|
+
const bytes = await collect(response, maxXml);
|
|
206
|
+
const fallback = status === 404 ? "NotFound" : status === 403 || status === 401 ? "AccessDenied"
|
|
207
|
+
: status === 412 ? "PreconditionFailed" : status === 409 ? "ConditionalRequestConflict"
|
|
208
|
+
: status === 501 ? "NotImplemented" : status === 408 ? "RequestTimeout" : status >= 300 && status < 400 ? "RedirectRejected" : "HttpError";
|
|
209
|
+
let code = fallback;
|
|
210
|
+
let message = `S3 HTTP request failed with status ${status}`;
|
|
211
|
+
if (bytes.length) {
|
|
212
|
+
try {
|
|
213
|
+
const parsed = parseXml(bytes);
|
|
214
|
+
if (parsed.name === "Error") {
|
|
215
|
+
code = text(parsed, "Code") || fallback;
|
|
216
|
+
message = text(parsed, "Message") || message;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (ignoredError) {
|
|
220
|
+
void ignoredError;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
throw new S3ServiceError(code, status, message);
|
|
224
|
+
}
|
|
225
|
+
return response;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
scope.finish();
|
|
229
|
+
throw scope.signal.aborted ? scope.signal.reason : error;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const get = async (input, requestOptions) => {
|
|
233
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
234
|
+
const range = rangeHeader(input.Range);
|
|
235
|
+
const headers = { ...conditions(input), ...(range ? { range } : {}) };
|
|
236
|
+
const response = await exchange("GET", input.Bucket, input.Key, headers, [], empty, requestOptions);
|
|
237
|
+
try {
|
|
238
|
+
const metadata = head(response.message);
|
|
239
|
+
const expected = checkRange(response.message, range, metadata);
|
|
240
|
+
if (input.IfMatch !== undefined && input.IfMatch !== "*" && metadata.ETag !== undefined && metadata.ETag !== input.IfMatch)
|
|
241
|
+
malformed("GET ETag differs from IfMatch");
|
|
242
|
+
return { ...metadata, Body: limitedBody(response, maxGet, expected) };
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
response.close();
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const boundedCopy = async (input, requestOptions) => {
|
|
250
|
+
if (!conditionalPut)
|
|
251
|
+
throw new S3ServiceError("NotImplemented", 501, "disabled native COPY requires verified conditional PUT for bounded fallback");
|
|
252
|
+
const scope = scopeFor(requestOptions?.abortSignal, timeout);
|
|
253
|
+
const operationOptions = { abortSignal: scope.signal };
|
|
254
|
+
const destination = { Bucket: input.Bucket, Key: input.Key };
|
|
255
|
+
const source = copySource(input.CopySource).replace(/^\//, "");
|
|
256
|
+
const slash = source.indexOf("/");
|
|
257
|
+
const origin = { Bucket: decodeURIComponent(source.slice(0, slash)), Key: decodeURIComponent(source.slice(slash + 1)) };
|
|
258
|
+
const sourceCondition = input.CopySourceIfMatch;
|
|
259
|
+
let destinationCondition = { ...conditions(input) };
|
|
260
|
+
const directive = input.MetadataDirective ?? "COPY";
|
|
261
|
+
const replacementMetadata = input.Metadata === undefined ? {} : { ...input.Metadata };
|
|
262
|
+
let body;
|
|
263
|
+
try {
|
|
264
|
+
scope.signal.throwIfAborted();
|
|
265
|
+
if (Object.keys(destinationCondition).length === 0) {
|
|
266
|
+
let current;
|
|
267
|
+
try {
|
|
268
|
+
current = await transport.headObject(destination, operationOptions);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (scope.signal.aborted)
|
|
272
|
+
throw scope.signal.reason;
|
|
273
|
+
if (!(error instanceof S3ServiceError) || !["NotFound", "NoSuchKey"].includes(error.code))
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
if (current) {
|
|
277
|
+
if (!current.ETag)
|
|
278
|
+
malformed("destination lacks ETag for conditional COPY fallback");
|
|
279
|
+
destinationCondition = { "if-match": current.ETag };
|
|
280
|
+
}
|
|
281
|
+
else
|
|
282
|
+
destinationCondition = { "if-none-match": "*" };
|
|
283
|
+
}
|
|
284
|
+
const output = await get({ ...origin, ...(sourceCondition === undefined ? {} : { IfMatch: sourceCondition }) }, operationOptions);
|
|
285
|
+
body = output.Body[Symbol.asyncIterator]();
|
|
286
|
+
if (!output.ETag || output.ContentLength === undefined)
|
|
287
|
+
malformed("source lacks ETag/length for bounded COPY fallback");
|
|
288
|
+
if (sourceCondition !== undefined && sourceCondition !== "*" && output.ETag !== sourceCondition)
|
|
289
|
+
throw new S3ServiceError("PreconditionFailed", 412, "source changed before COPY fallback");
|
|
290
|
+
if (output.ContentLength > maxPut)
|
|
291
|
+
throw new S3ServiceError("EntityTooLarge", 413, "COPY fallback exceeds PUT byte limit");
|
|
292
|
+
const chunks = [];
|
|
293
|
+
let count = 0;
|
|
294
|
+
while (true) {
|
|
295
|
+
const result = await body.next();
|
|
296
|
+
if (result.done)
|
|
297
|
+
break;
|
|
298
|
+
count += result.value.length;
|
|
299
|
+
if (count > maxPut)
|
|
300
|
+
throw new S3ServiceError("EntityTooLarge", 413, "COPY fallback exceeds PUT byte limit");
|
|
301
|
+
chunks.push(result.value);
|
|
302
|
+
}
|
|
303
|
+
scope.signal.throwIfAborted();
|
|
304
|
+
const metadata = directive === "REPLACE" ? replacementMetadata : output.Metadata;
|
|
305
|
+
const headers = { ...metadataHeaders(metadata), ...destinationCondition };
|
|
306
|
+
const response = await exchange("PUT", destination.Bucket, destination.Key, headers, [], Buffer.concat(chunks, count), operationOptions);
|
|
307
|
+
try {
|
|
308
|
+
if (response.message.statusCode !== 200)
|
|
309
|
+
malformed("unexpected COPY fallback PUT status");
|
|
310
|
+
await collect(response, maxXml);
|
|
311
|
+
const etag = header(response.message, "etag");
|
|
312
|
+
if (!etag)
|
|
313
|
+
malformed("COPY fallback PUT lacks confirmation ETag");
|
|
314
|
+
return { CopyObjectResult: { ETag: etag } };
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
response.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await body?.return?.();
|
|
322
|
+
scope.finish();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const transport = {
|
|
326
|
+
capabilities: Object.freeze({ streamingRead: true, streamingWrite: false, conditionalPut, conditionalCopy, conditionalDelete }),
|
|
327
|
+
async headObject(input, requestOptions) {
|
|
328
|
+
const response = await exchange("HEAD", input.Bucket, input.Key, {}, [], empty, requestOptions);
|
|
329
|
+
try {
|
|
330
|
+
if (response.message.statusCode !== 200)
|
|
331
|
+
malformed("unexpected HEAD status");
|
|
332
|
+
const metadata = head(response.message);
|
|
333
|
+
await collect(response, maxXml);
|
|
334
|
+
return metadata;
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
response.close();
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
async getObject(input, requestOptions) {
|
|
341
|
+
const response = await get(input, requestOptions);
|
|
342
|
+
const chunks = [];
|
|
343
|
+
let count = 0;
|
|
344
|
+
for await (const chunk of response.Body) {
|
|
345
|
+
chunks.push(chunk);
|
|
346
|
+
count += chunk.length;
|
|
347
|
+
}
|
|
348
|
+
return { ...response, Body: Buffer.concat(chunks, count) };
|
|
349
|
+
},
|
|
350
|
+
getObjectStream: get,
|
|
351
|
+
async putObject(input, requestOptions) {
|
|
352
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
353
|
+
if (!(input.Body instanceof Uint8Array))
|
|
354
|
+
invalid("PUT requires a bounded Uint8Array");
|
|
355
|
+
if (input.Body.length > maxPut)
|
|
356
|
+
throw new S3ServiceError("EntityTooLarge", 413, "PUT exceeds byte limit");
|
|
357
|
+
requireCondition(conditionalPut, input.IfMatch !== undefined || input.IfNoneMatch !== undefined);
|
|
358
|
+
const body = new Uint8Array(input.Body);
|
|
359
|
+
const headers = { ...metadataHeaders(input.Metadata), ...conditions(input) };
|
|
360
|
+
const response = await exchange("PUT", input.Bucket, input.Key, headers, [], body, requestOptions);
|
|
361
|
+
try {
|
|
362
|
+
if (response.message.statusCode !== 200)
|
|
363
|
+
malformed("unexpected PUT status");
|
|
364
|
+
await collect(response, maxXml);
|
|
365
|
+
return { ETag: header(response.message, "etag") };
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
response.close();
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
async deleteObject(input, requestOptions) {
|
|
372
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
373
|
+
requireCondition(conditionalDelete, input.IfMatch !== undefined);
|
|
374
|
+
const response = await exchange("DELETE", input.Bucket, input.Key, conditions(input), [], empty, requestOptions);
|
|
375
|
+
try {
|
|
376
|
+
if (response.message.statusCode !== 204)
|
|
377
|
+
malformed("unexpected DELETE status");
|
|
378
|
+
await collect(response, maxXml);
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
response.close();
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
async copyObject(input, requestOptions) {
|
|
386
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
387
|
+
if (input.MetadataDirective !== undefined && input.MetadataDirective !== "COPY" && input.MetadataDirective !== "REPLACE")
|
|
388
|
+
invalid("invalid MetadataDirective");
|
|
389
|
+
const headers = { ...metadataHeaders(input.Metadata), ...conditions(input), "x-amz-copy-source": copySource(input.CopySource),
|
|
390
|
+
...(input.CopySourceIfMatch === undefined ? {} : { "x-amz-copy-source-if-match": headerValue(input.CopySourceIfMatch) }),
|
|
391
|
+
...(input.MetadataDirective === undefined ? {} : { "x-amz-metadata-directive": input.MetadataDirective }) };
|
|
392
|
+
if (input.CopySourceIfMatch !== undefined && !headers["x-amz-copy-source-if-match"])
|
|
393
|
+
invalid("empty CopySourceIfMatch");
|
|
394
|
+
bucketName(input.Bucket);
|
|
395
|
+
objectKey(input.Key);
|
|
396
|
+
if (!enabledCopy)
|
|
397
|
+
return boundedCopy(input, requestOptions);
|
|
398
|
+
requireCondition(conditionalCopy, input.IfMatch !== undefined || input.IfNoneMatch !== undefined || input.CopySourceIfMatch !== undefined);
|
|
399
|
+
const response = await exchange("PUT", input.Bucket, input.Key, headers, [], empty, requestOptions);
|
|
400
|
+
const root = parseXml(await collect(response, maxXml));
|
|
401
|
+
if (root.name === "Error")
|
|
402
|
+
throw new S3ServiceError(text(root, "Code", true), response.message.statusCode, text(root, "Message") ?? "embedded COPY failure");
|
|
403
|
+
if (response.message.statusCode !== 200 || root.name !== "CopyObjectResult")
|
|
404
|
+
malformed("invalid COPY response");
|
|
405
|
+
return { CopyObjectResult: { ETag: text(root, "ETag", true), LastModified: timestamp(text(root, "LastModified", true)) } };
|
|
406
|
+
},
|
|
407
|
+
async listObjectsV2(input, requestOptions) {
|
|
408
|
+
requestOptions?.abortSignal?.throwIfAborted();
|
|
409
|
+
if (input.MaxKeys !== undefined && (!Number.isSafeInteger(input.MaxKeys) || input.MaxKeys < 0 || input.MaxKeys > 1000))
|
|
410
|
+
invalid("MaxKeys must be 0..1000");
|
|
411
|
+
const query = [["list-type", "2"], ["encoding-type", "url"]];
|
|
412
|
+
for (const [name, value] of [["prefix", input.Prefix], ["delimiter", input.Delimiter], ["continuation-token", input.ContinuationToken]]) {
|
|
413
|
+
if (value !== undefined) {
|
|
414
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > 8192)
|
|
415
|
+
invalid(`invalid ${name}`);
|
|
416
|
+
query.push([name, value]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (input.MaxKeys !== undefined)
|
|
420
|
+
query.push(["max-keys", String(input.MaxKeys)]);
|
|
421
|
+
const response = await exchange("GET", input.Bucket, undefined, {}, query, empty, requestOptions);
|
|
422
|
+
const root = parseXml(await collect(response, maxXml));
|
|
423
|
+
if (response.message.statusCode !== 200 || root.name !== "ListBucketResult")
|
|
424
|
+
malformed("invalid LIST response");
|
|
425
|
+
const encoding = text(root, "EncodingType");
|
|
426
|
+
if (encoding !== undefined && encoding !== "url")
|
|
427
|
+
malformed("unsupported LIST encoding");
|
|
428
|
+
const decode = (value) => {
|
|
429
|
+
if (encoding !== "url")
|
|
430
|
+
return value;
|
|
431
|
+
try {
|
|
432
|
+
return decodeURIComponent(listEncoding === "form" ? value.replace(/\+/g, " ") : value);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return malformed("invalid URL-encoded LIST key");
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const truncated = text(root, "IsTruncated", true);
|
|
439
|
+
if (truncated !== "true" && truncated !== "false")
|
|
440
|
+
malformed("invalid IsTruncated");
|
|
441
|
+
const token = text(root, "NextContinuationToken");
|
|
442
|
+
if (truncated === "true" && !token)
|
|
443
|
+
malformed("truncated LIST lacks continuation token");
|
|
444
|
+
const keyCount = text(root, "KeyCount");
|
|
445
|
+
const contents = children(root, "Contents").map(item => ({ Key: decode(text(item, "Key", true)),
|
|
446
|
+
Size: integer(text(item, "Size", true)), ETag: text(item, "ETag", true), LastModified: timestamp(text(item, "LastModified", true)) }));
|
|
447
|
+
const prefixes = children(root, "CommonPrefixes").map(item => ({ Prefix: decode(text(item, "Prefix", true)) }));
|
|
448
|
+
if (contents.length + prefixes.length > (input.MaxKeys ?? 1000))
|
|
449
|
+
malformed("LIST exceeds requested page size");
|
|
450
|
+
return { Contents: contents, CommonPrefixes: prefixes, IsTruncated: truncated === "true",
|
|
451
|
+
...(keyCount === undefined ? {} : { KeyCount: integer(keyCount) }), ...(token === undefined ? {} : { NextContinuationToken: token }) };
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
return Object.freeze(transport);
|
|
455
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
|
|
2
|
+
export interface S3HttpCredentials {
|
|
3
|
+
readonly accessKeyId: string;
|
|
4
|
+
readonly secretAccessKey: string;
|
|
5
|
+
readonly sessionToken?: string;
|
|
6
|
+
}
|
|
7
|
+
export type S3HttpCredentialProvider = (options: {
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
}) => Promise<S3HttpCredentials>;
|
|
10
|
+
export type S3HttpRequestFactory = (options: RequestOptions, onResponse: (response: IncomingMessage) => void) => ClientRequest;
|
|
11
|
+
export interface S3HttpTransportOptions {
|
|
12
|
+
readonly endpoint: string;
|
|
13
|
+
readonly region: string;
|
|
14
|
+
readonly credentials: S3HttpCredentials | S3HttpCredentialProvider;
|
|
15
|
+
readonly addressingStyle?: "path" | "virtual-hosted";
|
|
16
|
+
readonly listUrlEncoding?: "percent" | "form";
|
|
17
|
+
readonly clock?: () => Date;
|
|
18
|
+
readonly request?: S3HttpRequestFactory;
|
|
19
|
+
readonly allowInsecureHttp?: boolean;
|
|
20
|
+
readonly maxPutBytes?: number;
|
|
21
|
+
readonly maxGetBytes?: number;
|
|
22
|
+
readonly maxXmlBytes?: number;
|
|
23
|
+
readonly requestTimeoutMs?: number;
|
|
24
|
+
readonly enableCopy?: boolean;
|
|
25
|
+
readonly verifiedConditionalOperations?: {
|
|
26
|
+
readonly put?: boolean;
|
|
27
|
+
readonly copy?: boolean;
|
|
28
|
+
readonly delete?: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface XmlNode {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly children: XmlNode[];
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function malformed(message?: string): never;
|
|
7
|
+
export declare function parseXml(bytes: Uint8Array): XmlNode;
|
|
8
|
+
export declare function children(node: XmlNode, name: string): XmlNode[];
|
|
9
|
+
export declare function text(node: XmlNode, name: string, required?: boolean): string | undefined;
|
|
10
|
+
export declare function integer(value: string): number;
|
|
11
|
+
export declare function timestamp(value: string): Date;
|