@ultimat3/storage 29.4.0 → 30.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.
Files changed (47) hide show
  1. package/CLAUDE.md +25 -45
  2. package/README.md +102 -12
  3. package/package.json +12 -2
  4. package/src/accept-part.ts +30 -0
  5. package/src/accept.ts +7 -0
  6. package/src/attachment.ts +99 -6
  7. package/src/byte-pump.ts +46 -0
  8. package/src/driver-local-list.ts +75 -0
  9. package/src/driver-local-multipart.ts +133 -0
  10. package/src/driver-local-remove.ts +39 -0
  11. package/src/driver-local-stream.ts +104 -0
  12. package/src/driver-local-write.ts +28 -5
  13. package/src/driver-local.ts +250 -295
  14. package/src/driver-memory-multipart.ts +41 -0
  15. package/src/driver-memory.ts +205 -143
  16. package/src/driver-s3-client.ts +9 -0
  17. package/src/driver-s3-copy.ts +35 -0
  18. package/src/driver-s3-lock.ts +9 -2
  19. package/src/driver-s3-multipart-session.ts +87 -0
  20. package/src/driver-s3-multipart.ts +177 -0
  21. package/src/driver-s3-presign.ts +52 -0
  22. package/src/driver-s3-put-stream.ts +97 -0
  23. package/src/driver-s3-settle.ts +150 -0
  24. package/src/driver-s3-signed.ts +68 -3
  25. package/src/driver-s3-sse.ts +29 -0
  26. package/src/driver-s3.ts +89 -65
  27. package/src/driver.ts +86 -36
  28. package/src/errors-multipart.ts +35 -0
  29. package/src/errors-promote.ts +14 -0
  30. package/src/errors-sniff.ts +35 -0
  31. package/src/errors-stream.ts +62 -0
  32. package/src/errors.ts +13 -0
  33. package/src/expire.ts +169 -0
  34. package/src/grant-multipart.ts +196 -0
  35. package/src/grant.ts +42 -25
  36. package/src/image-warm.ts +84 -0
  37. package/src/image.ts +57 -0
  38. package/src/index.ts +75 -3
  39. package/src/multipart-staged.ts +127 -0
  40. package/src/multipart.ts +119 -0
  41. package/src/put-stream.ts +52 -0
  42. package/src/range.ts +35 -4
  43. package/src/signed-url.ts +59 -2
  44. package/src/subject-target.ts +82 -0
  45. package/src/upload-client-multipart.ts +156 -0
  46. package/src/upload-client.ts +21 -0
  47. package/src/upload.ts +107 -21
@@ -0,0 +1,177 @@
1
+ // Single responsibility: the S3 multipart calls (create, part, complete, abort, list parts, list uploads) over the disk's signed requests. Each answers
2
+ // what its caller goes on with, or hands the provider's refusal to the disk's coded `refused`. The
3
+ // order they run in, and what a failure aborts, is the caller's.
4
+
5
+ import { escapeHtml } from '@ultimat3/core';
6
+ import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
7
+ import type { MultipartSession, UploadedPart } from './multipart';
8
+
9
+ export interface S3UploadTarget {
10
+ readonly wire: S3WireOptions;
11
+ readonly key: string;
12
+ /** The disk's coded write refusal. A refusal is never thrown bare. */
13
+ readonly refused: (refusal: S3Refusal) => never;
14
+ }
15
+
16
+ const unreadable = (what: string): S3Refusal => ({
17
+ name: 'S3Error',
18
+ code: 'UnreadableAnswer',
19
+ message: `the provider's answer to ${what} carried nothing to go on`,
20
+ statusCode: 200,
21
+ });
22
+
23
+ /** Every `<name>…</name>` block of an S3 listing, in order; the shapes read here are flat. */
24
+ export const xmlBlocks = (xml: string, name: string): readonly string[] =>
25
+ [...xml.matchAll(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g'))].map(
26
+ (match) => match[1] ?? '',
27
+ );
28
+
29
+ /** XML's entities: S3 writes an etag as `&quot;…&quot;` and a key's `&` as `&amp;`. */
30
+ export const xmlText = (text: string): string =>
31
+ text
32
+ // A numeric reference too: a gateway writes the etag's quotes as `&#34;`, S3 as `&quot;`.
33
+ .replace(/&#(x[0-9a-f]{1,6}|[0-9]{1,7});/gi, (_, code: string) =>
34
+ String.fromCodePoint(
35
+ Math.min(Number(code[0] === 'x' || code[0] === 'X' ? `0${code}` : code), 0x10ffff),
36
+ ),
37
+ )
38
+ .replaceAll('&quot;', '"')
39
+ .replaceAll('&apos;', "'")
40
+ .replaceAll('&lt;', '<')
41
+ .replaceAll('&gt;', '>')
42
+ .replaceAll('&amp;', '&');
43
+
44
+ // Core's table: `&#39;`/`&quot;` are numeric/XML references too, so its output is valid XML text.
45
+ const completeXml = (parts: readonly { partNumber: number; etag: string }[]): Uint8Array =>
46
+ new TextEncoder().encode(
47
+ `<CompleteMultipartUpload>${parts
48
+ .map(
49
+ ({ partNumber, etag }) =>
50
+ `<Part><PartNumber>${partNumber}</PartNumber><ETag>${escapeHtml(etag)}</ETag></Part>`,
51
+ )
52
+ .join('')}</CompleteMultipartUpload>`,
53
+ );
54
+
55
+ /** CreateMultipartUpload. The object's own headers travel here: a part has none. */
56
+ export async function createS3Upload(
57
+ { wire, key, refused }: S3UploadTarget,
58
+ headers: Readonly<Record<string, string>>,
59
+ ): Promise<string> {
60
+ const result = await sendSigned(wire, { method: 'POST', key, headers, query: { uploads: '' } });
61
+ if (!result.ok) return refused(result.refusal);
62
+ const id = xmlField(await result.response.text(), 'UploadId');
63
+ return id === undefined ? refused(unreadable('CreateMultipartUpload')) : id;
64
+ }
65
+
66
+ /** UploadPart, answering the etag the Complete must name. */
67
+ export async function uploadS3Part(
68
+ { wire, key, refused }: S3UploadTarget,
69
+ uploadId: string,
70
+ partNumber: number,
71
+ body: Uint8Array,
72
+ ): Promise<string> {
73
+ const result = await sendSigned(wire, {
74
+ method: 'PUT',
75
+ key,
76
+ query: { partNumber: String(partNumber), uploadId },
77
+ body,
78
+ });
79
+ if (!result.ok) return refused(result.refusal);
80
+ await result.response.body?.cancel().catch(() => undefined);
81
+ const etag = result.response.headers.get('etag');
82
+ return etag === null ? refused(unreadable('UploadPart')) : etag;
83
+ }
84
+
85
+ /** CompleteMultipartUpload, `parts` ascending. The object exists only once this returns. */
86
+ export async function completeS3Upload(
87
+ { wire, key, refused }: S3UploadTarget,
88
+ uploadId: string,
89
+ parts: readonly { readonly partNumber: number; readonly etag: string }[],
90
+ ): Promise<void> {
91
+ const result = await sendSigned(wire, {
92
+ method: 'POST',
93
+ key,
94
+ query: { uploadId },
95
+ body: completeXml(parts),
96
+ });
97
+ if (!result.ok) return refused(result.refusal);
98
+ // S3 answers a failed Complete with 200 and an `<Error>` body, once it has started working.
99
+ const answer = await result.response.text();
100
+ if (answer.includes('<Error>')) {
101
+ refused({
102
+ name: 'S3Error',
103
+ code: xmlField(answer, 'Code') ?? 'CompleteFailed',
104
+ message: xmlField(answer, 'Message') ?? 'the provider refused the Complete',
105
+ statusCode: 200,
106
+ });
107
+ }
108
+ }
109
+
110
+ /** AbortMultipartUpload. `undefined` on success; the refusal otherwise, for the caller to weigh. */
111
+ export async function abortS3Upload(
112
+ { wire, key }: S3UploadTarget,
113
+ uploadId: string,
114
+ ): Promise<S3Refusal | undefined> {
115
+ const result = await sendSigned(wire, { method: 'DELETE', key, query: { uploadId } });
116
+ return result.ok ? undefined : result.refusal;
117
+ }
118
+
119
+ /** ListParts, every page: what landed, which is the resume and the Complete's etags. */
120
+ export async function listS3Parts(
121
+ { wire, key, refused }: S3UploadTarget,
122
+ uploadId: string,
123
+ ): Promise<readonly UploadedPart[]> {
124
+ const parts: UploadedPart[] = [];
125
+ let marker: string | undefined;
126
+ for (;;) {
127
+ const query = { uploadId, ...(marker === undefined ? {} : { 'part-number-marker': marker }) };
128
+ const result = await sendSigned(wire, { method: 'GET', key, query });
129
+ if (!result.ok) return refused(result.refusal);
130
+ const answer = await result.response.text();
131
+ for (const block of xmlBlocks(answer, 'Part')) {
132
+ const partNumber = Number(xmlField(block, 'PartNumber'));
133
+ const size = Number(xmlField(block, 'Size'));
134
+ const etag = xmlText(xmlField(block, 'ETag') ?? '');
135
+ if (Number.isSafeInteger(partNumber) && Number.isSafeInteger(size) && etag !== '') {
136
+ parts.push({ partNumber, etag, size });
137
+ }
138
+ }
139
+ marker = xmlField(answer, 'NextPartNumberMarker');
140
+ if (xmlField(answer, 'IsTruncated') !== 'true' || marker === undefined) break;
141
+ }
142
+ return parts.sort((a, b) => a.partNumber - b.partNumber);
143
+ }
144
+
145
+ /** ListMultipartUploads under a prefix, every page: the sessions nobody completed or aborted. */
146
+ export async function listS3Uploads(
147
+ { wire, refused }: Omit<S3UploadTarget, 'key'>,
148
+ prefix: string | undefined,
149
+ ): Promise<readonly MultipartSession[]> {
150
+ const sessions: MultipartSession[] = [];
151
+ let markers: Readonly<Record<string, string>> = {};
152
+ for (;;) {
153
+ const query = { uploads: '', ...(prefix === undefined ? {} : { prefix }), ...markers };
154
+ const result = await sendSigned(wire, { method: 'GET', key: '', query });
155
+ if (!result.ok) return refused(result.refusal);
156
+ const answer = await result.response.text();
157
+ for (const block of xmlBlocks(answer, 'Upload')) {
158
+ const key = xmlText(xmlField(block, 'Key') ?? '');
159
+ const uploadId = xmlText(xmlField(block, 'UploadId') ?? '');
160
+ const initiated = new Date(xmlField(block, 'Initiated') ?? '');
161
+ if (key === '' || uploadId === '') continue;
162
+ sessions.push({
163
+ key,
164
+ uploadId,
165
+ ...(Number.isNaN(initiated.getTime()) ? {} : { initiated }),
166
+ });
167
+ }
168
+ const nextKey = xmlField(answer, 'NextKeyMarker');
169
+ const nextId = xmlField(answer, 'NextUploadIdMarker');
170
+ if (xmlField(answer, 'IsTruncated') !== 'true' || nextKey === undefined) break;
171
+ markers = {
172
+ 'key-marker': xmlText(nextKey),
173
+ ...(nextId === undefined ? {} : { 'upload-id-marker': xmlText(nextId) }),
174
+ };
175
+ }
176
+ return sessions;
177
+ }
@@ -0,0 +1,52 @@
1
+ // Single responsibility: the s3 disk's `signedUrl`, both presigners under one rule. Bun's presign
2
+ // binds method and expiry and signs `host` alone: `type` becomes `response-content-type`, which
3
+ // governs a GET, so a PUT's `Content-Type` is unsigned and `promoteAttachment` sniffs the object. A
4
+ // `contentLength` leaves Bun for core's signer, the one way a presign can hold a size.
5
+
6
+ import { assert, finiteCount, MAX_AWS_PRESIGN_SECONDS } from '@ultimat3/core';
7
+ import { type SignedUrlOptions, signedByteBound } from './driver';
8
+ import type { S3FileLike } from './driver-s3-client';
9
+ import { presignBoundPut, type S3WireOptions } from './driver-s3-signed';
10
+ import { uploadSizeUndeclared } from './errors-stream';
11
+ import { assertSafeKey } from './path';
12
+ import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
13
+
14
+ export async function s3SignedUrl(
15
+ wire: S3WireOptions,
16
+ fileOf: (key: string) => S3FileLike,
17
+ key: string,
18
+ urlOptions: SignedUrlOptions | undefined,
19
+ ): Promise<string> {
20
+ // Screened with the SAME function `buildSignedUrl` applies on the local disk: `Math.ceil(NaN /
21
+ // 1000)` is `NaN`, so `X-Amz-Expires=NaN` went to AWS and the app got a 403 on a link it believed
22
+ // it had just minted. `=== undefined` rather than `??`, so an explicit `null` is refused.
23
+ const expiresInMs = finiteCount(
24
+ 'the s3 driver',
25
+ 'expiresInMs',
26
+ urlOptions?.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : urlOptions.expiresInMs,
27
+ 1,
28
+ );
29
+ const seconds = Math.ceil(expiresInMs / 1000);
30
+ // Before the branch, so Bun's presign cannot mint a URL past SigV4's limit the other refuses.
31
+ assert(
32
+ seconds <= MAX_AWS_PRESIGN_SECONDS,
33
+ `signedUrl() on the s3 disk was given expiresInMs ${expiresInMs}, past SigV4's seven days`,
34
+ `signedUrl(key, { expiresInMs: ${MAX_AWS_PRESIGN_SECONDS * 1000} }) # the longest a presign lives`,
35
+ );
36
+ const safe = assertSafeKey(key);
37
+ const contentLength = signedByteBound('s3', urlOptions);
38
+ if (urlOptions?.contentLength !== undefined && contentLength !== undefined) {
39
+ return presignBoundPut(wire, safe, contentLength, seconds, urlOptions?.part);
40
+ }
41
+ // A part names its exact size or nothing: Bun's presign below would drop it and sign a whole PUT.
42
+ if (urlOptions?.part !== undefined) throw uploadSizeUndeclared(safe, undefined);
43
+ // A ceiling bounds a PUT; a read has no body, and the local disks ignore it there too.
44
+ if (urlOptions?.method === 'PUT' && urlOptions.maxBytes !== undefined) {
45
+ throw uploadSizeUndeclared(safe, undefined);
46
+ }
47
+ return fileOf(safe).presign({
48
+ method: urlOptions?.method ?? 'GET',
49
+ expiresIn: seconds,
50
+ ...(urlOptions?.contentType === undefined ? {} : { type: urlOptions.contentType }),
51
+ });
52
+ }
@@ -0,0 +1,97 @@
1
+ // Single responsibility: `putStream` on the s3 wire — the pump that cuts a body into parts. Not
2
+ // `S3File.writer()`, measured on Bun 1.4.4: `end(error)` COMPLETES the upload, so an overrun would
3
+ // replace the object at the key, and the writer cannot send `x-amz-meta-*`. Here the object does not
4
+ // exist until the Complete, and every other exit aborts.
5
+
6
+ import { pumpBounded } from './byte-pump';
7
+ import type { PutOptions } from './driver';
8
+ import { objectHeaders, signedPut } from './driver-s3-lock';
9
+ import {
10
+ abortS3Upload,
11
+ completeS3Upload,
12
+ createS3Upload,
13
+ type S3UploadTarget,
14
+ uploadS3Part,
15
+ } from './driver-s3-multipart';
16
+ import {
17
+ isAnswered,
18
+ multipartEtagOf,
19
+ type S3Stat,
20
+ sentObject,
21
+ settleComplete,
22
+ settleLostPut,
23
+ } from './driver-s3-settle';
24
+ import type { S3WireOptions } from './driver-s3-signed';
25
+ import { partBytesFor } from './multipart';
26
+ import type { PutStreamOptions } from './put-stream';
27
+ import { streamLimit } from './put-stream';
28
+
29
+ export interface S3PutStreamInput extends Pick<S3UploadTarget, 'key' | 'refused'> {
30
+ readonly body: ReadableStream<Uint8Array>;
31
+ readonly options: PutStreamOptions;
32
+ readonly maxBytes: number;
33
+ readonly driver: string;
34
+ /** The disk's HEAD, asked only when a last request's answer was lost. */
35
+ readonly stat: S3Stat;
36
+ }
37
+
38
+ /** The whole body, stored — or nothing stored and the key as it was. */
39
+ export async function putS3Stream(wire: S3WireOptions, input: S3PutStreamInput): Promise<void> {
40
+ const { key, body, options, maxBytes, driver, refused, stat } = input;
41
+ const target: S3UploadTarget = { wire, key, refused };
42
+ const putOptions: PutOptions = options;
43
+ // Screened before a byte is read: a header that cannot travel fails here, not on the Create.
44
+ const headers = objectHeaders(putOptions);
45
+ const partBytes = partBytesFor(maxBytes);
46
+ let chunks: Uint8Array[] = [];
47
+ let held = 0;
48
+ let total = 0;
49
+ let uploadId: string | undefined;
50
+ const etags: string[] = [];
51
+
52
+ const sendPart = async (): Promise<void> => {
53
+ uploadId ??= await createS3Upload(target, headers);
54
+ const part = new Uint8Array(Bun.concatArrayBuffers(chunks));
55
+ chunks = [];
56
+ held = 0;
57
+ etags.push(await uploadS3Part(target, uploadId, etags.length + 1, part));
58
+ };
59
+
60
+ try {
61
+ await pumpBounded(body, streamLimit(driver, key, maxBytes), async (chunk) => {
62
+ chunks.push(chunk);
63
+ held += chunk.byteLength;
64
+ total += chunk.byteLength;
65
+ if (held >= partBytes) await sendPart();
66
+ });
67
+ if (uploadId === undefined) {
68
+ const whole = new Uint8Array(Bun.concatArrayBuffers(chunks));
69
+ const rethrow = (error: unknown): never => {
70
+ throw error;
71
+ };
72
+ const refusal = await signedPut(wire, key, whole, putOptions).catch(
73
+ settleLostPut(stat, { driver, key, bytes: whole, attributes: options }, rethrow),
74
+ );
75
+ if (refusal !== undefined) refused(refusal);
76
+ return;
77
+ }
78
+ if (held > 0) await sendPart();
79
+ const open = uploadId;
80
+ await completeS3Upload(
81
+ target,
82
+ open,
83
+ etags.map((etag, at) => ({ partNumber: at + 1, etag })),
84
+ ).catch(async (lost: unknown) => {
85
+ if (isAnswered(lost)) throw lost;
86
+ // Settled here, abort included, so the catch below must not abort a second time.
87
+ uploadId = undefined;
88
+ const sent = sentObject(total, multipartEtagOf(etags), options);
89
+ await settleComplete(stat, { ...target, driver }, open, sent, lost);
90
+ });
91
+ } catch (error) {
92
+ // Parts bill until they are aborted. The refusal the caller reads is the first one, so an abort
93
+ // that fails too is left to the bucket's lifecycle rule rather than replacing it.
94
+ if (uploadId !== undefined) await abortS3Upload(target, uploadId).catch(() => undefined);
95
+ throw error;
96
+ }
97
+ }
@@ -0,0 +1,150 @@
1
+ // Single responsibility: what a write whose answer was lost actually did. The last request went out
2
+ // and the socket died, so the provider may have stored the object. Settled by a stat against what
3
+ // was sent, and for a multipart upload by an abort, whose answer says whether it was still open.
4
+ // What neither can settle is `X_STORAGE_WRITE_OUTCOME_UNKNOWN`, never a refusal the disk did not make.
5
+
6
+ import { isUltimateError, stringField } from '@ultimat3/core';
7
+ import { DEFAULT_CONTENT_TYPE, type PutOptions } from './driver';
8
+ import { abortS3Upload, type S3UploadTarget } from './driver-s3-multipart';
9
+ import { writeOutcomeUnknown } from './errors-stream';
10
+
11
+ /** The disk's own HEAD: `undefined` for nothing there. A throw is a stat that could not answer. */
12
+ export type S3Stat = (
13
+ key: string,
14
+ ) => Promise<
15
+ { readonly size: number; readonly etag: string; readonly contentType: string } | undefined
16
+ >;
17
+
18
+ export interface SentObject {
19
+ readonly size: number;
20
+ /** The etag the provider gives exactly these bytes, unquoted; `undefined` when it cannot be known. */
21
+ readonly etag: string | undefined;
22
+ readonly contentType: string;
23
+ /**
24
+ * The write set something a HEAD does not read back (metadata, cache-control, a lock). Then a
25
+ * match proves only the bytes, which an overwrite of the same bytes matches already: unknown.
26
+ */
27
+ readonly unverifiable: boolean;
28
+ }
29
+
30
+ /** What a write said about the object, beyond its bytes. */
31
+ export interface SentAttributes
32
+ extends Pick<PutOptions, 'contentType' | 'cacheControl' | 'metadata'> {
33
+ readonly retention?: unknown;
34
+ readonly legalHold?: unknown;
35
+ }
36
+
37
+ /** `SentObject` for `size` bytes of `etag` under `attributes`. */
38
+ export const sentObject = (
39
+ size: number,
40
+ etag: string | undefined,
41
+ attributes: SentAttributes | undefined,
42
+ ): SentObject => ({
43
+ size,
44
+ etag,
45
+ contentType: attributes?.contentType ?? DEFAULT_CONTENT_TYPE,
46
+ unverifiable:
47
+ attributes?.metadata !== undefined ||
48
+ attributes?.cacheControl !== undefined ||
49
+ attributes?.retention !== undefined ||
50
+ attributes?.legalHold !== undefined,
51
+ });
52
+
53
+ const unquoted = (etag: string): string => etag.replace(/^W\//, '').replaceAll('"', '');
54
+
55
+ const md5Hex = (bytes: Uint8Array): string =>
56
+ new Bun.CryptoHasher('md5').update(bytes).digest('hex');
57
+
58
+ /** A single PUT's etag: the body's MD5. A provider encrypting with KMS mints another, so no match. */
59
+ export const putEtagOf = (bytes: Uint8Array): string => md5Hex(bytes);
60
+
61
+ /**
62
+ * S3's multipart etag: the MD5 of the parts' binary MD5s, then `-<count>`. Measured equal on the test
63
+ * stack's gateway. A part etag that is not an MD5 (KMS) makes it unknowable, never a guess.
64
+ */
65
+ export function multipartEtagOf(partEtags: readonly string[]): string | undefined {
66
+ const digests: Uint8Array[] = [];
67
+ for (const etag of partEtags) {
68
+ const hex = unquoted(etag);
69
+ if (!/^[0-9a-f]{32}$/i.test(hex)) return undefined;
70
+ digests.push(Uint8Array.fromHex(hex));
71
+ }
72
+ return `${md5Hex(new Uint8Array(Bun.concatArrayBuffers(digests)))}-${partEtags.length}`;
73
+ }
74
+
75
+ /** `true` when the key holds what was sent, `false` when it holds something else, `undefined` unknown. */
76
+ async function holds(stat: S3Stat, key: string, sent: SentObject): Promise<boolean | undefined> {
77
+ let found: Awaited<ReturnType<S3Stat>>;
78
+ try {
79
+ found = await stat(key);
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ if (found === undefined) return false;
84
+ if (sent.etag === undefined || sent.unverifiable) return undefined;
85
+ return (
86
+ found.size === sent.size &&
87
+ unquoted(found.etag) === sent.etag &&
88
+ found.contentType === sent.contentType
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Whether a failed write is the provider ANSWERING (a refusal, already coded or Bun's `S3Error`) or
94
+ * the answer lost on the wire, after which the object may be stored.
95
+ */
96
+ export const isAnswered = (error: unknown): boolean =>
97
+ isUltimateError(error) || stringField(error, 'name') === 'S3Error';
98
+
99
+ /**
100
+ * The `.catch` for a single PUT: a provider answer goes to `refused`, a lost one is settled, so
101
+ * `put()` resolves when the object landed and is `X_STORAGE_WRITE_OUTCOME_UNKNOWN` when unproven.
102
+ */
103
+ export const settleLostPut =
104
+ (
105
+ stat: S3Stat,
106
+ input: {
107
+ readonly driver: string;
108
+ readonly key: string;
109
+ readonly bytes: Uint8Array;
110
+ readonly attributes: SentAttributes | undefined;
111
+ },
112
+ refused: (error: unknown) => never,
113
+ ) =>
114
+ async (error: unknown): Promise<undefined> => {
115
+ if (isAnswered(error)) return refused(error);
116
+ const sent = sentObject(input.bytes.byteLength, putEtagOf(input.bytes), input.attributes);
117
+ await settlePut(stat, { driver: input.driver, key: input.key, lost: error }, sent);
118
+ return undefined;
119
+ };
120
+
121
+ /** A lost single PUT. It cannot be aborted, so only a match settles it. */
122
+ export async function settlePut(
123
+ stat: S3Stat,
124
+ input: { readonly driver: string; readonly key: string; readonly lost: unknown },
125
+ sent: SentObject,
126
+ ): Promise<void> {
127
+ if ((await holds(stat, input.key, sent)) === true) return;
128
+ throw writeOutcomeUnknown(input.driver, input.key, input.lost);
129
+ }
130
+
131
+ /**
132
+ * A lost Complete. Resolves when the object landed; rethrows `lost` when the abort proves the upload
133
+ * was still open, so nothing was stored and the caller's refusal is the truth.
134
+ */
135
+ export async function settleComplete(
136
+ stat: S3Stat,
137
+ target: S3UploadTarget & { readonly driver: string },
138
+ uploadId: string,
139
+ sent: SentObject,
140
+ lost: unknown,
141
+ ): Promise<void> {
142
+ if ((await holds(stat, target.key, sent)) === true) return;
143
+ const refusal = await abortS3Upload(target, uploadId).catch(() => 'unanswered' as const);
144
+ if (refusal === undefined) throw lost;
145
+ // NoSuchUpload: nothing is open, so the Complete finished. Whether with these bytes, the key says.
146
+ if (refusal !== 'unanswered' && refusal.statusCode === 404) {
147
+ if ((await holds(stat, target.key, sent)) === true) return;
148
+ }
149
+ throw writeOutcomeUnknown(target.driver, target.key, lost);
150
+ }
@@ -9,9 +9,11 @@ import {
9
9
  type Clock,
10
10
  ConfigInvalidError,
11
11
  EnvMissingError,
12
+ presignAwsUrl,
12
13
  signAwsRequest,
13
14
  systemClock,
14
15
  } from '@ultimat3/core';
16
+ import type { SignedPart } from './signed-url';
15
17
 
16
18
  /** The slice of `fetch` this file uses; injected in tests so no socket is ever opened. */
17
19
  export type S3FetchLike = (
@@ -51,10 +53,12 @@ export interface S3Refusal {
51
53
  }
52
54
 
53
55
  export interface S3SignedRequest {
54
- readonly method: 'GET' | 'PUT';
56
+ readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE';
55
57
  readonly key: string;
56
58
  /** A sub-resource such as `retention` — sent and signed as `?retention=`. */
57
59
  readonly subresource?: string | undefined;
60
+ /** Query parameters of a multipart call, signed. An empty value is sent as `uploads=`. */
61
+ readonly query?: Readonly<Record<string, string>> | undefined;
58
62
  readonly headers?: Readonly<Record<string, string>> | undefined;
59
63
  readonly body?: Uint8Array | undefined;
60
64
  }
@@ -122,6 +126,61 @@ export function resolveS3Target(options: S3WireOptions): S3Target {
122
126
  };
123
127
  }
124
128
 
129
+ /**
130
+ * A PUT URL that signs its `content-length`, which is what `Bun.S3Client.presign` has no option for.
131
+ * The provider recomputes the signature from the header the browser really sent, so the only body
132
+ * this URL lands is one of exactly `contentLength` bytes. A browser sets that header itself.
133
+ */
134
+ export async function presignBoundPut(
135
+ options: S3WireOptions,
136
+ key: string,
137
+ contentLength: number,
138
+ expiresInSeconds: number,
139
+ part?: SignedPart | undefined,
140
+ ): Promise<string> {
141
+ const url = s3ObjectUrl(options, key);
142
+ // A part of an open upload: S3's own UploadPart, its two parameters inside the signature.
143
+ if (part !== undefined) {
144
+ url.searchParams.set('partNumber', String(part.partNumber));
145
+ url.searchParams.set('uploadId', part.uploadId);
146
+ }
147
+ const presigned = await presignAwsUrl({
148
+ method: 'PUT',
149
+ url,
150
+ expiresInSeconds,
151
+ headers: { 'content-length': String(contentLength) },
152
+ credentials: s3Credentials(options),
153
+ region: signingRegion(options),
154
+ service: 's3',
155
+ clock: options.clock ?? systemClock,
156
+ });
157
+ return presigned.url;
158
+ }
159
+
160
+ /** The labels between `s3.` and `.amazonaws.com` of an AWS host: its region, or none. */
161
+ const AWS_HOST = /(?:^|\.)s3\.(?:(.+)\.)?amazonaws\.com$/i;
162
+ const REGION = /^[a-z0-9-]{1,64}$/;
163
+
164
+ /**
165
+ * The region a request is signed for, as `Bun.S3Client` derives it, measured on Bun 1.4.4 so the
166
+ * disk's two transports sign alike: a region set wins; no endpoint is AWS's `us-east-1`; an AWS host
167
+ * `s3.<region>.amazonaws.com` is that region, bare `s3.amazonaws.com` is `us-east-1`; any other host
168
+ * is `auto` (R2, a gateway). One deviation: Bun reads `s3.dualstack.<region>` as the whole
169
+ * `dualstack.<region>`, which is no region, and this reads the region.
170
+ */
171
+ export function signingRegion(options: S3WireOptions): string {
172
+ const { region, endpoint } = resolveS3Target(options);
173
+ if (region !== undefined) return region;
174
+ if (endpoint === undefined) return 'us-east-1';
175
+ const host = URL.canParse(endpoint) ? new URL(endpoint).hostname : '';
176
+ const match = AWS_HOST.exec(host);
177
+ if (match === null) return 'auto';
178
+ const named = match[1];
179
+ if (named === undefined) return 'us-east-1';
180
+ const last = named.split('.').at(-1) ?? '';
181
+ return REGION.test(named) ? named : REGION.test(last) ? last : 'auto';
182
+ }
183
+
125
184
  /** Read lazily, at the first signed request — importing or constructing the disk reads nothing. */
126
185
  export function s3Credentials(options: S3WireOptions): AwsCredentials {
127
186
  const env = options.env ?? process.env;
@@ -153,7 +212,10 @@ export function s3ObjectUrl(options: S3WireOptions, key: string): URL {
153
212
  );
154
213
  }
155
214
 
156
- const XML_FIELD = (name: string): RegExp => new RegExp(`<${name}>([^<]{0,512})</${name}>`);
215
+ // Uncapped: a key is up to 1,024 bytes and S3 escapes it (`&` is `&amp;`), so a 512-character
216
+ // cap dropped a long key's session from `listS3Uploads` and ended its paging at a long
217
+ // `NextKeyMarker`. `[^<]*` stops at the next tag, so the match stays linear with no cap.
218
+ const XML_FIELD = (name: string): RegExp => new RegExp(`<${name}>([^<]*)</${name}>`);
157
219
 
158
220
  /** The first `<name>…</name>` of an S3 XML answer — the shapes read here are flat and fixed. */
159
221
  export function xmlField(xml: string, name: string): string | undefined {
@@ -189,6 +251,9 @@ export async function sendSigned(
189
251
  }
190
252
  const url = s3ObjectUrl(options, request.key);
191
253
  if (request.subresource !== undefined) url.search = `${request.subresource}=`;
254
+ for (const [name, value] of Object.entries(request.query ?? {})) {
255
+ url.searchParams.set(name, value);
256
+ }
192
257
  const signed = await signAwsRequest({
193
258
  method: request.method,
194
259
  url,
@@ -197,7 +262,7 @@ export async function sendSigned(
197
262
  // refuse a body that changed in flight; a read has none.
198
263
  payload: { body: request.body ?? new Uint8Array() },
199
264
  credentials: s3Credentials(options),
200
- region: resolveS3Target(options).region ?? 'auto',
265
+ region: signingRegion(options),
201
266
  service: 's3',
202
267
  clock: options.clock ?? systemClock,
203
268
  });
@@ -0,0 +1,29 @@
1
+ // Single responsibility: the one `put()` option the s3 disk cannot honour, refused by name with the
2
+ // bucket-wide command that does the job instead.
3
+
4
+ import { NotImplementedError, renderFixLiteral, renderFixShellArg } from '@ultimat3/core';
5
+ import type { PutOptions } from './driver';
6
+
7
+ /**
8
+ * Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
9
+ * A typed refusal at the call site is the whole point: `serverSideEncryption` exists on
10
+ * `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
11
+ * meets while writing the call, not while answering a security review.
12
+ */
13
+ export function refuseUnsupportedPut(bucket: string, putOptions?: PutOptions): void {
14
+ const sse = putOptions?.serverSideEncryption;
15
+ if (sse === undefined) return;
16
+ // The key id is the caller's own option, spliced into single-quoted JSON: `renderFixLiteral`
17
+ // escapes the `"` and `\` JSON needs, and a `'` — the one byte live inside single quotes — makes
18
+ // it the placeholder rather than ending the argument (plan 101 row S12).
19
+ const keyId = sse.kmsKeyId?.includes("'") === false ? sse.kmsKeyId : undefined;
20
+ const bucketArg = renderFixShellArg(bucket, "'<bucket>'");
21
+ throw new NotImplementedError({
22
+ cause:
23
+ 'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
24
+ fix:
25
+ sse.algorithm === 'aws:kms'
26
+ ? `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":${renderFixLiteral(keyId, '"<key-arn>"')}},"BucketKeyEnabled":true}]}'`
27
+ : `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'`,
28
+ });
29
+ }