@ultimat3/storage 1.2.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.
- package/CLAUDE.md +169 -0
- package/README.md +156 -7
- package/package.json +3 -2
- package/src/accept.ts +125 -0
- package/src/attachment.ts +213 -0
- package/src/driver-local.ts +185 -27
- package/src/driver-s3-fixture.ts +164 -0
- package/src/driver-s3.ts +142 -25
- package/src/driver.ts +126 -11
- package/src/errors.ts +169 -2
- package/src/grant.ts +100 -0
- package/src/index.ts +63 -1
- package/src/path.ts +23 -1
- package/src/signed-url.ts +34 -16
- package/src/upload-client.ts +172 -0
- package/src/upload.ts +9 -7
|
@@ -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
|
+
}
|
package/src/driver-local.ts
CHANGED
|
@@ -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 {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await
|
|
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:
|
|
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);
|