@ultimat3/storage 1.1.0 → 2.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,213 @@
1
+ // Single responsibility: where an entity's files live, and how one that was never attached is
2
+ // found again. The upload happens BEFORE the row it belongs to exists, so it lands under a
3
+ // `pending/` segment inside the org and is promoted once there is an id — which makes an orphan
4
+ // a fact about the key rather than a join nobody runs. Every key here goes through `scopedKey`,
5
+ // so the tenant prefix is a construction, not a check somebody remembered to write.
6
+
7
+ import type { Clock } from '@ultimat3/core';
8
+ import { renderThrowable, systemClock } from '@ultimat3/core';
9
+ import type { ListPage, StorageDriver, StorageListEntry, StorageObject } from './driver';
10
+ import { orgMismatch, quarantined } from './errors';
11
+ import { isWithinOrg, orgPrefix, scopedKey } from './path';
12
+
13
+ /** The one segment an unattached upload lives under. `sweepOrphans` reads only this prefix. */
14
+ export const PENDING_SEGMENT = 'pending';
15
+
16
+ /**
17
+ * One more segment inside `pending/`, and the whole of Ultimate's content-scanning mechanism.
18
+ *
19
+ * Magic-byte sniffing closes stored XSS; it is not malware scanning, and `application/zip` is
20
+ * deliberately accepted as the OOXML container, so a macro-laden `.docx` passes `validateUpload`
21
+ * exactly as a clean one does. Scanning is the APP's — a scanner is a business decision with a
22
+ * vendor, a licence and a latency budget (axiom 8) — so what ships is the place to put one: an
23
+ * upload granted with `quarantine: true` lands under this prefix, `promoteAttachment` REFUSES a
24
+ * key that is still there (`X_STORAGE_QUARANTINED`), and the app's scan job calls
25
+ * `releaseQuarantine` on a verdict of clean. Inside `pending/` on purpose: an upload nobody ever
26
+ * scanned is still an orphan, so `sweepOrphans` collects it with no second prefix to walk.
27
+ */
28
+ export const QUARANTINE_SEGMENT = 'quarantine';
29
+
30
+ export interface AttachmentTarget {
31
+ /** Entity name as the app declares it — `post`, `user`. Exactly one key segment. */
32
+ readonly entity: string;
33
+ readonly id: string;
34
+ /** The field on that entity the file belongs to — `avatar`, `attachments`. */
35
+ readonly field: string;
36
+ }
37
+
38
+ // Deliberately narrow. A filename is client-supplied, and an extension is decoration: the stored
39
+ // content type is the SNIFFED one (`validateUpload`), so anything longer or stranger is dropped
40
+ // rather than sanitised — sanitising is what turns `evil.png.html` into a key that looks safe.
41
+ const EXTENSION = /^\.[a-z0-9]{1,12}$/;
42
+
43
+ export function uploadExtension(filename: string): string {
44
+ const cut = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
45
+ const base = filename.slice(cut + 1);
46
+ const dot = base.lastIndexOf('.');
47
+ const extension = dot <= 0 ? '' : base.slice(dot).toLowerCase();
48
+ return EXTENSION.test(extension) ? extension : '';
49
+ }
50
+
51
+ /**
52
+ * The final key segment: an opaque id plus the surviving extension. The id — never the client's
53
+ * filename — is what makes the key unguessable and collision-free; the original name belongs in
54
+ * the entity row, where it can be displayed without ever being a path.
55
+ */
56
+ export function uploadName(uploadId: string, filename: string): string {
57
+ return `${uploadId}${uploadExtension(filename)}`;
58
+ }
59
+
60
+ /** `org/<orgId>/pending/<name>` — an upload with no row behind it yet. */
61
+ export function pendingKey(orgId: string, name: string): string {
62
+ return scopedKey(orgId, PENDING_SEGMENT, name);
63
+ }
64
+
65
+ export function pendingPrefix(orgId: string): string {
66
+ return `${orgPrefix(orgId)}${PENDING_SEGMENT}/`;
67
+ }
68
+
69
+ /** `org/<orgId>/<entity>/<id>/<field>/` — every file one field of one row owns. */
70
+ export function attachmentPrefix(orgId: string, target: AttachmentTarget): string {
71
+ return `${scopedKey(orgId, target.entity, target.id, target.field)}/`;
72
+ }
73
+
74
+ export function attachmentKey(orgId: string, target: AttachmentTarget, name: string): string {
75
+ return scopedKey(orgId, target.entity, target.id, target.field, name);
76
+ }
77
+
78
+ /** `org/<orgId>/pending/quarantine/<name>` — uploaded, validated, and not yet cleared to use. */
79
+ export function quarantineKey(orgId: string, name: string): string {
80
+ return scopedKey(orgId, PENDING_SEGMENT, QUARANTINE_SEGMENT, name);
81
+ }
82
+
83
+ export function quarantinePrefix(orgId: string): string {
84
+ return `${pendingPrefix(orgId)}${QUARANTINE_SEGMENT}/`;
85
+ }
86
+
87
+ export const isPendingKey = (key: string, orgId: string): boolean =>
88
+ key.startsWith(pendingPrefix(orgId));
89
+
90
+ /** True while nothing has cleared the key. `promoteAttachment` refuses exactly this. */
91
+ export const isQuarantinedKey = (key: string, orgId: string): boolean =>
92
+ key.startsWith(quarantinePrefix(orgId));
93
+
94
+ export interface ReleaseQuarantineInput {
95
+ readonly disk: StorageDriver;
96
+ /** A key under `quarantinePrefix(orgId)`. */
97
+ readonly key: string;
98
+ readonly orgId: string;
99
+ }
100
+
101
+ /**
102
+ * The app's scan said clean: move the object out of quarantine and onto the ordinary `pending/`
103
+ * key, which `promoteAttachment` will accept. Copy then delete, for the reason promotion does —
104
+ * a delete that ran first loses the bytes on a failed write.
105
+ *
106
+ * Releasing a key that is not quarantined returns it untouched rather than copying it onto
107
+ * itself: a scan job that retries after a crash must not be a second round trip, and a `pending/`
108
+ * key is already the released state.
109
+ */
110
+ export async function releaseQuarantine(input: ReleaseQuarantineInput): Promise<string> {
111
+ if (!isWithinOrg(input.key, input.orgId)) throw orgMismatch(input.key, input.orgId);
112
+ if (!isQuarantinedKey(input.key, input.orgId)) return input.key;
113
+ const name = input.key.slice(input.key.lastIndexOf('/') + 1);
114
+ const released = pendingKey(input.orgId, name);
115
+ await input.disk.copy(input.key, released);
116
+ await input.disk.delete(input.key);
117
+ return released;
118
+ }
119
+
120
+ export interface PromoteAttachmentInput {
121
+ readonly disk: StorageDriver;
122
+ /** A key `grantUpload` minted with no `target`. */
123
+ readonly key: string;
124
+ readonly orgId: string;
125
+ readonly target: AttachmentTarget;
126
+ }
127
+
128
+ /**
129
+ * Move a pending upload onto the row that now exists. Copy first, delete second: a delete that
130
+ * ran first would lose the bytes on a failed write, and the sweep would have collected them
131
+ * anyway. Re-promoting an already-promoted key is `X_STORAGE_NOT_FOUND` from the copy, never a
132
+ * silent no-op that leaves the caller believing a file is attached.
133
+ *
134
+ * The copy is `disk.copy`, not `get` + `put`: promotion used to download the whole object into
135
+ * this process and upload it again, so attaching a 500MB file moved a gigabyte through the pod
136
+ * for a rename. `copy` never touches the app's heap on either driver.
137
+ */
138
+ export async function promoteAttachment(input: PromoteAttachmentInput): Promise<StorageObject> {
139
+ if (!isWithinOrg(input.key, input.orgId)) throw orgMismatch(input.key, input.orgId);
140
+ if (isQuarantinedKey(input.key, input.orgId)) throw quarantined(input.key, input.orgId);
141
+ const name = input.key.slice(input.key.lastIndexOf('/') + 1);
142
+ const attached = attachmentKey(input.orgId, input.target, name);
143
+ const object = await input.disk.copy(input.key, attached);
144
+ await input.disk.delete(input.key);
145
+ return object;
146
+ }
147
+
148
+ export interface SweepOrphansInput {
149
+ readonly disk: StorageDriver;
150
+ readonly orgId: string;
151
+ /** Age past which an unattached upload is an orphan. Longer than any form can stay open. */
152
+ readonly olderThanMs: number;
153
+ readonly clock?: Clock | undefined;
154
+ /**
155
+ * Return `true` to spare a key the app can still account for. Absent spares nothing, because
156
+ * a pending key by definition has no row pointing at it — override only when the app parks
157
+ * a reference somewhere the prefix cannot express.
158
+ */
159
+ readonly keep?: ((object: StorageListEntry) => boolean | Promise<boolean>) | undefined;
160
+ }
161
+
162
+ /** One key the sweep tried to delete and could not, with the disk's own words for why. */
163
+ export interface SweepFailure {
164
+ readonly key: string;
165
+ readonly reason: string;
166
+ }
167
+
168
+ /**
169
+ * Two lists, because one cannot be wrong. A sweep that answered a single array of "deleted" keys
170
+ * put every refusal in it: a GDPR erasure over 200 objects against a bucket whose policy had lost
171
+ * `s3:DeleteObject` returned all 200 as deleted, and the compliance report said the data was gone.
172
+ */
173
+ export interface SweepResult {
174
+ readonly deleted: readonly string[];
175
+ readonly failed: readonly SweepFailure[];
176
+ }
177
+
178
+ /**
179
+ * Delete every unattached upload past the window, and answer with what went and what did not.
180
+ * Scoped to one org and to the `pending/` prefix — quarantine included, since it lives inside
181
+ * that prefix: a sweep that could reach an attached key is a job that deletes production data
182
+ * the first time an app forgets to promote something.
183
+ *
184
+ * A refusal is recorded and the sweep continues. Stopping at the first one would leave the caller
185
+ * unable to distinguish "one key is stuck" from "this whole disk denies deletes", and a caller
186
+ * with `failed` non-empty already knows not to report the batch as erased.
187
+ */
188
+ export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResult> {
189
+ const clock = input.clock ?? systemClock;
190
+ const cutoff = clock.now().getTime() - input.olderThanMs;
191
+ const prefix = pendingPrefix(input.orgId);
192
+ const deleted: string[] = [];
193
+ const failed: SweepFailure[] = [];
194
+ let cursor: string | undefined;
195
+ do {
196
+ const page: ListPage = await input.disk.list({
197
+ prefix,
198
+ ...(cursor === undefined ? {} : { cursor }),
199
+ });
200
+ for (const object of page.objects) {
201
+ if (object.lastModified.getTime() > cutoff) continue;
202
+ if ((await input.keep?.(object)) === true) continue;
203
+ try {
204
+ await input.disk.delete(object.key);
205
+ deleted.push(object.key);
206
+ } catch (error) {
207
+ failed.push({ key: object.key, reason: renderThrowable(error) });
208
+ }
209
+ }
210
+ cursor = page.truncated ? page.cursor : undefined;
211
+ } while (cursor !== undefined);
212
+ return { deleted, failed };
213
+ }
@@ -3,7 +3,7 @@
3
3
  // Content type, etag and user metadata live in a sidecar under `.meta/`: a POSIX file has
4
4
  // nowhere to keep them, and `get` must round-trip exactly what `put` was handed.
5
5
 
6
- import { type Clock, systemClock } from '@ultimat3/core';
6
+ import { type Clock, isLocal, resolveEnvironment, stringField, systemClock } from '@ultimat3/core';
7
7
  import {
8
8
  DEFAULT_CONTENT_TYPE,
9
9
  DEFAULT_LIST_LIMIT,
@@ -14,18 +14,49 @@ import {
14
14
  type SignedUrlOptions,
15
15
  type StorageBody,
16
16
  type StorageDriver,
17
+ type StorageListEntry,
17
18
  type StorageObject,
18
19
  type StorageRead,
19
20
  sha256Base64,
20
21
  toBytes,
21
22
  } from './driver';
22
- import { checksumMismatch, objectNotFound } from './errors';
23
- import { assertSafeKey } from './path';
23
+ import {
24
+ checksumMismatch,
25
+ deleteFailed,
26
+ listFailed,
27
+ objectNotFound,
28
+ signingSecretMissing,
29
+ storageNotImplemented,
30
+ } from './errors';
31
+ import { assertSafeKey, META_DIR } from './path';
24
32
  import { buildSignedUrl } from './signed-url';
33
+ import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
25
34
 
26
- const META_DIR = '.meta';
27
35
  const DRIVER_NAME = 'local';
28
36
 
37
+ /**
38
+ * The dev-only fallback signing key. A literal, not a per-process random one, so a restart does
39
+ * not invalidate every URL `x dev` handed out — and published in this repo, which is exactly why
40
+ * `localDriver` refuses to use it outside a development or test environment.
41
+ */
42
+ export const DEV_SIGNING_SECRET = 'ultimate-dev-signing-secret';
43
+
44
+ /** The env key production must set. Named once, read by the driver and by the predicate below. */
45
+ export const STORAGE_SIGNING_SECRET_KEY = 'STORAGE_SIGNING_SECRET';
46
+
47
+ /**
48
+ * True while a local disk built without an explicit `signingSecret` would sign with the shipped
49
+ * development key — `x doctor` reports it, exactly as it reports `usesDevCursorSecret()`.
50
+ *
51
+ * Reads the environment, not a driver instance: this is the same question `x doctor` asks about
52
+ * the cursor secret, and a disk handed an explicit `signingSecret` in `app.config.ts` never
53
+ * consults the variable at all.
54
+ */
55
+ export function usesDevStorageSecret(): boolean {
56
+ const configured = process.env[STORAGE_SIGNING_SECRET_KEY];
57
+ return configured === undefined || configured === '' || configured === DEV_SIGNING_SECRET;
58
+ }
59
+
29
60
  export interface LocalDriverOptions {
30
61
  /** Directory the disk owns outright. Created on first write. */
31
62
  readonly root: string;
@@ -34,6 +65,13 @@ export interface LocalDriverOptions {
34
65
  /** Route prefix the dev server serves signed URLs from. */
35
66
  readonly baseUrl?: string | undefined;
36
67
  readonly clock?: Clock | undefined;
68
+ /**
69
+ * Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
70
+ * upload policy's ceiling — the same number for the same fact. The dev disk enforces it for
71
+ * the same reason production does: a limit an app only meets in production is a limit it
72
+ * discovers by being OOM-killed.
73
+ */
74
+ readonly maxPutBytes?: number | undefined;
37
75
  }
38
76
 
39
77
  interface Sidecar {
@@ -43,23 +81,70 @@ interface Sidecar {
43
81
  readonly metadata?: Readonly<Record<string, string>> | undefined;
44
82
  }
45
83
 
84
+ // `!Array.isArray` is the load-bearing clause, matching `isPlainObject` in
85
+ // `@ultimat3/schema`'s `builder.ts`: `typeof [] === 'object'` and every value of `['a','b']` is a
86
+ // string, so an array in the `metadata` slot was handed back through `head()`/`get()` as object
87
+ // metadata — against a `Record<string, string>` every reader downstream is typed on.
88
+ const isStringRecord = (value: unknown): value is Readonly<Record<string, string>> =>
89
+ typeof value === 'object' &&
90
+ value !== null &&
91
+ !Array.isArray(value) &&
92
+ Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'string');
93
+
46
94
  function parseSidecar(raw: unknown): Sidecar | undefined {
47
95
  if (typeof raw !== 'object' || raw === null) return undefined;
48
96
  const record = raw as Record<string, unknown>;
49
97
  const contentType = record['contentType'];
50
98
  const etag = record['etag'];
51
99
  if (typeof contentType !== 'string' || typeof etag !== 'string') return undefined;
52
- return { contentType, etag };
100
+ // `put()` writes cacheControl/metadata into the same sidecar (below) — dropping them here
101
+ // silently truncated what was just written, even though `Sidecar` itself declares both.
102
+ const cacheControl = record['cacheControl'];
103
+ const metadata = record['metadata'];
104
+ return {
105
+ contentType,
106
+ etag,
107
+ ...(typeof cacheControl === 'string' ? { cacheControl } : {}),
108
+ ...(isStringRecord(metadata) ? { metadata } : {}),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * A POSIX file is not encrypted at rest by this driver, and recording the request in the sidecar
114
+ * would answer a security review with a field the disk never honoured. Refused on the DEV disk
115
+ * too, and deliberately: a `put()` that succeeds locally and throws in production is a gap an app
116
+ * meets on the worst day, and both drivers refusing is one rule instead of two.
117
+ */
118
+ function refuseUnsupportedPut(putOptions?: PutOptions): void {
119
+ if (putOptions?.serverSideEncryption === undefined) return;
120
+ throw storageNotImplemented(
121
+ 'server-side encryption on the local driver (it writes plain files under one root)',
122
+ 'drop serverSideEncryption from put(), and encrypt the disk itself — an s3Driver over a bucket with a default KMS rule, or a LUKS/FileVault volume under `root`',
123
+ );
53
124
  }
54
125
 
126
+ /** `ENOENT` is the one delete failure that means "already in the desired state". */
127
+ const isMissingFile = (error: unknown): boolean => stringField(error, 'code') === 'ENOENT';
128
+
55
129
  export function localDriver(options: LocalDriverOptions): StorageDriver {
56
130
  const root = options.root.replace(/\/+$/, '');
131
+ const maxPutBytes = options.maxPutBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
57
132
  const clock = options.clock ?? systemClock;
58
133
  const baseUrl = options.baseUrl ?? `/_storage/${DRIVER_NAME}`;
59
- // A dev disk must work with zero config; a production disk that forgets the secret still
60
- // gets a *shared* secret, never a per-process random one that breaks on restart.
61
- const secret =
62
- options.signingSecret ?? process.env['STORAGE_SIGNING_SECRET'] ?? 'ultimate-dev-signing-secret';
134
+ // A dev disk must work with zero config. Outside development the fallback is refused rather
135
+ // than used: the literal is published, so signing with it hands every reader the power to mint
136
+ // a PUT for any key with any size and type limit — which `acceptSignedUpload` then trusts over
137
+ // the app's own `uploadPolicy`. Refused HERE, at construction, so the boot fails rather than
138
+ // the first upload.
139
+ // The published literal counts as no secret at all, whichever way it arrives: an env var or an
140
+ // `app.config.ts` that pasted it in signs exactly as weakly as the fallback does.
141
+ const supplied = options.signingSecret ?? process.env[STORAGE_SIGNING_SECRET_KEY];
142
+ const configured =
143
+ supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
144
+ ? undefined
145
+ : supplied;
146
+ if (configured === undefined && !isLocal()) throw signingSecretMissing(resolveEnvironment());
147
+ const secret = configured ?? DEV_SIGNING_SECRET;
63
148
 
64
149
  const filePath = (key: string): string => `${root}/${key}`;
65
150
  const metaPath = (key: string): string => `${root}/${META_DIR}/${key}.json`;
@@ -75,27 +160,55 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
75
160
  }
76
161
  };
77
162
 
78
- const head = async (key: string): Promise<StorageObject | undefined> => {
163
+ // No `contentType` fallback: the sidecar is the only thing that knows, so a missing one means
164
+ // this driver does not know either — exactly what the s3 driver's `list()` reports. `get()`
165
+ // fills the default below, because a `StorageObject` promises a type and a read has one.
166
+ //
167
+ // `hash` is the ONLY thing that reads the object's bytes, and it defaults off. The etag used to
168
+ // be computed unconditionally when the sidecar was missing, under a comment saying "`list()`
169
+ // must not read every file it lists" — which is exactly what `list()` then did, one whole
170
+ // object at a time, sequentially, for every sidecar-less key on the disk (a `put()` that died
171
+ // between its two writes leaves one). `copy()` inherited it too, so a copy documented as never
172
+ // routing bytes through the heap buffered the whole source. A listing that cannot know an etag
173
+ // reports `''`, which is what the s3 listing already answers for a provider that returns none.
174
+ const head = async (key: string, hash = false): Promise<StorageListEntry | undefined> => {
79
175
  const file = Bun.file(filePath(key));
80
176
  if (!(await file.exists())) return undefined;
81
177
  const sidecar = await readSidecar(key);
82
- // Only hash when the sidecar is gone `list()` must not read every file it lists.
83
- const etag = sidecar?.etag ?? etagOf(new Uint8Array(await file.arrayBuffer()));
178
+ const etag = sidecar?.etag ?? (hash ? etagOf(new Uint8Array(await file.arrayBuffer())) : '');
84
179
  return {
85
180
  key,
86
181
  size: file.size,
87
- contentType: sidecar?.contentType ?? DEFAULT_CONTENT_TYPE,
88
182
  etag,
89
183
  lastModified: new Date(file.lastModified),
184
+ ...(sidecar?.contentType === undefined ? {} : { contentType: sidecar.contentType }),
185
+ ...(sidecar?.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
186
+ ...(sidecar?.metadata === undefined ? {} : { metadata: sidecar.metadata }),
90
187
  };
91
188
  };
92
189
 
190
+ /** Removes one path, or reports WHY it could not — a swallowed refusal is a false erasure. */
191
+ const removeIfPresent = async (path: string, key: string): Promise<void> => {
192
+ try {
193
+ await Bun.file(path).delete();
194
+ } catch (error) {
195
+ if (isMissingFile(error)) return;
196
+ throw deleteFailed(
197
+ DRIVER_NAME,
198
+ key,
199
+ error,
200
+ `make the disk root writable by this process, then retry: ls -ld ${root} && rm -f ${path}`,
201
+ );
202
+ }
203
+ };
204
+
93
205
  return {
94
206
  name: DRIVER_NAME,
95
207
 
96
208
  async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
97
209
  const safe = assertSafeKey(key);
98
- const bytes = await toBytes(body);
210
+ refuseUnsupportedPut(putOptions);
211
+ const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
99
212
  const claimed = putOptions?.checksum;
100
213
  if (claimed !== undefined) {
101
214
  const actual = sha256Base64(bytes);
@@ -115,15 +228,26 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
115
228
  contentType: sidecar.contentType,
116
229
  etag: sidecar.etag,
117
230
  lastModified: clock.now(),
231
+ ...(sidecar.cacheControl === undefined ? {} : { cacheControl: sidecar.cacheControl }),
232
+ ...(sidecar.metadata === undefined ? {} : { metadata: sidecar.metadata }),
118
233
  };
119
234
  },
120
235
 
121
236
  async get(key: string): Promise<StorageRead> {
122
237
  const safe = assertSafeKey(key);
123
- const object = await head(safe);
124
- if (object === undefined) throw objectNotFound(DRIVER_NAME, safe);
238
+ const entry = await head(safe);
239
+ if (entry === undefined) throw objectNotFound(DRIVER_NAME, safe);
125
240
  const bytes = new Uint8Array(await Bun.file(filePath(safe)).arrayBuffer());
126
- return { object, bytes };
241
+ return {
242
+ object: {
243
+ ...entry,
244
+ contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
245
+ // Hashed HERE and not inside `head`, so a sidecar-less object is read exactly once: a
246
+ // `get()` already holds every byte, and `head(key, true)` would have read them again.
247
+ etag: entry.etag === '' ? etagOf(bytes) : entry.etag,
248
+ },
249
+ bytes,
250
+ };
127
251
  },
128
252
 
129
253
  async stream(key: string): Promise<ReadableStream<Uint8Array>> {
@@ -133,15 +257,38 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
133
257
  return file.stream();
134
258
  },
135
259
 
260
+ /** A real file copy — `Bun.write` from a `BunFile` never routes the bytes through the heap. */
261
+ async copy(from: string, to: string): Promise<StorageObject> {
262
+ const source = assertSafeKey(from);
263
+ const destination = assertSafeKey(to);
264
+ // `hash: true` — the destination gets a sidecar, and a sidecar carrying `etag: ''` is a
265
+ // durable lie every later `get()` of the copy would trust. The read is bounded to the one
266
+ // case the source has no sidecar of its own; the common path still touches no bytes.
267
+ const entry = await head(source, true);
268
+ if (entry === undefined) throw objectNotFound(DRIVER_NAME, source);
269
+ await Bun.write(filePath(destination), Bun.file(filePath(source)));
270
+ const sidecar: Sidecar = {
271
+ contentType: entry.contentType ?? DEFAULT_CONTENT_TYPE,
272
+ etag: entry.etag,
273
+ cacheControl: entry.cacheControl,
274
+ metadata: entry.metadata,
275
+ };
276
+ await Bun.write(metaPath(destination), JSON.stringify(sidecar));
277
+ return {
278
+ ...entry,
279
+ key: destination,
280
+ contentType: sidecar.contentType,
281
+ lastModified: clock.now(),
282
+ };
283
+ },
284
+
136
285
  async delete(key: string): Promise<void> {
137
286
  const safe = assertSafeKey(key);
138
- // Idempotent by contract: a missing key is already in the desired state.
139
- await Bun.file(filePath(safe))
140
- .delete()
141
- .catch(() => undefined);
142
- await Bun.file(metaPath(safe))
143
- .delete()
144
- .catch(() => undefined);
287
+ // Idempotent by contract: a missing key is already in the desired state. A REFUSED unlink
288
+ // is not — a read-only mount or a root this process cannot write reports the bytes gone
289
+ // when they are still on disk, which is the one lie an erasure sweep must never repeat.
290
+ await removeIfPresent(filePath(safe), safe);
291
+ await removeIfPresent(metaPath(safe), safe);
145
292
  },
146
293
 
147
294
  async exists(key: string): Promise<boolean> {
@@ -162,13 +309,24 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
162
309
  if (cursor !== undefined && key <= cursor) continue;
163
310
  keys.push(key);
164
311
  }
165
- } catch {
312
+ } catch (error) {
166
313
  // A disk nobody has written to yet has no directory: an empty listing, not an error.
167
- return { objects: [], truncated: false };
314
+ if (isMissingFile(error)) return { objects: [], truncated: false };
315
+ // Everything else is a refusal, and a bare `catch` reported all of them as "this disk is
316
+ // empty" — `EACCES` on the root, `ENOTDIR` on a root that is a file, an I/O error on the
317
+ // mount. `sweepOrphans` walks `list()`, so that swallow certified an unreadable prefix as
318
+ // having no orphans: the same false report `delete()`'s `.catch(() => undefined)` used to
319
+ // make, one call to the left.
320
+ throw listFailed(
321
+ DRIVER_NAME,
322
+ prefix,
323
+ error,
324
+ `make the disk root readable by this process, then retry: ls -ld ${root}`,
325
+ );
168
326
  }
169
327
  keys.sort();
170
328
  const page = keys.slice(0, limit);
171
- const objects: StorageObject[] = [];
329
+ const objects: StorageListEntry[] = [];
172
330
  for (const key of page) {
173
331
  const object = await head(key);
174
332
  if (object !== undefined) objects.push(object);