@felipedsvit/s3node 0.1.2
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 +143 -0
- package/bin/s3node.js +101 -0
- package/docs/plan.md +340 -0
- package/package.json +50 -0
- package/src/auth/chunked.js +277 -0
- package/src/auth/credentials.js +41 -0
- package/src/auth/sigv4.js +293 -0
- package/src/errors.js +65 -0
- package/src/features/cors.js +115 -0
- package/src/features/encryption.js +201 -0
- package/src/features/formdata.js +169 -0
- package/src/features/lifecycle.js +175 -0
- package/src/features/notifications.js +161 -0
- package/src/features/policy.js +0 -0
- package/src/features/postpolicy.js +150 -0
- package/src/features/tagging.js +64 -0
- package/src/handlers/bucket.js +197 -0
- package/src/handlers/config.js +129 -0
- package/src/handlers/index.js +6 -0
- package/src/handlers/multipart.js +109 -0
- package/src/handlers/object.js +211 -0
- package/src/handlers/post.js +106 -0
- package/src/handlers/service.js +10 -0
- package/src/handlers/shared.js +84 -0
- package/src/http.js +243 -0
- package/src/index.js +30 -0
- package/src/router.js +128 -0
- package/src/server.js +282 -0
- package/src/storage/blobs.js +135 -0
- package/src/storage/metadata.js +616 -0
- package/src/storage/store.js +548 -0
- package/src/util/bytes.js +35 -0
- package/src/util/crc.js +54 -0
- package/src/util/hash.js +64 -0
- package/src/xml.js +205 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { PassThrough, Readable } from 'node:stream'
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
import { S3Error } from '../errors.js'
|
|
7
|
+
import { EncryptionManager, blockAlignedOffset } from '../features/encryption.js'
|
|
8
|
+
import { CHECKSUM_ALGORITHMS, multipartEtag } from '../util/hash.js'
|
|
9
|
+
import { toKeyBuffer } from '../util/bytes.js'
|
|
10
|
+
import { BlobStore } from './blobs.js'
|
|
11
|
+
import { MetadataStore, NULL_VERSION } from './metadata.js'
|
|
12
|
+
|
|
13
|
+
export const MAX_KEY_BYTES = 1024
|
|
14
|
+
export const DEFAULT_MIN_PART_SIZE = 5 * 1024 * 1024
|
|
15
|
+
export const MAX_PARTS = 10_000
|
|
16
|
+
export const READ_HIGH_WATER_MARK = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
/** Bucket subresources stored in `bucket_config`. */
|
|
19
|
+
export const CONFIG_NAMES = ['versioning', 'policy', 'cors', 'lifecycle', 'tagging', 'notification']
|
|
20
|
+
|
|
21
|
+
const BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/
|
|
22
|
+
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/
|
|
23
|
+
|
|
24
|
+
export function validateBucketName(name) {
|
|
25
|
+
if (!BUCKET_NAME_RE.test(name) || name.includes('..') || IPV4_RE.test(name)) {
|
|
26
|
+
throw new S3Error('InvalidBucketName')
|
|
27
|
+
}
|
|
28
|
+
return name
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validateKey(key) {
|
|
32
|
+
const buf = toKeyBuffer(key)
|
|
33
|
+
if (buf.length === 0) throw new S3Error('InvalidArgument', 'Object key must not be empty')
|
|
34
|
+
if (buf.length > MAX_KEY_BYTES) throw new S3Error('KeyTooLongError')
|
|
35
|
+
return key
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function newVersionId() {
|
|
39
|
+
return randomUUID().replaceAll('-', '')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function checksumMismatch(algorithm, expected, actual) {
|
|
43
|
+
return new S3Error(
|
|
44
|
+
'InvalidRequest',
|
|
45
|
+
`Value for x-amz-checksum-${algorithm} header is invalid: expected ${expected}, computed ${actual}`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runs a stream chain and exposes the result as a single readable. */
|
|
50
|
+
function chain(...streams) {
|
|
51
|
+
if (streams.length === 1) return streams[0]
|
|
52
|
+
const output = new PassThrough({ highWaterMark: READ_HIGH_WATER_MARK })
|
|
53
|
+
pipeline(...streams, output).catch((err) => output.destroy(err))
|
|
54
|
+
return output
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Storage facade: metadata in SQLite, bytes in the content-addressed blob
|
|
59
|
+
* store. All ordering guarantees between the two live here.
|
|
60
|
+
*/
|
|
61
|
+
export class ObjectStore {
|
|
62
|
+
constructor({ dataDir, region = 'us-east-1', minPartSize = DEFAULT_MIN_PART_SIZE }) {
|
|
63
|
+
this.dataDir = dataDir
|
|
64
|
+
this.region = region
|
|
65
|
+
this.minPartSize = minPartSize
|
|
66
|
+
this.blobs = new BlobStore(dataDir)
|
|
67
|
+
this.metadata = null
|
|
68
|
+
this.encryption = null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static async open(options) {
|
|
72
|
+
const store = new ObjectStore(options)
|
|
73
|
+
await mkdir(options.dataDir, { recursive: true })
|
|
74
|
+
await store.blobs.init()
|
|
75
|
+
store.metadata = new MetadataStore(join(options.dataDir, 'metadata.sqlite'))
|
|
76
|
+
store.encryption = await EncryptionManager.load(
|
|
77
|
+
join(options.dataDir, 'master.key'), options.encryptionMasterKey ?? null)
|
|
78
|
+
return store
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close() {
|
|
82
|
+
this.metadata?.close()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
requireBucket(name) {
|
|
86
|
+
const bucket = this.metadata.getBucket(name)
|
|
87
|
+
if (!bucket) throw new S3Error('NoSuchBucket', undefined, { bucketName: name })
|
|
88
|
+
return bucket
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------------------- buckets ---------------------------- */
|
|
92
|
+
|
|
93
|
+
createBucket(name) {
|
|
94
|
+
validateBucketName(name)
|
|
95
|
+
if (this.metadata.getBucket(name)) throw new S3Error('BucketAlreadyOwnedByYou')
|
|
96
|
+
this.metadata.createBucket(name, this.region)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async deleteBucket(name) {
|
|
100
|
+
this.requireBucket(name)
|
|
101
|
+
if (!this.metadata.isBucketEmpty(name)) throw new S3Error('BucketNotEmpty')
|
|
102
|
+
const orphans = this.metadata.blobsInBucket(name)
|
|
103
|
+
this.metadata.deleteBucket(name)
|
|
104
|
+
await this.blobs.removeMany(orphans)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
listBuckets() {
|
|
108
|
+
return this.metadata.listBuckets()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ------------------------ bucket subresources -------------------- */
|
|
112
|
+
|
|
113
|
+
getBucketConfig(bucket, name) {
|
|
114
|
+
this.requireBucket(bucket)
|
|
115
|
+
return this.metadata.getConfig(bucket, name)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
putBucketConfig(bucket, name, value) {
|
|
119
|
+
this.requireBucket(bucket)
|
|
120
|
+
this.metadata.putConfig(bucket, name, value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deleteBucketConfig(bucket, name) {
|
|
124
|
+
this.requireBucket(bucket)
|
|
125
|
+
this.metadata.deleteConfig(bucket, name)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 'Unset' until the bucket has been configured; then 'Enabled' or 'Suspended'. */
|
|
129
|
+
bucketVersioning(bucket) {
|
|
130
|
+
return this.metadata.getConfig(bucket, 'versioning')?.status ?? 'Unset'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------------------------- objects ---------------------------- */
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Streams the body to a blob, validates every integrity header the client
|
|
137
|
+
* sent, and only then commits metadata.
|
|
138
|
+
*/
|
|
139
|
+
async putObject({
|
|
140
|
+
bucket, key, body, contentType, metadata = {}, tags = {},
|
|
141
|
+
contentMd5 = null, expectedSha256 = null, checksumAlgorithm = null, expectedChecksum = null,
|
|
142
|
+
trailerProvider = null, encryptionRequest = null,
|
|
143
|
+
}) {
|
|
144
|
+
this.requireBucket(bucket)
|
|
145
|
+
validateKey(key)
|
|
146
|
+
|
|
147
|
+
const algorithms = ['md5']
|
|
148
|
+
if (expectedSha256) algorithms.push('sha256')
|
|
149
|
+
if (checksumAlgorithm) algorithms.push(checksumAlgorithm)
|
|
150
|
+
|
|
151
|
+
let encryption = null
|
|
152
|
+
let transforms = []
|
|
153
|
+
if (encryptionRequest) {
|
|
154
|
+
const created = this.encryption.create(encryptionRequest)
|
|
155
|
+
encryption = created.context
|
|
156
|
+
transforms = [this.encryption.createEncryptStream(created.key, encryption, 0)]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { blobId, size, hasher } = await this.blobs.write(body, { algorithms, transforms })
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const md5 = hasher.digest('md5', 'hex')
|
|
163
|
+
|
|
164
|
+
if (contentMd5 && hasher.digest('md5', 'base64') !== contentMd5) {
|
|
165
|
+
throw new S3Error('BadDigest')
|
|
166
|
+
}
|
|
167
|
+
if (expectedSha256 && hasher.digest('sha256', 'hex') !== expectedSha256) {
|
|
168
|
+
throw new S3Error('XAmzContentSHA256Mismatch')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A checksum may arrive as a header (known up front) or as a trailer at
|
|
172
|
+
// the end of an aws-chunked body (docs/plan.md 4.2).
|
|
173
|
+
const checksums = {}
|
|
174
|
+
if (checksumAlgorithm) {
|
|
175
|
+
const computed = hasher.digest(checksumAlgorithm, 'base64')
|
|
176
|
+
const declared = expectedChecksum ?? trailerProvider?.(`x-amz-checksum-${checksumAlgorithm}`) ?? null
|
|
177
|
+
if (declared && declared !== computed) {
|
|
178
|
+
throw checksumMismatch(checksumAlgorithm, declared, computed)
|
|
179
|
+
}
|
|
180
|
+
checksums[checksumAlgorithm] = computed
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const etag = `"${md5}"`
|
|
184
|
+
const lastModified = new Date()
|
|
185
|
+
const versioning = this.bucketVersioning(bucket)
|
|
186
|
+
const versionId = versioning === 'Enabled' ? newVersionId() : NULL_VERSION
|
|
187
|
+
// Only an unversioned write overwrites a row; with versioning enabled the
|
|
188
|
+
// previous version keeps both its row and its blob.
|
|
189
|
+
const replaced = versionId === NULL_VERSION
|
|
190
|
+
? this.metadata.getObject(bucket, key, NULL_VERSION)
|
|
191
|
+
: null
|
|
192
|
+
|
|
193
|
+
this.metadata.transaction(() => {
|
|
194
|
+
this.metadata.clearLatest(bucket, key)
|
|
195
|
+
this.metadata.putObject({
|
|
196
|
+
bucket, key, versionId, isLatest: true, isDeleteMarker: false,
|
|
197
|
+
size, etag, contentType, lastModified,
|
|
198
|
+
blobId, parts: null, metadata, checksums, tags, encryption,
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Metadata is committed; the superseded blob is now unreferenced.
|
|
203
|
+
if (replaced) await this._releaseObjectBlobs(replaced)
|
|
204
|
+
|
|
205
|
+
return { etag, size, lastModified, checksums, versionId, versioned: versioning === 'Enabled', encryption }
|
|
206
|
+
} catch (err) {
|
|
207
|
+
await this.blobs.remove(blobId)
|
|
208
|
+
throw err
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getObject(bucket, key, versionId = null) {
|
|
213
|
+
this.requireBucket(bucket)
|
|
214
|
+
const record = this.metadata.getObject(bucket, key, versionId)
|
|
215
|
+
if (!record) {
|
|
216
|
+
throw new S3Error(versionId ? 'NoSuchVersion' : 'NoSuchKey', undefined, { key: String(key) })
|
|
217
|
+
}
|
|
218
|
+
if (record.isDeleteMarker) {
|
|
219
|
+
// A delete marker addressed directly is a 405; reached as the current
|
|
220
|
+
// version it reads as a plain 404.
|
|
221
|
+
throw new S3Error(versionId ? 'MethodNotAllowed' : 'NoSuchKey', undefined, {
|
|
222
|
+
headers: { 'x-amz-delete-marker': 'true', 'x-amz-version-id': record.versionId },
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
return record
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolves the data key for a stored object, enforcing SSE-C key presentation. */
|
|
229
|
+
resolveEncryptionKey(record, encryptionRequest) {
|
|
230
|
+
if (!record.encryption) {
|
|
231
|
+
if (encryptionRequest?.mode === 'SSE-C') {
|
|
232
|
+
throw new S3Error('InvalidRequest', 'The object was not stored with SSE-C')
|
|
233
|
+
}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
return this.encryption.resolveKey(record.encryption, encryptionRequest)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Readable over [start, end] inclusive, spanning a manifest when needed. */
|
|
240
|
+
createObjectStream(record, start = 0, end = record.size - 1, { encryptionKey = null } = {}) {
|
|
241
|
+
if (record.size === 0 || end < start) return Readable.from([])
|
|
242
|
+
|
|
243
|
+
const context = record.encryption
|
|
244
|
+
if (!context) {
|
|
245
|
+
return record.parts
|
|
246
|
+
? this.blobs.createRangeStream(record.parts, start, end)
|
|
247
|
+
: this.blobs.createReadStream(record.blobId, { start, end })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const manager = this.encryption
|
|
251
|
+
if (!record.parts) {
|
|
252
|
+
// CTR seeking needs the ciphertext read to begin on a block boundary.
|
|
253
|
+
const aligned = blockAlignedOffset(start)
|
|
254
|
+
return chain(
|
|
255
|
+
this.blobs.createReadStream(record.blobId, { start: aligned, end }),
|
|
256
|
+
...manager.createDecryptStreams(encryptionKey, context, start, 0),
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const blobs = this.blobs
|
|
261
|
+
const parts = record.parts
|
|
262
|
+
async function* generate() {
|
|
263
|
+
let offset = 0
|
|
264
|
+
for (const part of parts) {
|
|
265
|
+
const partStart = offset
|
|
266
|
+
const partEnd = offset + part.size - 1
|
|
267
|
+
offset += part.size
|
|
268
|
+
if (partEnd < start) continue
|
|
269
|
+
if (partStart > end) break
|
|
270
|
+
const from = Math.max(start - partStart, 0)
|
|
271
|
+
const to = Math.min(end - partStart, part.size - 1)
|
|
272
|
+
if (to < from) continue
|
|
273
|
+
yield* chain(
|
|
274
|
+
blobs.createReadStream(part.blobId, { start: blockAlignedOffset(from), end: to }),
|
|
275
|
+
...manager.createDecryptStreams(encryptionKey, context, from, part.partNumber),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return Readable.from(generate(), { highWaterMark: READ_HIGH_WATER_MARK })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Removes a version, or writes a delete marker when the bucket is versioned.
|
|
284
|
+
* Returns what happened so the handler can set the response headers.
|
|
285
|
+
*/
|
|
286
|
+
async deleteObject(bucket, key, versionId = null) {
|
|
287
|
+
this.requireBucket(bucket)
|
|
288
|
+
const versioning = this.bucketVersioning(bucket)
|
|
289
|
+
|
|
290
|
+
if (versionId) {
|
|
291
|
+
const record = this.metadata.getObject(bucket, key, versionId)
|
|
292
|
+
if (!record) return { deleted: false }
|
|
293
|
+
this.metadata.transaction(() => {
|
|
294
|
+
this.metadata.deleteVersion(bucket, key, versionId)
|
|
295
|
+
if (record.isLatest) this.metadata.promoteLatest(bucket, key)
|
|
296
|
+
})
|
|
297
|
+
await this._releaseObjectBlobs(record)
|
|
298
|
+
return { deleted: true, versionId, deleteMarker: record.isDeleteMarker }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (versioning === 'Enabled' || versioning === 'Suspended') {
|
|
302
|
+
const markerVersion = versioning === 'Enabled' ? newVersionId() : NULL_VERSION
|
|
303
|
+
const replaced = markerVersion === NULL_VERSION
|
|
304
|
+
? this.metadata.getObject(bucket, key, NULL_VERSION)
|
|
305
|
+
: null
|
|
306
|
+
this.metadata.transaction(() => {
|
|
307
|
+
this.metadata.clearLatest(bucket, key)
|
|
308
|
+
this.metadata.putObject({
|
|
309
|
+
bucket, key, versionId: markerVersion, isLatest: true, isDeleteMarker: true,
|
|
310
|
+
size: 0, etag: '', lastModified: new Date(), blobId: null,
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
if (replaced) await this._releaseObjectBlobs(replaced)
|
|
314
|
+
return { deleted: true, versionId: markerVersion, deleteMarker: true }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const record = this.metadata.getObject(bucket, key)
|
|
318
|
+
if (!record) return { deleted: false }
|
|
319
|
+
// Drop the reference first: a crash then leaves an orphan blob, never a
|
|
320
|
+
// metadata row pointing at missing data.
|
|
321
|
+
this.metadata.deleteVersion(bucket, key, record.versionId)
|
|
322
|
+
await this._releaseObjectBlobs(record)
|
|
323
|
+
return { deleted: true }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async copyObject({
|
|
327
|
+
sourceBucket, sourceKey, sourceVersionId = null, bucket, key,
|
|
328
|
+
metadata, contentType, replaceMetadata, tags, replaceTags,
|
|
329
|
+
sourceEncryptionRequest = null, encryptionRequest = null,
|
|
330
|
+
}) {
|
|
331
|
+
const source = this.getObject(sourceBucket, sourceKey, sourceVersionId)
|
|
332
|
+
this.requireBucket(bucket)
|
|
333
|
+
validateKey(key)
|
|
334
|
+
|
|
335
|
+
const sourceKeyMaterial = this.resolveEncryptionKey(source, sourceEncryptionRequest)
|
|
336
|
+
const plaintext = this.createObjectStream(source, 0, source.size - 1,
|
|
337
|
+
{ encryptionKey: sourceKeyMaterial })
|
|
338
|
+
|
|
339
|
+
let encryption = null
|
|
340
|
+
let transforms = []
|
|
341
|
+
if (encryptionRequest) {
|
|
342
|
+
const created = this.encryption.create(encryptionRequest)
|
|
343
|
+
encryption = created.context
|
|
344
|
+
transforms = [this.encryption.createEncryptStream(created.key, encryption, 0)]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { blobId, size, hasher } = await this.blobs.write(plaintext, { algorithms: ['md5'], transforms })
|
|
348
|
+
try {
|
|
349
|
+
const etag = `"${hasher.digest('md5', 'hex')}"`
|
|
350
|
+
const lastModified = new Date()
|
|
351
|
+
const versioning = this.bucketVersioning(bucket)
|
|
352
|
+
const versionId = versioning === 'Enabled' ? newVersionId() : NULL_VERSION
|
|
353
|
+
const replaced = versionId === NULL_VERSION
|
|
354
|
+
? this.metadata.getObject(bucket, key, NULL_VERSION)
|
|
355
|
+
: null
|
|
356
|
+
|
|
357
|
+
this.metadata.transaction(() => {
|
|
358
|
+
this.metadata.clearLatest(bucket, key)
|
|
359
|
+
this.metadata.putObject({
|
|
360
|
+
bucket, key, versionId, isLatest: true, isDeleteMarker: false,
|
|
361
|
+
size, etag, lastModified,
|
|
362
|
+
contentType: replaceMetadata ? contentType : source.contentType,
|
|
363
|
+
blobId, parts: null,
|
|
364
|
+
metadata: replaceMetadata ? metadata : source.metadata,
|
|
365
|
+
checksums: {},
|
|
366
|
+
tags: replaceTags ? tags : source.tags,
|
|
367
|
+
encryption,
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
if (replaced) await this._releaseObjectBlobs(replaced)
|
|
371
|
+
return { etag, lastModified, size, versionId, versioned: versioning === 'Enabled', encryption }
|
|
372
|
+
} catch (err) {
|
|
373
|
+
await this.blobs.remove(blobId)
|
|
374
|
+
throw err
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
listObjects(bucket, options) {
|
|
379
|
+
this.requireBucket(bucket)
|
|
380
|
+
return this.metadata.listObjects(bucket, options)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
listVersions(bucket, options) {
|
|
384
|
+
this.requireBucket(bucket)
|
|
385
|
+
return this.metadata.listVersions(bucket, options)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ---------------------------- tagging ---------------------------- */
|
|
389
|
+
|
|
390
|
+
getObjectTags(bucket, key, versionId = null) {
|
|
391
|
+
return this.getObject(bucket, key, versionId).tags ?? {}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setObjectTags(bucket, key, versionId, tags) {
|
|
395
|
+
const record = this.getObject(bucket, key, versionId)
|
|
396
|
+
this.metadata.setTags(bucket, key, record.versionId, tags)
|
|
397
|
+
return record.versionId
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async _releaseObjectBlobs(record) {
|
|
401
|
+
const ids = []
|
|
402
|
+
if (record.blobId) ids.push(record.blobId)
|
|
403
|
+
if (record.parts) for (const part of record.parts) ids.push(part.blobId)
|
|
404
|
+
await this.blobs.removeMany(ids)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* -------------------------- multipart ---------------------------- */
|
|
408
|
+
|
|
409
|
+
createMultipartUpload({ bucket, key, contentType, metadata = {}, tags = {}, encryptionRequest = null }) {
|
|
410
|
+
this.requireBucket(bucket)
|
|
411
|
+
validateKey(key)
|
|
412
|
+
const uploadId = randomUUID().replaceAll('-', '')
|
|
413
|
+
// Fixing the encryption context up front is what lets every part share one
|
|
414
|
+
// key while still getting a non-overlapping counter window.
|
|
415
|
+
const encryption = encryptionRequest ? this.encryption.create(encryptionRequest).context : null
|
|
416
|
+
this.metadata.createUpload({ uploadId, bucket, key, contentType, metadata, tags, encryption })
|
|
417
|
+
return { uploadId, encryption }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
requireUpload(uploadId, bucket, key) {
|
|
421
|
+
const upload = this.metadata.getUpload(uploadId)
|
|
422
|
+
if (!upload || upload.bucket !== bucket || Buffer.compare(upload.key, toKeyBuffer(key)) !== 0) {
|
|
423
|
+
throw new S3Error('NoSuchUpload')
|
|
424
|
+
}
|
|
425
|
+
return upload
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async uploadPart({
|
|
429
|
+
bucket, key, uploadId, partNumber, body, contentMd5, expectedSha256,
|
|
430
|
+
checksumAlgorithm, expectedChecksum, trailerProvider, encryptionRequest = null,
|
|
431
|
+
}) {
|
|
432
|
+
const upload = this.requireUpload(uploadId, bucket, key)
|
|
433
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > MAX_PARTS) {
|
|
434
|
+
throw new S3Error('InvalidArgument', `Part number must be an integer between 1 and ${MAX_PARTS}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const algorithms = ['md5']
|
|
438
|
+
if (expectedSha256) algorithms.push('sha256')
|
|
439
|
+
if (checksumAlgorithm) algorithms.push(checksumAlgorithm)
|
|
440
|
+
|
|
441
|
+
let transforms = []
|
|
442
|
+
if (upload.encryption) {
|
|
443
|
+
const dataKey = this.encryption.resolveKey(upload.encryption, encryptionRequest)
|
|
444
|
+
transforms = [this.encryption.createEncryptStream(dataKey, upload.encryption, partNumber)]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const { blobId, size, hasher } = await this.blobs.write(body, { algorithms, transforms })
|
|
448
|
+
try {
|
|
449
|
+
if (contentMd5 && hasher.digest('md5', 'base64') !== contentMd5) throw new S3Error('BadDigest')
|
|
450
|
+
if (expectedSha256 && hasher.digest('sha256', 'hex') !== expectedSha256) {
|
|
451
|
+
throw new S3Error('XAmzContentSHA256Mismatch')
|
|
452
|
+
}
|
|
453
|
+
if (checksumAlgorithm) {
|
|
454
|
+
const computed = hasher.digest(checksumAlgorithm, 'base64')
|
|
455
|
+
const declared = expectedChecksum ?? trailerProvider?.(`x-amz-checksum-${checksumAlgorithm}`) ?? null
|
|
456
|
+
if (declared && declared !== computed) throw checksumMismatch(checksumAlgorithm, declared, computed)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const etag = `"${hasher.digest('md5', 'hex')}"`
|
|
460
|
+
const previous = this.metadata.getPart(uploadId, partNumber)
|
|
461
|
+
this.metadata.putPart({ uploadId, partNumber, size, etag, blobId })
|
|
462
|
+
// Re-uploading a part number replaces it; the old blob is now garbage.
|
|
463
|
+
if (previous) await this.blobs.remove(previous.blobId)
|
|
464
|
+
return { etag, size, encryption: upload.encryption }
|
|
465
|
+
} catch (err) {
|
|
466
|
+
await this.blobs.remove(blobId)
|
|
467
|
+
throw err
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
listParts(bucket, key, uploadId, options) {
|
|
472
|
+
this.requireUpload(uploadId, bucket, key)
|
|
473
|
+
return this.metadata.listParts(uploadId, options)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
listMultipartUploads(bucket, maxUploads) {
|
|
477
|
+
this.requireBucket(bucket)
|
|
478
|
+
return this.metadata.listUploads(bucket, maxUploads)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async completeMultipartUpload({ bucket, key, uploadId, requestedParts }) {
|
|
482
|
+
const upload = this.requireUpload(uploadId, bucket, key)
|
|
483
|
+
if (!requestedParts.length) throw new S3Error('InvalidRequest', 'You must specify at least one part')
|
|
484
|
+
|
|
485
|
+
const stored = new Map(this.metadata.allParts(uploadId).map((part) => [part.partNumber, part]))
|
|
486
|
+
const manifest = []
|
|
487
|
+
let previousNumber = 0
|
|
488
|
+
|
|
489
|
+
for (const [index, requested] of requestedParts.entries()) {
|
|
490
|
+
if (requested.partNumber <= previousNumber) throw new S3Error('InvalidPartOrder')
|
|
491
|
+
previousNumber = requested.partNumber
|
|
492
|
+
|
|
493
|
+
const part = stored.get(requested.partNumber)
|
|
494
|
+
if (!part) {
|
|
495
|
+
throw new S3Error('InvalidPart', `Part ${requested.partNumber} was not uploaded`)
|
|
496
|
+
}
|
|
497
|
+
const normalize = (etag) => etag.replaceAll('"', '').replace(/^"|"$/g, '')
|
|
498
|
+
if (normalize(part.etag) !== normalize(requested.etag)) {
|
|
499
|
+
throw new S3Error('InvalidPart', `ETag mismatch for part ${requested.partNumber}`)
|
|
500
|
+
}
|
|
501
|
+
if (index < requestedParts.length - 1 && part.size < this.minPartSize) {
|
|
502
|
+
throw new S3Error('EntityTooSmall',
|
|
503
|
+
`Part ${requested.partNumber} is ${part.size} bytes; the minimum is ${this.minPartSize}`)
|
|
504
|
+
}
|
|
505
|
+
manifest.push({ partNumber: part.partNumber, size: part.size, etag: part.etag, blobId: part.blobId })
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const size = manifest.reduce((total, part) => total + part.size, 0)
|
|
509
|
+
const etag = `"${multipartEtag(manifest.map((part) => part.etag.replaceAll('"', '')))}"`
|
|
510
|
+
const lastModified = new Date()
|
|
511
|
+
const versioning = this.bucketVersioning(bucket)
|
|
512
|
+
const versionId = versioning === 'Enabled' ? newVersionId() : NULL_VERSION
|
|
513
|
+
const replaced = versionId === NULL_VERSION
|
|
514
|
+
? this.metadata.getObject(bucket, key, NULL_VERSION)
|
|
515
|
+
: null
|
|
516
|
+
|
|
517
|
+
this.metadata.transaction(() => {
|
|
518
|
+
this.metadata.clearLatest(bucket, key)
|
|
519
|
+
this.metadata.putObject({
|
|
520
|
+
bucket, key, versionId, isLatest: true, isDeleteMarker: false,
|
|
521
|
+
size, etag, lastModified,
|
|
522
|
+
contentType: upload.contentType,
|
|
523
|
+
blobId: null, parts: manifest,
|
|
524
|
+
metadata: upload.metadata, checksums: {},
|
|
525
|
+
tags: upload.tags, encryption: upload.encryption,
|
|
526
|
+
})
|
|
527
|
+
this.metadata.deleteUpload(uploadId)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// Parts not referenced by the manifest are now unreachable, as is whatever
|
|
531
|
+
// object previously occupied the key.
|
|
532
|
+
const kept = new Set(manifest.map((part) => part.blobId))
|
|
533
|
+
const discarded = [...stored.values()].map((part) => part.blobId).filter((id) => !kept.has(id))
|
|
534
|
+
await this.blobs.removeMany(discarded)
|
|
535
|
+
if (replaced) await this._releaseObjectBlobs(replaced)
|
|
536
|
+
|
|
537
|
+
return { etag, size, lastModified, versionId, versioned: versioning === 'Enabled', encryption: upload.encryption }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async abortMultipartUpload({ bucket, key, uploadId }) {
|
|
541
|
+
this.requireUpload(uploadId, bucket, key)
|
|
542
|
+
const parts = this.metadata.allParts(uploadId)
|
|
543
|
+
this.metadata.deleteUpload(uploadId)
|
|
544
|
+
await this.blobs.removeMany(parts.map((part) => part.blobId))
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export { CHECKSUM_ALGORITHMS, NULL_VERSION }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-order key helpers. S3 orders keys by their UTF-8 bytes; JavaScript
|
|
3
|
+
* string comparison orders by UTF-16 code units and diverges on surrogate
|
|
4
|
+
* pairs, so every ordering decision goes through Buffers (docs/plan.md 4.4).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function toKeyBuffer(key) {
|
|
8
|
+
return Buffer.isBuffer(key) ? key : Buffer.from(String(key), 'utf8')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function compareKeys(a, b) {
|
|
12
|
+
return Buffer.compare(toKeyBuffer(a), toKeyBuffer(b))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Smallest buffer strictly greater than every key beginning with `prefix`.
|
|
17
|
+
* Returns null when no such bound exists (empty prefix, or all-0xFF prefix),
|
|
18
|
+
* meaning the scan is unbounded above.
|
|
19
|
+
*/
|
|
20
|
+
export function prefixUpperBound(prefix) {
|
|
21
|
+
const buf = toKeyBuffer(prefix)
|
|
22
|
+
for (let i = buf.length - 1; i >= 0; i--) {
|
|
23
|
+
if (buf[i] < 0xff) {
|
|
24
|
+
const bound = Buffer.from(buf.subarray(0, i + 1))
|
|
25
|
+
bound[i] += 1
|
|
26
|
+
return bound
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Smallest buffer strictly greater than `key`. */
|
|
33
|
+
export function keySuccessor(key) {
|
|
34
|
+
return Buffer.concat([toKeyBuffer(key), Buffer.from([0x00])])
|
|
35
|
+
}
|
package/src/util/crc.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-driven CRC32 (IEEE, as used by x-amz-checksum-crc32) and CRC32C
|
|
3
|
+
* (Castagnoli, x-amz-checksum-crc32c). Kept in-tree so the package has zero
|
|
4
|
+
* runtime dependencies — see docs/plan.md section 8.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function buildTable(poly) {
|
|
8
|
+
const table = new Uint32Array(256)
|
|
9
|
+
for (let i = 0; i < 256; i++) {
|
|
10
|
+
let c = i
|
|
11
|
+
for (let bit = 0; bit < 8; bit++) c = c & 1 ? poly ^ (c >>> 1) : c >>> 1
|
|
12
|
+
table[i] = c >>> 0
|
|
13
|
+
}
|
|
14
|
+
return table
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CRC32_TABLE = buildTable(0xedb88320)
|
|
18
|
+
const CRC32C_TABLE = buildTable(0x82f63b78)
|
|
19
|
+
|
|
20
|
+
class Crc {
|
|
21
|
+
constructor(table) {
|
|
22
|
+
this.table = table
|
|
23
|
+
this.state = 0xffffffff
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
update(chunk) {
|
|
27
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
28
|
+
const table = this.table
|
|
29
|
+
let c = this.state
|
|
30
|
+
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
|
31
|
+
this.state = c >>> 0
|
|
32
|
+
return this
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
value() {
|
|
36
|
+
return (this.state ^ 0xffffffff) >>> 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
digest(encoding) {
|
|
40
|
+
const out = Buffer.allocUnsafe(4)
|
|
41
|
+
out.writeUInt32BE(this.value(), 0)
|
|
42
|
+
if (encoding === 'hex') return out.toString('hex')
|
|
43
|
+
if (encoding === 'base64') return out.toString('base64')
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class Crc32 extends Crc {
|
|
49
|
+
constructor() { super(CRC32_TABLE) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Crc32c extends Crc {
|
|
53
|
+
constructor() { super(CRC32C_TABLE) }
|
|
54
|
+
}
|
package/src/util/hash.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { Transform } from 'node:stream'
|
|
3
|
+
import { Crc32, Crc32c } from './crc.js'
|
|
4
|
+
|
|
5
|
+
/** Checksum algorithm names as they appear in `x-amz-checksum-*` headers. */
|
|
6
|
+
export const CHECKSUM_ALGORITHMS = ['crc32', 'crc32c', 'sha1', 'sha256']
|
|
7
|
+
|
|
8
|
+
function createDigester(algorithm) {
|
|
9
|
+
if (algorithm === 'crc32') return new Crc32()
|
|
10
|
+
if (algorithm === 'crc32c') return new Crc32c()
|
|
11
|
+
return createHash(algorithm)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pass-through stream that computes every requested digest in a single pass.
|
|
16
|
+
* Only the digests a request actually needs should be asked for: hashing runs
|
|
17
|
+
* on the main thread and is the dominant CPU cost of the write path
|
|
18
|
+
* (docs/plan.md section 6.2).
|
|
19
|
+
*/
|
|
20
|
+
export class HashingStream extends Transform {
|
|
21
|
+
constructor(algorithms = ['md5'], options = {}) {
|
|
22
|
+
super(options)
|
|
23
|
+
this.algorithms = [...new Set(algorithms)]
|
|
24
|
+
this.digesters = new Map(this.algorithms.map((a) => [a, createDigester(a)]))
|
|
25
|
+
this.bytesWritten = 0
|
|
26
|
+
this._digests = new Map()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_transform(chunk, _encoding, callback) {
|
|
30
|
+
this.bytesWritten += chunk.length
|
|
31
|
+
for (const digester of this.digesters.values()) digester.update(chunk)
|
|
32
|
+
callback(null, chunk)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Digest of a single algorithm. Safe to call repeatedly. */
|
|
36
|
+
digest(algorithm, encoding = 'hex') {
|
|
37
|
+
const cacheKey = `${algorithm}:${encoding}`
|
|
38
|
+
if (this._digests.has(cacheKey)) return this._digests.get(cacheKey)
|
|
39
|
+
const digester = this.digesters.get(algorithm)
|
|
40
|
+
if (!digester) throw new Error(`Digest not computed: ${algorithm}`)
|
|
41
|
+
const raw = this._digests.get(`${algorithm}:buffer`) ?? digester.digest()
|
|
42
|
+
this._digests.set(`${algorithm}:buffer`, raw)
|
|
43
|
+
const value = encoding === 'buffer' ? raw : raw.toString(encoding)
|
|
44
|
+
this._digests.set(cacheKey, value)
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sha256Hex(data) {
|
|
50
|
+
return createHash('sha256').update(data).digest('hex')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function md5Hex(data) {
|
|
54
|
+
return createHash('md5').update(data).digest('hex')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Multipart ETag: MD5 over the concatenated *binary* MD5 digests of every
|
|
59
|
+
* part, suffixed with the part count (docs/plan.md section 4.3).
|
|
60
|
+
*/
|
|
61
|
+
export function multipartEtag(partMd5Hexes) {
|
|
62
|
+
const concatenated = Buffer.concat(partMd5Hexes.map((hex) => Buffer.from(hex, 'hex')))
|
|
63
|
+
return `${createHash('md5').update(concatenated).digest('hex')}-${partMd5Hexes.length}`
|
|
64
|
+
}
|