@filelayer/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +338 -0
- package/LICENSE +202 -0
- package/MIGRATIONS.md +328 -0
- package/NOTICE +37 -0
- package/README.md +343 -0
- package/SEMANTICS.md +729 -0
- package/dist/authz.d.ts +524 -0
- package/dist/authz.d.ts.map +1 -0
- package/dist/authz.js +889 -0
- package/dist/authz.js.map +1 -0
- package/dist/db.d.ts +145 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +217 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.d.ts +293 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +519 -0
- package/dist/delivery.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/filelayer.d.ts +542 -0
- package/dist/filelayer.d.ts.map +1 -0
- package/dist/filelayer.js +1360 -0
- package/dist/filelayer.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/simple.d.ts +297 -0
- package/dist/simple.d.ts.map +1 -0
- package/dist/simple.js +492 -0
- package/dist/simple.js.map +1 -0
- package/dist/storage.d.ts +269 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +700 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +432 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +862 -0
- package/dist/store.js.map +1 -0
- package/package.json +77 -0
- package/schema.sql +1190 -0
- package/src/authz.ts +1398 -0
- package/src/db.ts +271 -0
- package/src/delivery.ts +737 -0
- package/src/errors.ts +24 -0
- package/src/filelayer.ts +1836 -0
- package/src/index.ts +7 -0
- package/src/simple.ts +666 -0
- package/src/storage.ts +917 -0
- package/src/store.ts +1072 -0
- package/test/delivery.test.ts +0 -0
- package/test/group-subjects.test.ts +1072 -0
- package/test/helpers.ts +65 -0
- package/test/listing.test.ts +689 -0
- package/test/local-s3.d.mts +33 -0
- package/test/local-s3.mjs +400 -0
- package/test/persistence.test.ts +953 -0
- package/test/regression.test.ts +619 -0
- package/test/s3-live.test.ts +322 -0
- package/test/security.test.ts +1652 -0
- package/test/semantics.test.ts +888 -0
- package/test/storage.test.ts +437 -0
- package/test/tiers.test.ts +432 -0
- package/test/vault-example.test.ts +302 -0
- package/tsconfig.build.json +29 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Types for the JS test harness. Test-only; excluded from LOC counts. */
|
|
2
|
+
export interface LocalS3Object {
|
|
3
|
+
body: Buffer;
|
|
4
|
+
contentType: string;
|
|
5
|
+
lastModified: Date;
|
|
6
|
+
}
|
|
7
|
+
export interface LocalS3LogEntry {
|
|
8
|
+
method: string;
|
|
9
|
+
key: string;
|
|
10
|
+
query: Record<string, string>;
|
|
11
|
+
presigned: boolean;
|
|
12
|
+
at: number;
|
|
13
|
+
}
|
|
14
|
+
export interface LocalS3 {
|
|
15
|
+
server: import('node:http').Server;
|
|
16
|
+
objects: Map<string, LocalS3Object>;
|
|
17
|
+
uploads: Map<string, unknown>;
|
|
18
|
+
requestLog: LocalS3LogEntry[];
|
|
19
|
+
listen(port?: number): Promise<number>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
endpoint(): string;
|
|
22
|
+
bucket: string;
|
|
23
|
+
deactivateAccessKey(id: string): void;
|
|
24
|
+
activateAccessKey(id: string): void;
|
|
25
|
+
injectFault(f: { key?: string; method?: string; status?: number; code?: string }): void;
|
|
26
|
+
clearFaults(): void;
|
|
27
|
+
}
|
|
28
|
+
export function createLocalS3(opts: {
|
|
29
|
+
accessKeyId: string;
|
|
30
|
+
secretAccessKey: string;
|
|
31
|
+
bucket?: string;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
}): LocalS3;
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST HARNESS -- NOT APPLICATION CODE. Excluded from all LOC counts.
|
|
3
|
+
*
|
|
4
|
+
* A local S3-protocol object store that performs REAL AWS Signature Version 4
|
|
5
|
+
* verification of both header-signed and presigned requests, and implements
|
|
6
|
+
* enough of the API surface that `S3Storage` can be exercised end to end
|
|
7
|
+
* without credentials:
|
|
8
|
+
*
|
|
9
|
+
* PUT / GET / HEAD / DELETE, ranged GET (206 + Content-Range, 416),
|
|
10
|
+
* ListObjectsV2 with continuation tokens,
|
|
11
|
+
* CreateMultipartUpload / UploadPart / CompleteMultipartUpload /
|
|
12
|
+
* AbortMultipartUpload,
|
|
13
|
+
* presigned GET including expiry, response-content-type and
|
|
14
|
+
* response-content-disposition overrides.
|
|
15
|
+
*
|
|
16
|
+
* WHAT MAKES IT WORTH ANYTHING. It recomputes the signature from the request as
|
|
17
|
+
* RECEIVED -- the raw encoded path, the raw query string, the actual header
|
|
18
|
+
* values named in SignedHeaders -- and returns 403 SignatureDoesNotMatch on any
|
|
19
|
+
* mismatch. It also verifies that `x-amz-content-sha256` matches the body that
|
|
20
|
+
* actually arrived. A client that signs one string and sends another fails here
|
|
21
|
+
* exactly as it would fail against AWS.
|
|
22
|
+
*
|
|
23
|
+
* It also enforces the rules that bite in production and never bite in a mock:
|
|
24
|
+
* - every part except the last must be >= 5 MiB (EntityTooSmall)
|
|
25
|
+
* - completing with an ETag that does not match the stored part is InvalidPart
|
|
26
|
+
* - an unknown uploadId is NoSuchUpload
|
|
27
|
+
* - an expired presigned URL is AccessDenied
|
|
28
|
+
* - a request with no date or a skewed date is RequestTimeTooSkewed
|
|
29
|
+
*
|
|
30
|
+
* DERIVED FROM an earlier S3 test harness in this repository, which verified
|
|
31
|
+
* presigned signatures only and stubbed header-signed ones. That gap is exactly
|
|
32
|
+
* where our adapter's bugs were, so it is closed here.
|
|
33
|
+
*/
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import crypto from 'node:crypto';
|
|
36
|
+
|
|
37
|
+
const ALGO = 'AWS4-HMAC-SHA256';
|
|
38
|
+
const MIN_PART = 5 * 1024 * 1024;
|
|
39
|
+
const MAX_SKEW_MS = 15 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
const hmac = (key, data) => crypto.createHmac('sha256', key).update(data, 'utf8').digest();
|
|
42
|
+
const sha256hex = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
43
|
+
const signingKey = (secret, date, region, service) =>
|
|
44
|
+
hmac(hmac(hmac(hmac('AWS4' + secret, date), region), service), 'aws4_request');
|
|
45
|
+
|
|
46
|
+
const rfc3986 = (s) =>
|
|
47
|
+
encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
48
|
+
|
|
49
|
+
/** Sorted, re-encoded canonical query string, derived from the RAW query. */
|
|
50
|
+
function canonicalQuery(rawQuery) {
|
|
51
|
+
if (!rawQuery) return '';
|
|
52
|
+
return rawQuery
|
|
53
|
+
.split('&')
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((pair) => {
|
|
56
|
+
const i = pair.indexOf('=');
|
|
57
|
+
const k = i < 0 ? pair : pair.slice(0, i);
|
|
58
|
+
const v = i < 0 ? '' : pair.slice(i + 1);
|
|
59
|
+
return [rfc3986(decodeURIComponent(k)), rfc3986(decodeURIComponent(v))];
|
|
60
|
+
})
|
|
61
|
+
.filter(([k]) => k !== 'X-Amz-Signature')
|
|
62
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
63
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
64
|
+
.join('&');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseAmzDate(amzDate) {
|
|
68
|
+
if (!/^\d{8}T\d{6}Z$/.test(amzDate)) return NaN;
|
|
69
|
+
return Date.UTC(
|
|
70
|
+
+amzDate.slice(0, 4),
|
|
71
|
+
+amzDate.slice(4, 6) - 1,
|
|
72
|
+
+amzDate.slice(6, 8),
|
|
73
|
+
+amzDate.slice(9, 11),
|
|
74
|
+
+amzDate.slice(11, 13),
|
|
75
|
+
+amzDate.slice(13, 15),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createLocalS3({ accessKeyId, secretAccessKey, bucket = 'test-bucket', now = Date.now } = {}) {
|
|
80
|
+
/** @type {Map<string, {body: Buffer, contentType: string, lastModified: Date}>} */
|
|
81
|
+
const objects = new Map();
|
|
82
|
+
/** @type {Map<string, {key: string, contentType: string, parts: Map<number,{body:Buffer,etag:string}>}>} */
|
|
83
|
+
const uploads = new Map();
|
|
84
|
+
const credentials = new Map([[accessKeyId, { secretAccessKey, active: true }]]);
|
|
85
|
+
const requestLog = [];
|
|
86
|
+
/** Faults the test can inject, e.g. { key: 'a/b', method: 'PUT', status: 500, code: 'InternalError' } */
|
|
87
|
+
let faults = [];
|
|
88
|
+
|
|
89
|
+
const etagOf = (buf) => `"${crypto.createHash('md5').update(buf).digest('hex')}"`;
|
|
90
|
+
|
|
91
|
+
/** Recompute the signature over the request AS RECEIVED. */
|
|
92
|
+
function verify(req, rawPath, rawQuery, body) {
|
|
93
|
+
const q = new URLSearchParams(rawQuery);
|
|
94
|
+
const presigned = q.has('X-Amz-Signature');
|
|
95
|
+
|
|
96
|
+
let credential, amzDate, signedHeaders, signature, payloadHash;
|
|
97
|
+
if (presigned) {
|
|
98
|
+
credential = q.get('X-Amz-Credential') ?? '';
|
|
99
|
+
amzDate = q.get('X-Amz-Date') ?? '';
|
|
100
|
+
signedHeaders = q.get('X-Amz-SignedHeaders') ?? 'host';
|
|
101
|
+
signature = q.get('X-Amz-Signature');
|
|
102
|
+
payloadHash = 'UNSIGNED-PAYLOAD';
|
|
103
|
+
if (q.get('X-Amz-Algorithm') !== ALGO) return { code: 'InvalidRequest', status: 400 };
|
|
104
|
+
} else {
|
|
105
|
+
const auth = String(req.headers.authorization ?? '');
|
|
106
|
+
if (!auth.startsWith(ALGO + ' ')) return { code: 'AccessDenied', status: 403 };
|
|
107
|
+
const cred = /Credential=([^,\s]+)/.exec(auth);
|
|
108
|
+
const sh = /SignedHeaders=([^,\s]+)/.exec(auth);
|
|
109
|
+
const sig = /Signature=([0-9a-f]+)/.exec(auth);
|
|
110
|
+
if (!cred || !sh || !sig) return { code: 'AuthorizationHeaderMalformed', status: 400 };
|
|
111
|
+
credential = cred[1];
|
|
112
|
+
signedHeaders = sh[1];
|
|
113
|
+
signature = sig[1];
|
|
114
|
+
amzDate = String(req.headers['x-amz-date'] ?? '');
|
|
115
|
+
payloadHash = String(req.headers['x-amz-content-sha256'] ?? '');
|
|
116
|
+
if (!payloadHash) return { code: 'MissingSecurityHeader', status: 400 };
|
|
117
|
+
// Real S3 rejects a body that does not match the declared payload hash.
|
|
118
|
+
if (payloadHash !== 'UNSIGNED-PAYLOAD' && payloadHash !== sha256hex(body)) {
|
|
119
|
+
return { code: 'XAmzContentSHA256Mismatch', status: 400 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [keyId, dateStamp, credRegion, service] = String(credential).split('/');
|
|
124
|
+
const c = credentials.get(keyId);
|
|
125
|
+
if (!c || !c.active) return { code: 'InvalidAccessKeyId', status: 403 };
|
|
126
|
+
|
|
127
|
+
const signedAt = parseAmzDate(amzDate);
|
|
128
|
+
if (Number.isNaN(signedAt)) return { code: 'AuthorizationHeaderMalformed', status: 400 };
|
|
129
|
+
if (presigned) {
|
|
130
|
+
const expires = parseInt(q.get('X-Amz-Expires') ?? '0', 10);
|
|
131
|
+
if (now() > signedAt + expires * 1000) return { code: 'AccessDenied', status: 403, expired: true };
|
|
132
|
+
} else if (Math.abs(now() - signedAt) > MAX_SKEW_MS) {
|
|
133
|
+
return { code: 'RequestTimeTooSkewed', status: 403 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `host` must be verified against what the client actually sent, because
|
|
137
|
+
// that is what AWS does and it is how a virtual-host/path-style mix-up is
|
|
138
|
+
// caught.
|
|
139
|
+
const canonicalHeaders = String(signedHeaders)
|
|
140
|
+
.split(';')
|
|
141
|
+
.map((h) => `${h}:${String(req.headers[h] ?? '').trim().replace(/\s+/g, ' ')}\n`)
|
|
142
|
+
.join('');
|
|
143
|
+
|
|
144
|
+
const canonicalRequest = [
|
|
145
|
+
req.method,
|
|
146
|
+
rawPath,
|
|
147
|
+
canonicalQuery(rawQuery),
|
|
148
|
+
canonicalHeaders,
|
|
149
|
+
signedHeaders,
|
|
150
|
+
payloadHash,
|
|
151
|
+
].join('\n');
|
|
152
|
+
|
|
153
|
+
const scope = `${dateStamp}/${credRegion}/${service}/aws4_request`;
|
|
154
|
+
const stringToSign = [ALGO, amzDate, scope, sha256hex(canonicalRequest)].join('\n');
|
|
155
|
+
const expected = crypto
|
|
156
|
+
.createHmac('sha256', signingKey(c.secretAccessKey, dateStamp, credRegion, service))
|
|
157
|
+
.update(stringToSign, 'utf8')
|
|
158
|
+
.digest('hex');
|
|
159
|
+
|
|
160
|
+
if (expected !== signature) {
|
|
161
|
+
return { code: 'SignatureDoesNotMatch', status: 403, canonicalRequest, expected, got: signature };
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, presigned, query: q };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const server = http.createServer((req, res) => {
|
|
167
|
+
const qIndex = (req.url ?? '').indexOf('?');
|
|
168
|
+
const rawPath = qIndex < 0 ? req.url : req.url.slice(0, qIndex);
|
|
169
|
+
const rawQuery = qIndex < 0 ? '' : req.url.slice(qIndex + 1);
|
|
170
|
+
|
|
171
|
+
const chunks = [];
|
|
172
|
+
req.on('data', (c) => chunks.push(c));
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
const body = Buffer.concat(chunks);
|
|
175
|
+
try {
|
|
176
|
+
handle(req, res, rawPath, rawQuery, body);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
xml(res, 500, 'InternalError', String(err && err.message));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function xml(res, status, code, message = '') {
|
|
184
|
+
const b = Buffer.from(
|
|
185
|
+
`<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message>${message}</Message></Error>`,
|
|
186
|
+
);
|
|
187
|
+
res.writeHead(status, { 'content-type': 'application/xml', 'content-length': String(b.length) });
|
|
188
|
+
res.end(b);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handle(req, res, rawPath, rawQuery, body) {
|
|
192
|
+
// Path-style: /<bucket>/<key...>
|
|
193
|
+
const decodedPath = decodeURIComponent(rawPath);
|
|
194
|
+
const segs = decodedPath.replace(/^\//, '').split('/');
|
|
195
|
+
const reqBucket = segs.shift();
|
|
196
|
+
const key = segs.join('/');
|
|
197
|
+
const q = new URLSearchParams(rawQuery);
|
|
198
|
+
|
|
199
|
+
requestLog.push({
|
|
200
|
+
method: req.method,
|
|
201
|
+
key,
|
|
202
|
+
query: Object.fromEntries(q.entries()),
|
|
203
|
+
presigned: q.has('X-Amz-Signature'),
|
|
204
|
+
at: now(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const v = verify(req, rawPath, rawQuery, body);
|
|
208
|
+
if (!v.ok) {
|
|
209
|
+
if (v.code === 'SignatureDoesNotMatch') {
|
|
210
|
+
// Surfaced to the test runner, because a silent 403 in a signing test is
|
|
211
|
+
// an afternoon lost.
|
|
212
|
+
server.emit('sigfail', v);
|
|
213
|
+
}
|
|
214
|
+
return xml(res, v.status, v.code, v.canonicalRequest ? 'canonical request mismatch' : '');
|
|
215
|
+
}
|
|
216
|
+
if (reqBucket !== bucket) return xml(res, 404, 'NoSuchBucket', reqBucket ?? '');
|
|
217
|
+
|
|
218
|
+
for (const f of faults) {
|
|
219
|
+
if ((f.key === undefined || f.key === key) && (f.method === undefined || f.method === req.method)) {
|
|
220
|
+
return xml(res, f.status ?? 500, f.code ?? 'InternalError', 'injected fault');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- ListObjectsV2 ------------------------------------------------------
|
|
225
|
+
if (req.method === 'GET' && key === '' && q.get('list-type') === '2') {
|
|
226
|
+
const prefix = q.get('prefix') ?? '';
|
|
227
|
+
const max = Math.min(Number(q.get('max-keys') ?? 1000), 1000);
|
|
228
|
+
const after = q.get('continuation-token') ?? '';
|
|
229
|
+
const all = [...objects.entries()]
|
|
230
|
+
.filter(([k]) => k.startsWith(prefix) && k > after)
|
|
231
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
232
|
+
const page = all.slice(0, max);
|
|
233
|
+
const truncated = all.length > max;
|
|
234
|
+
const b = Buffer.from(
|
|
235
|
+
`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult>` +
|
|
236
|
+
`<Name>${bucket}</Name><Prefix>${prefix}</Prefix>` +
|
|
237
|
+
`<KeyCount>${page.length}</KeyCount><MaxKeys>${max}</MaxKeys>` +
|
|
238
|
+
`<IsTruncated>${truncated}</IsTruncated>` +
|
|
239
|
+
(truncated ? `<NextContinuationToken>${page[page.length - 1][0]}</NextContinuationToken>` : '') +
|
|
240
|
+
page
|
|
241
|
+
.map(
|
|
242
|
+
([k, o]) =>
|
|
243
|
+
`<Contents><Key>${k.replace(/&/g, '&').replace(/</g, '<')}</Key>` +
|
|
244
|
+
`<LastModified>${o.lastModified.toISOString()}</LastModified>` +
|
|
245
|
+
`<ETag>${etagOf(o.body).replace(/"/g, '"')}</ETag>` +
|
|
246
|
+
`<Size>${o.body.length}</Size></Contents>`,
|
|
247
|
+
)
|
|
248
|
+
.join('') +
|
|
249
|
+
`</ListBucketResult>`,
|
|
250
|
+
);
|
|
251
|
+
res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
|
|
252
|
+
return res.end(b);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Multipart ----------------------------------------------------------
|
|
256
|
+
if (req.method === 'POST' && q.has('uploads')) {
|
|
257
|
+
const uploadId = crypto.randomUUID();
|
|
258
|
+
uploads.set(uploadId, {
|
|
259
|
+
key,
|
|
260
|
+
contentType: String(req.headers['content-type'] ?? 'application/octet-stream'),
|
|
261
|
+
parts: new Map(),
|
|
262
|
+
});
|
|
263
|
+
const b = Buffer.from(
|
|
264
|
+
`<?xml version="1.0" encoding="UTF-8"?><InitiateMultipartUploadResult>` +
|
|
265
|
+
`<Bucket>${bucket}</Bucket><Key>${key}</Key><UploadId>${uploadId}</UploadId>` +
|
|
266
|
+
`</InitiateMultipartUploadResult>`,
|
|
267
|
+
);
|
|
268
|
+
res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
|
|
269
|
+
return res.end(b);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (req.method === 'PUT' && q.has('uploadId')) {
|
|
273
|
+
const up = uploads.get(q.get('uploadId'));
|
|
274
|
+
if (!up) return xml(res, 404, 'NoSuchUpload');
|
|
275
|
+
const n = Number(q.get('partNumber'));
|
|
276
|
+
if (!Number.isInteger(n) || n < 1 || n > 10000) return xml(res, 400, 'InvalidArgument');
|
|
277
|
+
const etag = etagOf(body);
|
|
278
|
+
up.parts.set(n, { body, etag });
|
|
279
|
+
res.writeHead(200, { ETag: etag, 'content-length': '0' });
|
|
280
|
+
return res.end();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (req.method === 'DELETE' && q.has('uploadId')) {
|
|
284
|
+
uploads.delete(q.get('uploadId'));
|
|
285
|
+
res.writeHead(204).end();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (req.method === 'POST' && q.has('uploadId')) {
|
|
290
|
+
const uploadId = q.get('uploadId');
|
|
291
|
+
const up = uploads.get(uploadId);
|
|
292
|
+
if (!up) return xml(res, 404, 'NoSuchUpload');
|
|
293
|
+
const doc = body.toString('utf8');
|
|
294
|
+
const listed = [...doc.matchAll(/<Part><PartNumber>(\d+)<\/PartNumber><ETag>(.*?)<\/ETag><\/Part>/g)].map(
|
|
295
|
+
(m) => ({ n: Number(m[1]), etag: m[2].replace(/"/g, '"') }),
|
|
296
|
+
);
|
|
297
|
+
if (listed.length === 0) return xml(res, 400, 'MalformedXML');
|
|
298
|
+
for (let i = 1; i < listed.length; i++) {
|
|
299
|
+
if (listed[i].n <= listed[i - 1].n) return xml(res, 400, 'InvalidPartOrder');
|
|
300
|
+
}
|
|
301
|
+
const buffers = [];
|
|
302
|
+
for (let i = 0; i < listed.length; i++) {
|
|
303
|
+
const p = up.parts.get(listed[i].n);
|
|
304
|
+
if (!p) return xml(res, 400, 'InvalidPart', `part ${listed[i].n}`);
|
|
305
|
+
if (p.etag !== listed[i].etag) return xml(res, 400, 'InvalidPart', 'etag mismatch');
|
|
306
|
+
// The rule that only ever fires in production.
|
|
307
|
+
if (i < listed.length - 1 && p.body.length < MIN_PART) {
|
|
308
|
+
return xml(res, 400, 'EntityTooSmall', `part ${listed[i].n} is ${p.body.length} bytes`);
|
|
309
|
+
}
|
|
310
|
+
buffers.push(p.body);
|
|
311
|
+
}
|
|
312
|
+
const full = Buffer.concat(buffers);
|
|
313
|
+
objects.set(up.key, { body: full, contentType: up.contentType, lastModified: new Date(now()) });
|
|
314
|
+
uploads.delete(uploadId);
|
|
315
|
+
const b = Buffer.from(
|
|
316
|
+
`<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUploadResult>` +
|
|
317
|
+
`<Location>http://localhost/${bucket}/${up.key}</Location><Bucket>${bucket}</Bucket>` +
|
|
318
|
+
`<Key>${up.key}</Key><ETag>"${crypto.createHash('md5').update(full).digest('hex')}-${listed.length}"</ETag>` +
|
|
319
|
+
`</CompleteMultipartUploadResult>`,
|
|
320
|
+
);
|
|
321
|
+
res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
|
|
322
|
+
return res.end(b);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- Single-object operations ------------------------------------------
|
|
326
|
+
if (req.method === 'PUT') {
|
|
327
|
+
objects.set(key, {
|
|
328
|
+
body,
|
|
329
|
+
contentType: String(req.headers['content-type'] ?? 'application/octet-stream'),
|
|
330
|
+
lastModified: new Date(now()),
|
|
331
|
+
});
|
|
332
|
+
res.writeHead(200, { ETag: etagOf(body), 'content-length': '0' });
|
|
333
|
+
return res.end();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (req.method === 'DELETE') {
|
|
337
|
+
objects.delete(key);
|
|
338
|
+
res.writeHead(204).end();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const obj = objects.get(key);
|
|
343
|
+
if (!obj) return xml(res, 404, 'NoSuchKey', key);
|
|
344
|
+
|
|
345
|
+
// Presigned response-header overrides: the store, not the client, decides.
|
|
346
|
+
const ct = v.presigned ? (v.query.get('response-content-type') ?? obj.contentType) : obj.contentType;
|
|
347
|
+
const cd = v.presigned ? v.query.get('response-content-disposition') : null;
|
|
348
|
+
|
|
349
|
+
const range = String(req.headers.range ?? '');
|
|
350
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
351
|
+
if (m) {
|
|
352
|
+
const total = obj.body.length;
|
|
353
|
+
let start = m[1] === '' ? total - Number(m[2]) : Number(m[1]);
|
|
354
|
+
let end = m[1] === '' ? total - 1 : m[2] === '' ? total - 1 : Number(m[2]);
|
|
355
|
+
if (Number.isNaN(start) || start >= total || start < 0) {
|
|
356
|
+
res.writeHead(416, { 'content-range': `bytes */${total}` });
|
|
357
|
+
return res.end();
|
|
358
|
+
}
|
|
359
|
+
end = Math.min(end, total - 1);
|
|
360
|
+
const slice = obj.body.subarray(start, end + 1);
|
|
361
|
+
const headers = {
|
|
362
|
+
'content-type': ct,
|
|
363
|
+
'content-length': String(slice.length),
|
|
364
|
+
'content-range': `bytes ${start}-${end}/${total}`,
|
|
365
|
+
ETag: etagOf(obj.body),
|
|
366
|
+
'last-modified': obj.lastModified.toUTCString(),
|
|
367
|
+
};
|
|
368
|
+
if (cd) headers['content-disposition'] = cd;
|
|
369
|
+
res.writeHead(206, headers);
|
|
370
|
+
return req.method === 'HEAD' ? res.end() : res.end(slice);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const headers = {
|
|
374
|
+
'content-type': ct,
|
|
375
|
+
'content-length': String(obj.body.length),
|
|
376
|
+
ETag: etagOf(obj.body),
|
|
377
|
+
'last-modified': obj.lastModified.toUTCString(),
|
|
378
|
+
'accept-ranges': 'bytes',
|
|
379
|
+
};
|
|
380
|
+
if (cd) headers['content-disposition'] = cd;
|
|
381
|
+
res.writeHead(200, headers);
|
|
382
|
+
return req.method === 'HEAD' ? res.end() : res.end(obj.body);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
server,
|
|
387
|
+
objects,
|
|
388
|
+
uploads,
|
|
389
|
+
requestLog,
|
|
390
|
+
listen: (port = 0) =>
|
|
391
|
+
new Promise((r) => server.listen(port, '127.0.0.1', () => r(server.address().port))),
|
|
392
|
+
close: () => new Promise((r) => server.close(r)),
|
|
393
|
+
endpoint: () => `http://127.0.0.1:${server.address().port}`,
|
|
394
|
+
bucket,
|
|
395
|
+
deactivateAccessKey: (id) => { credentials.get(id).active = false; },
|
|
396
|
+
activateAccessKey: (id) => { credentials.get(id).active = true; },
|
|
397
|
+
injectFault: (f) => { faults.push(f); },
|
|
398
|
+
clearFaults: () => { faults = []; },
|
|
399
|
+
};
|
|
400
|
+
}
|