@ultimat3/storage 1.2.0 → 3.0.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.
@@ -0,0 +1,164 @@
1
+ // The in-memory `Bun.S3Client` stand-in the s3 driver's tests drive, and the helpers that read
2
+ // its answers. Shared rather than copied because `driver-s3.test.ts` (reads, copies, listings,
3
+ // credentials) and `driver-s3-put.test.ts` (everything `put()` refuses) must exercise the SAME
4
+ // fake — two fakes drifting apart would be two providers, agreeing only by construction.
5
+
6
+ import type { S3ClientLike, S3FileLike, S3ListResultLike, S3StatLike } from './driver-s3';
7
+ import { isStorageError, objectNotFound } from './errors';
8
+
9
+ /** The driver's private `DRIVER_NAME`; the fake reports failures against the same disk. */
10
+ export const FAKE_DISK = 's3';
11
+
12
+ export interface FakeObject {
13
+ bytes: Uint8Array;
14
+ type?: string | undefined;
15
+ etag?: string | undefined;
16
+ lastModified?: string | Date | undefined;
17
+ }
18
+
19
+ export interface PresignCall {
20
+ key: string;
21
+ options: {
22
+ method?: string | undefined;
23
+ expiresIn?: number | undefined;
24
+ type?: string | undefined;
25
+ };
26
+ }
27
+
28
+ export interface ListCall {
29
+ prefix?: string | undefined;
30
+ maxKeys?: number | undefined;
31
+ continuationToken?: string | undefined;
32
+ }
33
+
34
+ /**
35
+ * What Bun hands back when the SERVICE refuses: an ordinary `Error` named `S3Error`, carrying the
36
+ * provider's own code and status. The driver must read those structurally, because nothing in the
37
+ * type system says a caught value has them.
38
+ */
39
+ export function s3Error(code: string, statusCode: number, key: string): Error {
40
+ return Object.assign(new Error(`${code}: the fake provider refused ${key}`), {
41
+ name: 'S3Error',
42
+ code,
43
+ statusCode,
44
+ path: key,
45
+ });
46
+ }
47
+
48
+ /** In-memory stand-in for `Bun.S3Client`, driven through `S3ClientLike` — no socket, ever. */
49
+ export class FakeS3Client implements S3ClientLike {
50
+ readonly store = new Map<string, FakeObject>();
51
+ readonly fileCalls: string[] = [];
52
+ readonly presignCalls: PresignCall[] = [];
53
+ readonly listCalls: ListCall[] = [];
54
+ listResult: S3ListResultLike = { contents: [] };
55
+ /** A provider REFUSAL (403/503/reset) — the failure `delete()` must surface, not swallow. */
56
+ failDeleteFor: string | undefined;
57
+ /** A provider "there is nothing there" — the one failure `delete()` may call success. */
58
+ absentDeleteFor: string | undefined;
59
+ /**
60
+ * A refused LISTING — a denied `s3:ListBucket`, a throttle, an expired credential. There is no
61
+ * "absent" counterpart: a prefix with no keys is an empty page, never a rejection, so every
62
+ * failure `list()` can meet is one it must surface.
63
+ */
64
+ failListWith: Error | undefined;
65
+
66
+ /** Which key each handed-out `S3FileLike` stands for, so `write(sourceFile)` can read it. */
67
+ private readonly fileKeys = new WeakMap<object, string>();
68
+
69
+ /** The bytes behind an `S3FileLike` passed as `write`'s data, or `undefined` for raw bytes. */
70
+ sourceOf(data: unknown): Uint8Array | undefined {
71
+ if (typeof data !== 'object' || data === null) return undefined;
72
+ const sourceKey = this.fileKeys.get(data);
73
+ return sourceKey === undefined ? undefined : this.store.get(sourceKey)?.bytes;
74
+ }
75
+
76
+ file(key: string): S3FileLike {
77
+ this.fileCalls.push(key);
78
+ const store = this.store;
79
+ const client = this;
80
+ const handle: S3FileLike = {
81
+ async write(data, options) {
82
+ // `copy()` hands the SOURCE S3File to write(), exactly as Bun's own union allows, so the
83
+ // fake has to read one back out of its store rather than assume bytes.
84
+ const source = client.sourceOf(data);
85
+ const bytes =
86
+ source !== undefined
87
+ ? source
88
+ : data instanceof Uint8Array
89
+ ? data
90
+ : new Uint8Array(await (data as Blob).arrayBuffer());
91
+ store.set(key, { bytes, type: options?.type });
92
+ return bytes.byteLength;
93
+ },
94
+ async arrayBuffer() {
95
+ const entry = store.get(key);
96
+ // Reads the way the provider does: a GET on a key that is not there is a 404, and the
97
+ // driver is expected to have gated it behind exists() before ever getting here.
98
+ if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
99
+ return new Uint8Array(entry.bytes).buffer;
100
+ },
101
+ async exists() {
102
+ return store.has(key);
103
+ },
104
+ async delete() {
105
+ // Shaped like the real thing: an `S3Error` is a plain Error with `name: 'S3Error'`, a
106
+ // provider `code` and a status. Deliberately NOT a StorageError — the driver has to
107
+ // classify a rejection it never authored, which is the only kind a provider hands back.
108
+ if (client.absentDeleteFor === key) throw s3Error('NoSuchKey', 404, key);
109
+ if (client.failDeleteFor === key) throw s3Error('AccessDenied', 403, key);
110
+ store.delete(key);
111
+ },
112
+ stream() {
113
+ const entry = store.get(key);
114
+ const bytes = entry?.bytes ?? new Uint8Array();
115
+ return new ReadableStream<Uint8Array>({
116
+ start(controller) {
117
+ controller.enqueue(bytes);
118
+ controller.close();
119
+ },
120
+ });
121
+ },
122
+ async stat(): Promise<S3StatLike> {
123
+ const entry = store.get(key);
124
+ if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
125
+ return {
126
+ size: entry.bytes.byteLength,
127
+ type: entry.type,
128
+ etag: entry.etag,
129
+ lastModified: entry.lastModified,
130
+ };
131
+ },
132
+ presign(options) {
133
+ client.presignCalls.push({ key, options });
134
+ return `https://fake.example/${key}?signed`;
135
+ },
136
+ };
137
+ this.fileKeys.set(handle, key);
138
+ return handle;
139
+ }
140
+
141
+ async list(input: ListCall): Promise<S3ListResultLike> {
142
+ this.listCalls.push(input);
143
+ if (this.failListWith !== undefined) throw this.failListWith;
144
+ return this.listResult;
145
+ }
146
+ }
147
+
148
+ export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text);
149
+ export const textOf = (bytes: Uint8Array): string => new TextDecoder().decode(bytes);
150
+
151
+ /** The error code a call answered with, or how it failed to answer with one. */
152
+ export function codeOf(caught: unknown): string {
153
+ return isStorageError(caught) ? caught.code : `not-a-storage-error: ${String(caught)}`;
154
+ }
155
+
156
+ /** The thrown value itself, where the assertion is about its `cause`/`fix` and not just its code. */
157
+ export async function catchError(fn: () => Promise<unknown>): Promise<unknown> {
158
+ try {
159
+ await fn();
160
+ return undefined;
161
+ } catch (error) {
162
+ return error;
163
+ }
164
+ }
package/src/driver-s3.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // The client is built lazily on first use so importing this module never opens a socket, and
4
4
  // credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
5
5
 
6
- import { ConfigInvalidError, EnvMissingError } from '@ultimat3/core';
6
+ import { ConfigInvalidError, EnvMissingError, stringField } from '@ultimat3/core';
7
7
  import {
8
8
  DEFAULT_CONTENT_TYPE,
9
9
  DEFAULT_LIST_LIMIT,
@@ -13,19 +13,28 @@ import {
13
13
  type SignedUrlOptions,
14
14
  type StorageBody,
15
15
  type StorageDriver,
16
+ type StorageListEntry,
16
17
  type StorageObject,
17
18
  type StorageRead,
18
19
  sha256Base64,
19
20
  toBytes,
20
21
  } from './driver';
21
- import { checksumMismatch, objectNotFound, storageNotImplemented } from './errors';
22
+ import {
23
+ checksumMismatch,
24
+ deleteFailed,
25
+ listFailed,
26
+ objectNotFound,
27
+ storageNotImplemented,
28
+ } from './errors';
22
29
  import { assertSafeKey } from './path';
30
+ import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
23
31
 
24
32
  const DRIVER_NAME = 's3';
25
33
 
26
34
  /** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
27
35
  export interface S3FileLike {
28
- write(data: Uint8Array | Blob, options?: { type?: string }): Promise<number>;
36
+ /** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` that is `copy`. */
37
+ write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
29
38
  arrayBuffer(): Promise<ArrayBuffer>;
30
39
  exists(): Promise<boolean>;
31
40
  delete(): Promise<void>;
@@ -79,6 +88,13 @@ export interface S3DriverOptions {
79
88
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
80
89
  /** Injected in tests; production constructs `Bun.S3Client`. */
81
90
  readonly client?: S3ClientLike | undefined;
91
+ /**
92
+ * Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
93
+ * upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
94
+ * does write large objects from the server; a user upload belongs on `grantUpload` instead,
95
+ * and S3's single-PUT limit is 5GB regardless of what this says.
96
+ */
97
+ readonly maxPutBytes?: number | undefined;
82
98
  }
83
99
 
84
100
  interface S3ClientConstructor {
@@ -137,7 +153,63 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
137
153
  const toDate = (value: string | Date | undefined): Date =>
138
154
  value === undefined ? new Date(0) : value instanceof Date ? value : new Date(value);
139
155
 
156
+ /** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
157
+ function numberField(value: unknown, key: string): number | undefined {
158
+ if (typeof value !== 'object' || value === null) return undefined;
159
+ try {
160
+ const held = (value as Record<string, unknown>)[key];
161
+ return typeof held === 'number' ? held : undefined;
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ }
166
+
167
+ /** The provider codes that mean "there is nothing at that key", and nothing wider. */
168
+ const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
169
+
170
+ /**
171
+ * The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
172
+ * credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
173
+ * reported all four as deleted — which is how an erasure sweep certifies data it never removed.
174
+ *
175
+ * Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
176
+ * every field of a value this process did not build is a getter that can throw.
177
+ */
178
+ function isAbsentObject(error: unknown): boolean {
179
+ const code = stringField(error, 'code');
180
+ if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
181
+ return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
182
+ }
183
+
184
+ /**
185
+ * Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
186
+ * A typed refusal at the call site is the whole point: `serverSideEncryption` exists on
187
+ * `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
188
+ * meets while writing the call, not while answering a security review.
189
+ */
190
+ function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
191
+ if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
192
+ const uri = `s3://${bucket}/${key}`;
193
+ throw storageNotImplemented(
194
+ 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
195
+ `drop metadata/cacheControl from put(), or set them out of band: ` +
196
+ `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
197
+ );
198
+ }
199
+ const sse = putOptions?.serverSideEncryption;
200
+ if (sse === undefined) return;
201
+ const rule =
202
+ sse.algorithm === 'aws:kms'
203
+ ? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
204
+ : '{"SSEAlgorithm":"AES256"}';
205
+ throw storageNotImplemented(
206
+ 'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption)',
207
+ `set it bucket-wide, then drop serverSideEncryption from put(): aws s3api put-bucket-encryption --bucket ${bucket} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
208
+ );
209
+ }
210
+
140
211
  export function s3Driver(options: S3DriverOptions): StorageDriver {
212
+ const maxPutBytes = options.maxPutBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
141
213
  let client: S3ClientLike | undefined;
142
214
  const conn = (): S3ClientLike => {
143
215
  client ??= buildClient(options);
@@ -160,17 +232,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
160
232
 
161
233
  async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
162
234
  const safe = assertSafeKey(key);
163
- if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
164
- const uri = `s3://${options.bucket}/${safe}`;
165
- throw storageNotImplemented(
166
- 'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
167
- `drop metadata/cacheControl from put(), or set them out of band: ` +
168
- `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
169
- );
170
- }
171
- // Buffered on purpose: size and checksum must be known before the object exists.
172
- // Multipart streaming upload is a later optimisation, not a correctness change.
173
- const bytes = await toBytes(body);
235
+ refuseUnsupportedPut(options.bucket, safe, putOptions);
236
+ // Buffered on purpose: size and checksum must be known before the object exists — so this
237
+ // path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
238
+ // rather than a hope. User uploads never come through here: they go direct to the bucket
239
+ // via `grantUpload`, which is the architecture, not a later optimisation.
240
+ const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
174
241
  const claimed = putOptions?.checksum;
175
242
  if (claimed !== undefined) {
176
243
  const actual = sha256Base64(bytes);
@@ -197,11 +264,40 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
197
264
  return file.stream();
198
265
  },
199
266
 
200
- async delete(key: string): Promise<void> {
267
+ /**
268
+ * Bytes from one key to another without a round trip through this process. Bun exposes no
269
+ * `CopyObject`, so the source `S3File` is handed to `write()` — Bun's own union accepts one —
270
+ * and the bytes move inside Bun rather than through the app's heap. That is the half of the
271
+ * win available today: promoting a 500MB attachment used to be `get()` + `put()`, a full GB
272
+ * resident in the pod. A true server-side copy needs a Bun API that does not exist in 1.3.
273
+ */
274
+ async copy(from: string, to: string): Promise<StorageObject> {
275
+ const source = assertSafeKey(from);
276
+ const destination = assertSafeKey(to);
277
+ const file = conn().file(source);
278
+ if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, source);
279
+ const stat = await file.stat();
201
280
  await conn()
202
- .file(assertSafeKey(key))
203
- .delete()
204
- .catch(() => undefined);
281
+ .file(destination)
282
+ .write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE });
283
+ return statObject(destination);
284
+ },
285
+
286
+ async delete(key: string): Promise<void> {
287
+ const safe = assertSafeKey(key);
288
+ try {
289
+ await conn().file(safe).delete();
290
+ } catch (error) {
291
+ // Idempotent means an ABSENT key, and nothing else. AWS answers DELETE on a missing key
292
+ // with 204, so this branch is for the providers that do not.
293
+ if (isAbsentObject(error)) return;
294
+ throw deleteFailed(
295
+ DRIVER_NAME,
296
+ safe,
297
+ error,
298
+ `grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${options.bucket} --key ${safe}`,
299
+ );
300
+ }
205
301
  },
206
302
 
207
303
  async exists(key: string): Promise<boolean> {
@@ -209,18 +305,39 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
209
305
  },
210
306
 
211
307
  async list(listOptions?: ListOptions): Promise<ListPage> {
212
- const result = await conn().list({
213
- maxKeys: listOptions?.limit ?? DEFAULT_LIST_LIMIT,
214
- ...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
215
- ...(listOptions?.cursor === undefined ? {} : { continuationToken: listOptions.cursor }),
216
- });
217
- const objects: StorageObject[] = [];
308
+ const prefix = listOptions?.prefix ?? '';
309
+ // `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
310
+ // misconfigured, and it already answers with its own code and its own fix.
311
+ const client = conn();
312
+ let result: S3ListResultLike;
313
+ try {
314
+ result = await client.list({
315
+ maxKeys: listOptions?.limit ?? DEFAULT_LIST_LIMIT,
316
+ ...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
317
+ ...(listOptions?.cursor === undefined ? {} : { continuationToken: listOptions.cursor }),
318
+ });
319
+ } catch (error) {
320
+ // A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
321
+ // `@ultimat3/http`'s error map has nothing to turn it into but a 500. A denied
322
+ // `s3:ListBucket` is the commonest one and reads to an operator as an app crash.
323
+ throw listFailed(
324
+ DRIVER_NAME,
325
+ prefix,
326
+ error,
327
+ `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${options.bucket}${prefix === '' ? '' : ` --prefix ${prefix}`}`,
328
+ );
329
+ }
330
+ const objects: StorageListEntry[] = [];
218
331
  for (const entry of result.contents ?? []) {
219
332
  if (entry.key === undefined) continue;
333
+ // No `contentType`. ListObjectsV2 does not return one, and reading it for real would
334
+ // cost one HeadObject per listed row — which is what `list()` exists to avoid. It used
335
+ // to report `application/octet-stream`, indistinguishable from an object that really is
336
+ // one, while the local driver reported the truth from its sidecar: a caller filtering a
337
+ // listing by content type got everything on `local` and nothing on `s3`. Absent now.
220
338
  objects.push({
221
339
  key: entry.key,
222
340
  size: entry.size ?? 0,
223
- contentType: DEFAULT_CONTENT_TYPE,
224
341
  etag: entry.eTag ?? '',
225
342
  lastModified: toDate(entry.lastModified),
226
343
  });
package/src/driver.ts CHANGED
@@ -1,15 +1,48 @@
1
1
  // Single responsibility: the driver contract every disk implements, plus the byte helpers all
2
- // drivers share (body normalisation, SHA-256 etag/checksum). Bytes never travel as strings —
3
- // a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM.
2
+ // drivers share (BOUNDED body normalisation, SHA-256 etag/checksum). Bytes never travel as
3
+ // strings — a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM
4
+ // and never unbounded, which is the same failure with the sender choosing the size.
5
+
6
+ import { putTooLarge } from './errors';
4
7
 
5
8
  export type StorageBody = Uint8Array | ReadableStream<Uint8Array> | Blob;
6
9
 
7
- export interface StorageObject {
10
+ /**
11
+ * One row of a listing — everything a listing can HONESTLY know. `contentType` is optional here
12
+ * and required on `StorageObject` below, because S3's `ListObjectsV2` does not return one: the
13
+ * s3 driver used to fill in `application/octet-stream`, the local driver read the real value out
14
+ * of its sidecar, and a caller filtering a listing by content type therefore got every object on
15
+ * `local` and none on `s3`. Absent now means "this listing cannot know" — `get()` can.
16
+ */
17
+ export interface StorageListEntry {
8
18
  readonly key: string;
9
19
  readonly size: number;
10
- readonly contentType: string;
20
+ /** Absent means the driver's listing does not carry it. Never a fabricated default. */
21
+ readonly contentType?: string | undefined;
11
22
  readonly etag: string;
12
23
  readonly lastModified: Date;
24
+ /** Present only when the driver actually stored what `put()` was handed — never invented. */
25
+ readonly cacheControl?: string | undefined;
26
+ readonly metadata?: Readonly<Record<string, string>> | undefined;
27
+ }
28
+
29
+ /** A single object the driver has actually looked at, so the content type is known. */
30
+ export interface StorageObject extends StorageListEntry {
31
+ readonly contentType: string;
32
+ }
33
+
34
+ /**
35
+ * Encryption at rest, per object. Declared so the gap is visible at the type level: an engineer
36
+ * asked "can you prove which key encrypted this object?" must meet the answer at the call site,
37
+ * not by reading the driver. NO driver honours it yet — `Bun.S3Client` exposes `acl`,
38
+ * `storageClass` and `type` and nothing for `x-amz-server-side-encryption*`, and a POSIX file is
39
+ * not encrypted at all — so both drivers refuse a `put()` carrying one (`X_NOT_IMPLEMENTED`) with
40
+ * the out-of-band bucket-default command in the `fix`. A typed refusal beats a silent absence.
41
+ */
42
+ export interface ServerSideEncryption {
43
+ readonly algorithm: 'AES256' | 'aws:kms';
44
+ /** The customer-managed key. Only meaningful with `aws:kms`. */
45
+ readonly kmsKeyId?: string | undefined;
13
46
  }
14
47
 
15
48
  export interface PutOptions {
@@ -18,6 +51,8 @@ export interface PutOptions {
18
51
  readonly metadata?: Readonly<Record<string, string>> | undefined;
19
52
  /** base64 SHA-256 of the body. Supplied means verified: a mismatch is a rejected write. */
20
53
  readonly checksum?: string | undefined;
54
+ /** Refused by every shipped driver — see `ServerSideEncryption`. */
55
+ readonly serverSideEncryption?: ServerSideEncryption | undefined;
21
56
  }
22
57
 
23
58
  export interface ListOptions {
@@ -28,7 +63,7 @@ export interface ListOptions {
28
63
  }
29
64
 
30
65
  export interface ListPage {
31
- readonly objects: readonly StorageObject[];
66
+ readonly objects: readonly StorageListEntry[];
32
67
  readonly truncated: boolean;
33
68
  /** Absent when `truncated` is false. */
34
69
  readonly cursor?: string | undefined;
@@ -44,7 +79,17 @@ export type SignedUrlMethod = 'GET' | 'PUT';
44
79
  export interface SignedUrlOptions {
45
80
  readonly method?: SignedUrlMethod | undefined;
46
81
  readonly expiresInMs?: number | undefined;
47
- /** Constrains a `PUT`: the signature covers it, so a client cannot widen it. */
82
+ /**
83
+ * Ceiling on a `PUT`, and **the one option whose enforcement is the driver's, not the caller's**.
84
+ *
85
+ * `localDriver` signs it into the URL, so `acceptSignedUpload` refuses a client that widens it.
86
+ * `s3Driver` CANNOT: S3 has no request header for a size and Bun's `presign` covers method,
87
+ * expiry and content type only — so the client PUTs straight into the bucket and nothing between
88
+ * the grant and the object enforces this number. It is not refused there, because `grantUpload`
89
+ * supplies it on every grant and refusing would break every s3 upload an app mints; the ceiling
90
+ * a bucket-backed disk actually gets is a bucket rule or a post-upload check on `object.size`.
91
+ * `driver-parity.test.ts` pins both halves so neither moves alone.
92
+ */
48
93
  readonly maxBytes?: number | undefined;
49
94
  readonly contentType?: string | undefined;
50
95
  }
@@ -56,7 +101,17 @@ export interface StorageDriver {
56
101
  get(key: string): Promise<StorageRead>;
57
102
  /** Bytes without buffering — the only safe path for anything over a few MB. */
58
103
  stream(key: string): Promise<ReadableStream<Uint8Array>>;
59
- /** Idempotent: deleting an absent key is not an error. */
104
+ /**
105
+ * Bytes from one key to another without them passing through this process. Idempotent in the
106
+ * destination and non-destructive in the source: the caller deletes the source afterwards if
107
+ * it wanted a move. `X_STORAGE_NOT_FOUND` when `from` is absent.
108
+ */
109
+ copy(from: string, to: string): Promise<StorageObject>;
110
+ /**
111
+ * Deleting an ABSENT key is not an error. Anything else is: a denied `s3:DeleteObject`, a
112
+ * throttle, an expired credential and a read-only mount all raise `X_STORAGE_DELETE_FAILED`,
113
+ * because a caller that cannot tell "gone" from "refused" reports data erased that is not.
114
+ */
60
115
  delete(key: string): Promise<void>;
61
116
  exists(key: string): Promise<boolean>;
62
117
  list(options?: ListOptions): Promise<ListPage>;
@@ -66,10 +121,70 @@ export interface StorageDriver {
66
121
  export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
67
122
  export const DEFAULT_LIST_LIMIT = 1000;
68
123
 
69
- export async function toBytes(body: StorageBody): Promise<Uint8Array> {
70
- if (body instanceof Uint8Array) return body;
71
- if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());
72
- return new Uint8Array(await new Response(body).arrayBuffer());
124
+ /** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
125
+ export interface ByteLimit {
126
+ readonly driver: string;
127
+ readonly key: string;
128
+ readonly maxBytes: number;
129
+ }
130
+
131
+ /**
132
+ * Body → bytes, refusing past the ceiling BEFORE the whole body is resident.
133
+ *
134
+ * The bound is not an optimisation. `put()` buffers, so without one the heap grows by whatever a
135
+ * route piped into it: a 4GB request body on a pod with a 768Mi limit is an OOM kill, and the
136
+ * caller sees a dropped connection rather than a refusal. A `Uint8Array` and a `Blob` already
137
+ * know their length, so they are refused without a copy; a stream is read a chunk at a time and
138
+ * cancelled the moment the running total passes the ceiling, so at most one chunk past the limit
139
+ * is ever held. The server-side `put()` path is for objects that FIT IN MEMORY — user uploads go
140
+ * direct to the disk through `grantUpload`, which is the architecture, not the optimisation.
141
+ */
142
+ export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint8Array> {
143
+ if (body instanceof Uint8Array) {
144
+ if (body.byteLength > limit.maxBytes) {
145
+ throw putTooLarge(limit.driver, limit.key, body.byteLength, limit.maxBytes);
146
+ }
147
+ return body;
148
+ }
149
+ if (body instanceof Blob) {
150
+ if (body.size > limit.maxBytes) {
151
+ throw putTooLarge(limit.driver, limit.key, body.size, limit.maxBytes);
152
+ }
153
+ return new Uint8Array(await body.arrayBuffer());
154
+ }
155
+ return readBounded(body, limit);
156
+ }
157
+
158
+ /** The stream half of `toBytes`: the only shape whose length is unknown until it is read. */
159
+ async function readBounded(
160
+ body: ReadableStream<Uint8Array>,
161
+ limit: ByteLimit,
162
+ ): Promise<Uint8Array> {
163
+ const reader = body.getReader();
164
+ const chunks: Uint8Array[] = [];
165
+ let total = 0;
166
+ try {
167
+ for (;;) {
168
+ const next = await reader.read();
169
+ if (next.done) break;
170
+ total += next.value.byteLength;
171
+ if (total > limit.maxBytes) {
172
+ throw putTooLarge(limit.driver, limit.key, total, limit.maxBytes);
173
+ }
174
+ chunks.push(next.value);
175
+ }
176
+ } finally {
177
+ // Cancel rather than merely release: a refused body must stop arriving, not keep filling a
178
+ // socket buffer nobody will read. Already-closed streams answer this with a no-op.
179
+ await reader.cancel().catch(() => undefined);
180
+ }
181
+ const bytes = new Uint8Array(total);
182
+ let offset = 0;
183
+ for (const chunk of chunks) {
184
+ bytes.set(chunk, offset);
185
+ offset += chunk.byteLength;
186
+ }
187
+ return bytes;
73
188
  }
74
189
 
75
190
  /** base64 SHA-256 — the wire form for `PutOptions.checksum` and `ValidatedUpload.checksum`. */