@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.
@@ -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(/^&quot;|&quot;$/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
+ }
@@ -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
+ }
@@ -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
+ }