@ultimat3/storage 23.0.0 → 25.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 +69 -42
- package/README.md +165 -15
- package/package.json +3 -3
- package/src/accept.ts +3 -6
- package/src/attachment.ts +46 -16
- package/src/driver-local-read.ts +114 -0
- package/src/driver-local-sidecar.ts +96 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +166 -188
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +109 -25
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +180 -167
- package/src/driver.ts +98 -19
- package/src/errors.ts +112 -13
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +29 -16
- package/src/iso-bmff.ts +50 -0
- package/src/object-lock.ts +173 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -12
- package/src/signing-secret.ts +76 -0
- package/src/upload.ts +82 -5
package/src/driver-memory.ts
CHANGED
|
@@ -1,38 +1,56 @@
|
|
|
1
1
|
// Single responsibility: the disk a TEST holds — a `StorageDriver` over one `Map`, answering every
|
|
2
|
-
// question `localDriver` answers and refusing what it refuses
|
|
3
|
-
// create or clean up. Not a deployment's disk: a
|
|
2
|
+
// question `localDriver` answers and refusing what it refuses (a key that is another key's path
|
|
3
|
+
// included), with nothing on a filesystem to create or clean up. Not a deployment's disk: a
|
|
4
|
+
// restart is every object gone.
|
|
4
5
|
//
|
|
5
6
|
// It exists because the alternative was written by hand in every suite that needed a bucket: a
|
|
6
7
|
// fake with three methods and five `unsupported()` stubs, each a slightly different contract.
|
|
7
8
|
|
|
8
|
-
import { type Clock, finiteCount, systemClock } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
|
+
type Clock,
|
|
11
|
+
finiteCount,
|
|
12
|
+
NotImplementedError,
|
|
13
|
+
type Page,
|
|
14
|
+
pageOf,
|
|
15
|
+
systemClock,
|
|
16
|
+
} from '@ultimat3/core';
|
|
17
|
+
import {
|
|
18
|
+
assertListOptions,
|
|
19
|
+
assertPutOptions,
|
|
10
20
|
DEFAULT_CONTENT_TYPE,
|
|
11
21
|
etagOf,
|
|
12
22
|
type ListOptions,
|
|
13
|
-
type ListPage,
|
|
14
23
|
type PutOptions,
|
|
15
24
|
resolveListLimit,
|
|
16
25
|
type SignedUrlOptions,
|
|
17
26
|
type StorageBody,
|
|
18
27
|
type StorageDriver,
|
|
28
|
+
type StorageListEntry,
|
|
19
29
|
type StorageObject,
|
|
20
30
|
type StorageRead,
|
|
21
31
|
sha256Base64,
|
|
22
32
|
toBytes,
|
|
23
33
|
} from './driver';
|
|
24
34
|
import type { LocalDriverOptions } from './driver-local';
|
|
25
|
-
import {
|
|
26
|
-
import { checksumMismatch,
|
|
35
|
+
import { keyInTheWay } from './driver-memory-conflict';
|
|
36
|
+
import { checksumMismatch, getTooLarge, keyConflict, objectNotFound } from './errors';
|
|
37
|
+
import {
|
|
38
|
+
assertObjectLockOptions,
|
|
39
|
+
isLocked,
|
|
40
|
+
type ObjectLock,
|
|
41
|
+
ObjectLockedError,
|
|
42
|
+
requestedLock,
|
|
43
|
+
} from './object-lock';
|
|
27
44
|
import { assertSafeKey } from './path';
|
|
28
45
|
import type { SignedUrlVerification } from './signed-url';
|
|
29
46
|
import { buildSignedUrl, signedUrlBaseFor, verifySignedUrl } from './signed-url';
|
|
47
|
+
import { resolveSigningSecret } from './signing-secret';
|
|
30
48
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
31
49
|
|
|
32
50
|
const DRIVER_NAME = 'memory';
|
|
33
51
|
|
|
34
52
|
/** `localDriver`'s options, minus the directory there is none of. */
|
|
35
|
-
export type
|
|
53
|
+
export type MemoryStorageDriverOptions = Omit<LocalDriverOptions, 'root'>;
|
|
36
54
|
|
|
37
55
|
export interface MemoryStorageDriver extends StorageDriver {
|
|
38
56
|
/**
|
|
@@ -45,6 +63,8 @@ export interface MemoryStorageDriver extends StorageDriver {
|
|
|
45
63
|
interface Stored {
|
|
46
64
|
readonly bytes: Uint8Array;
|
|
47
65
|
readonly object: StorageObject;
|
|
66
|
+
/** The local disk's sidecar lock, held here instead. */
|
|
67
|
+
readonly lock?: ObjectLock | undefined;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
/**
|
|
@@ -54,25 +74,55 @@ interface Stored {
|
|
|
54
74
|
*/
|
|
55
75
|
const snapshot = (object: StorageObject): StorageObject => ({
|
|
56
76
|
...object,
|
|
57
|
-
|
|
77
|
+
...(object.lastModified === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { lastModified: new Date(object.lastModified.getTime()) }),
|
|
58
80
|
...(object.metadata === undefined ? {} : { metadata: { ...object.metadata } }),
|
|
59
81
|
});
|
|
60
82
|
|
|
61
|
-
export function
|
|
83
|
+
export function memoryStorageDriver(options: MemoryStorageDriverOptions = {}): MemoryStorageDriver {
|
|
62
84
|
const maxPutBytes = finiteCount(
|
|
63
85
|
'the memory disk driver',
|
|
64
86
|
'maxPutBytes',
|
|
65
87
|
options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
|
|
66
88
|
1,
|
|
67
89
|
);
|
|
90
|
+
const maxGetBytes = finiteCount(
|
|
91
|
+
'the memory disk driver',
|
|
92
|
+
'maxGetBytes',
|
|
93
|
+
options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
|
|
94
|
+
1,
|
|
95
|
+
);
|
|
68
96
|
const clock = options.clock ?? systemClock;
|
|
69
97
|
let baseUrl = options.baseUrl ?? signedUrlBaseFor(DRIVER_NAME);
|
|
70
98
|
const secret = resolveSigningSecret(DRIVER_NAME, options);
|
|
71
99
|
const stored = new Map<string, Stored>();
|
|
100
|
+
// The name a registry gave this disk, so a miss names `disk('<it>')` — the call the author wrote.
|
|
101
|
+
let registered: string = DRIVER_NAME;
|
|
102
|
+
|
|
103
|
+
/** The lock that holds NOW, or `undefined` — the local disk's rule, read off the map. */
|
|
104
|
+
const lockAt = (key: string): ObjectLock | undefined => {
|
|
105
|
+
const lock = stored.get(key)?.lock;
|
|
106
|
+
return isLocked(lock, clock.now()) ? lock : undefined;
|
|
107
|
+
};
|
|
108
|
+
const refuseOverwrite = (key: string, action: 'overwrite' | 'copy onto'): void => {
|
|
109
|
+
const lock = lockAt(key);
|
|
110
|
+
if (lock !== undefined) throw new ObjectLockedError({ disk: registered, key, action, lock });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The local disk's path rule: a key cannot be another key's directory (`X_STORAGE_KEY_CONFLICT`).
|
|
115
|
+
* Emulated, not inherited — a `Map` holds both — because a suite on this disk stands in for the
|
|
116
|
+
* dev disk, and a key layout that passes here and fails in `x dev` is a gap found late.
|
|
117
|
+
*/
|
|
118
|
+
const refuseConflict = (key: string): void => {
|
|
119
|
+
const blocking = keyInTheWay(stored, key);
|
|
120
|
+
if (blocking !== undefined) throw keyConflict(registered, key, blocking);
|
|
121
|
+
};
|
|
72
122
|
|
|
73
123
|
const found = (key: string): Stored => {
|
|
74
124
|
const hit = stored.get(key);
|
|
75
|
-
if (hit === undefined) throw objectNotFound(
|
|
125
|
+
if (hit === undefined) throw objectNotFound(registered, key);
|
|
76
126
|
return hit;
|
|
77
127
|
};
|
|
78
128
|
|
|
@@ -84,6 +134,7 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
84
134
|
},
|
|
85
135
|
|
|
86
136
|
registerAs(diskName: string): void {
|
|
137
|
+
registered = diskName;
|
|
87
138
|
if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
|
|
88
139
|
},
|
|
89
140
|
|
|
@@ -96,11 +147,14 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
96
147
|
// The same refusal both shipped disks make: a `put()` that succeeds in a test and throws in
|
|
97
148
|
// production is a gap an app meets on the worst day.
|
|
98
149
|
if (putOptions?.serverSideEncryption !== undefined) {
|
|
99
|
-
throw
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
150
|
+
throw new NotImplementedError({
|
|
151
|
+
cause:
|
|
152
|
+
'server-side encryption on the memory driver (it holds bytes in a Map) is not implemented by this driver — drop serverSideEncryption from put() and encrypt the disk itself: an s3Driver over a bucket with a default KMS rule',
|
|
153
|
+
fix: 'put(key, body)',
|
|
154
|
+
});
|
|
103
155
|
}
|
|
156
|
+
assertPutOptions(DRIVER_NAME, putOptions);
|
|
157
|
+
assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
|
|
104
158
|
const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
|
|
105
159
|
const claimed = putOptions?.checksum;
|
|
106
160
|
if (claimed !== undefined) {
|
|
@@ -118,13 +172,31 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
118
172
|
: { cacheControl: putOptions.cacheControl }),
|
|
119
173
|
...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
|
|
120
174
|
};
|
|
175
|
+
// Checked AFTER the body is read, with no await between check and write: the map is the
|
|
176
|
+
// queue the local disk needs a `keyedQueue` for.
|
|
177
|
+
refuseConflict(safe);
|
|
178
|
+
refuseOverwrite(safe, 'overwrite');
|
|
121
179
|
// A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
|
|
122
|
-
stored.set(safe, {
|
|
180
|
+
stored.set(safe, {
|
|
181
|
+
bytes: bytes.slice(),
|
|
182
|
+
object: snapshot(object),
|
|
183
|
+
lock: requestedLock(putOptions),
|
|
184
|
+
});
|
|
123
185
|
return snapshot(object);
|
|
124
186
|
},
|
|
125
187
|
|
|
188
|
+
async stat(key: string): Promise<StorageObject | undefined> {
|
|
189
|
+
const hit = stored.get(assertSafeKey(key));
|
|
190
|
+
return hit && snapshot(hit.object);
|
|
191
|
+
},
|
|
192
|
+
|
|
126
193
|
async get(key: string): Promise<StorageRead> {
|
|
127
|
-
const
|
|
194
|
+
const safe = assertSafeKey(key);
|
|
195
|
+
const hit = found(safe);
|
|
196
|
+
// The local disk's refusal, so a suite on this disk meets the ceiling production has.
|
|
197
|
+
if (hit.bytes.byteLength > maxGetBytes) {
|
|
198
|
+
throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
|
|
199
|
+
}
|
|
128
200
|
return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
|
|
129
201
|
},
|
|
130
202
|
|
|
@@ -134,8 +206,12 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
134
206
|
},
|
|
135
207
|
|
|
136
208
|
async copy(from: string, to: string): Promise<StorageObject> {
|
|
137
|
-
|
|
138
|
-
|
|
209
|
+
// Both keys before either lookup, as the other two disks do: an unsafe destination is the
|
|
210
|
+
// refusal whether or not the source exists.
|
|
211
|
+
const [from_, destination] = [assertSafeKey(from), assertSafeKey(to)];
|
|
212
|
+
const source = found(from_);
|
|
213
|
+
refuseConflict(destination);
|
|
214
|
+
refuseOverwrite(destination, 'copy onto');
|
|
139
215
|
const object: StorageObject = {
|
|
140
216
|
...source.object,
|
|
141
217
|
key: destination,
|
|
@@ -146,17 +222,28 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
146
222
|
},
|
|
147
223
|
|
|
148
224
|
async delete(key: string): Promise<void> {
|
|
149
|
-
|
|
225
|
+
const safe = assertSafeKey(key);
|
|
226
|
+
const lock = lockAt(safe);
|
|
227
|
+
if (lock !== undefined) {
|
|
228
|
+
throw new ObjectLockedError({ disk: registered, key: safe, action: 'delete', lock });
|
|
229
|
+
}
|
|
230
|
+
stored.delete(safe);
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async retentionOf(key: string): Promise<ObjectLock> {
|
|
234
|
+
const lock = found(assertSafeKey(key)).lock;
|
|
235
|
+
return lock === undefined ? { legalHold: false } : structuredClone(lock);
|
|
150
236
|
},
|
|
151
237
|
|
|
152
238
|
async exists(key: string): Promise<boolean> {
|
|
153
239
|
return stored.has(assertSafeKey(key));
|
|
154
240
|
},
|
|
155
241
|
|
|
156
|
-
async list(listOptions?: ListOptions): Promise<
|
|
242
|
+
async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
|
|
157
243
|
const prefix = listOptions?.prefix ?? '';
|
|
244
|
+
assertListOptions(listOptions);
|
|
158
245
|
const limit = resolveListLimit(listOptions?.limit);
|
|
159
|
-
const cursor = listOptions?.cursor;
|
|
246
|
+
const cursor = listOptions?.cursor ?? undefined;
|
|
160
247
|
// Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
|
|
161
248
|
const keys = [...stored.keys()]
|
|
162
249
|
.filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
|
|
@@ -166,10 +253,7 @@ export function memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDr
|
|
|
166
253
|
const hit = stored.get(key);
|
|
167
254
|
return hit === undefined ? [] : [snapshot(hit.object)];
|
|
168
255
|
});
|
|
169
|
-
|
|
170
|
-
return keys.length > page.length && last !== undefined
|
|
171
|
-
? { objects, truncated: true, cursor: last }
|
|
172
|
-
: { objects, truncated: false };
|
|
256
|
+
return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
|
|
173
257
|
},
|
|
174
258
|
|
|
175
259
|
async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: does a provider failure mean "there is nothing at that key"? Split from
|
|
2
|
+
// `driver-s3.ts` at its line ceiling; the one reader is that driver's delete and read paths.
|
|
3
|
+
|
|
4
|
+
import { stringField } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
|
|
7
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
8
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
9
|
+
try {
|
|
10
|
+
const held = (value as Record<string, unknown>)[key];
|
|
11
|
+
return typeof held === 'number' ? held : undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The provider codes that mean "there is nothing at that key", and nothing wider. */
|
|
18
|
+
const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
|
|
22
|
+
* credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
|
|
23
|
+
* reported all four as deleted — which is how an erasure sweep certifies data it never removed.
|
|
24
|
+
*
|
|
25
|
+
* Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
|
|
26
|
+
* every field of a value this process did not build is a getter that can throw.
|
|
27
|
+
*/
|
|
28
|
+
export function isAbsentObject(error: unknown): boolean {
|
|
29
|
+
const code = stringField(error, 'code');
|
|
30
|
+
if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
|
|
31
|
+
return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Single responsibility: the s3 disk's view of `Bun.S3Client` — the structural types its methods
|
|
2
|
+
// are called through (so `bun-types` stays out of the public contract and a test injects a fake),
|
|
3
|
+
// and the one place the real client is constructed, lazily, from the disk's options.
|
|
4
|
+
|
|
5
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
6
|
+
import type { S3DriverOptions } from './driver-s3';
|
|
7
|
+
import { requireEnv, resolveS3Target } from './driver-s3-signed';
|
|
8
|
+
|
|
9
|
+
/** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
|
|
10
|
+
export interface S3FileLike {
|
|
11
|
+
/** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
|
|
12
|
+
write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
|
|
13
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
14
|
+
exists(): Promise<boolean>;
|
|
15
|
+
delete(): Promise<void>;
|
|
16
|
+
stream(): ReadableStream<Uint8Array>;
|
|
17
|
+
stat(): Promise<S3StatLike>;
|
|
18
|
+
presign(options: { method?: string; expiresIn?: number; type?: string }): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface S3StatLike {
|
|
22
|
+
readonly size: number;
|
|
23
|
+
readonly type?: string | undefined;
|
|
24
|
+
readonly etag?: string | undefined;
|
|
25
|
+
readonly lastModified?: string | Date | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface S3ListEntryLike {
|
|
29
|
+
readonly key?: string | undefined;
|
|
30
|
+
readonly size?: number | undefined;
|
|
31
|
+
readonly eTag?: string | undefined;
|
|
32
|
+
readonly lastModified?: string | Date | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface S3ListResultLike {
|
|
36
|
+
readonly contents?: readonly S3ListEntryLike[] | undefined;
|
|
37
|
+
readonly isTruncated?: boolean | undefined;
|
|
38
|
+
readonly nextContinuationToken?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface S3ClientLike {
|
|
42
|
+
file(key: string): S3FileLike;
|
|
43
|
+
list(input: {
|
|
44
|
+
prefix?: string;
|
|
45
|
+
maxKeys?: number;
|
|
46
|
+
continuationToken?: string;
|
|
47
|
+
}): Promise<S3ListResultLike>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface S3ClientConstructor {
|
|
51
|
+
new (options: Record<string, unknown>): S3ClientLike;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything but the key pair `Bun.S3Client` is built with — the SAME `resolveS3Target` the signed
|
|
56
|
+
* path reads, handed over explicitly so Bun never consults a second table.
|
|
57
|
+
*/
|
|
58
|
+
export function clientTarget(options: S3DriverOptions): Record<string, unknown> {
|
|
59
|
+
const target = resolveS3Target(options);
|
|
60
|
+
// Bun's flag is the inverse: path style means "not virtual hosted".
|
|
61
|
+
const pathStyle = options.forcePathStyle;
|
|
62
|
+
return {
|
|
63
|
+
bucket: options.bucket,
|
|
64
|
+
...(target.region === undefined ? {} : { region: target.region }),
|
|
65
|
+
...(target.endpoint === undefined ? {} : { endpoint: target.endpoint }),
|
|
66
|
+
...(pathStyle === undefined ? {} : { virtualHostedStyle: !pathStyle }),
|
|
67
|
+
...(target.sessionToken === undefined ? {} : { sessionToken: target.sessionToken }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
72
|
+
if (options.client !== undefined) return options.client;
|
|
73
|
+
if (options.bucket === '') {
|
|
74
|
+
throw new ConfigInvalidError({
|
|
75
|
+
cause: 's3 disk was defined without a bucket',
|
|
76
|
+
fix: 'set storage.disks.<name>.bucket in app.config.ts',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const env = options.env ?? process.env;
|
|
80
|
+
const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
|
|
81
|
+
const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
|
|
82
|
+
const Client = (Bun as unknown as { S3Client?: S3ClientConstructor }).S3Client;
|
|
83
|
+
if (Client === undefined) {
|
|
84
|
+
throw new ConfigInvalidError({
|
|
85
|
+
cause: 'Bun.S3Client is unavailable in this runtime',
|
|
86
|
+
fix: 'upgrade the runtime: bun upgrade # the s3 disk needs bun >= 1.3',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return new Client({
|
|
90
|
+
accessKeyId: requireEnv(env, idVar, secretVar),
|
|
91
|
+
secretAccessKey: requireEnv(env, secretVar, idVar),
|
|
92
|
+
...clientTarget(options),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: the two s3 calls `Bun.S3Client` cannot make — a PUT carrying user
|
|
2
|
+
// metadata, `Cache-Control` and Object Lock headers, and the `?retention` / `?legal-hold` reads
|
|
3
|
+
// behind `retentionOf` — over the signed wire in `driver-s3-signed.ts`, answered in this package's
|
|
4
|
+
// vocabulary. A plain `put()` never comes here: it stays on the Bun client.
|
|
5
|
+
|
|
6
|
+
import { assert, describeValue } from '@ultimat3/core';
|
|
7
|
+
import { DEFAULT_CONTENT_TYPE, type PutOptions } from './driver';
|
|
8
|
+
import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
|
|
9
|
+
import { isRetentionMode, type ObjectLock } from './object-lock';
|
|
10
|
+
|
|
11
|
+
/** RFC 9110 `token` — the only bytes an `x-amz-meta-<name>` header name may carry. */
|
|
12
|
+
const HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
13
|
+
/** Printable ASCII: S3 stores a metadata value as a header, and a CR or a non-ASCII byte is not one. */
|
|
14
|
+
const HEADER_VALUE = /^[\x20-\x7e]*$/;
|
|
15
|
+
|
|
16
|
+
/** The options only the signed PUT can carry. Any one of them routes the put off the Bun client. */
|
|
17
|
+
export function needsSignedPut(options: PutOptions | undefined): boolean {
|
|
18
|
+
return (
|
|
19
|
+
options?.metadata !== undefined ||
|
|
20
|
+
options?.cacheControl !== undefined ||
|
|
21
|
+
options?.retention !== undefined ||
|
|
22
|
+
options?.legalHold !== undefined
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every header the signed PUT sends beyond the signer's own, screened before a byte moves. */
|
|
27
|
+
export function putHeaders(bytes: Uint8Array, options: PutOptions): Record<string, string> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
'content-type': options.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
30
|
+
// Required by S3 on any PUT that sets a retention, and an integrity check on every other one:
|
|
31
|
+
// a body that changed in flight is `BadDigest`, never a stored object.
|
|
32
|
+
'content-md5': new Bun.CryptoHasher('md5').update(bytes).digest('base64'),
|
|
33
|
+
};
|
|
34
|
+
if (options.cacheControl !== undefined) {
|
|
35
|
+
assert(
|
|
36
|
+
HEADER_VALUE.test(options.cacheControl),
|
|
37
|
+
`put() on the s3 disk was given a cacheControl that cannot travel as a header (${describeValue(options.cacheControl)})`,
|
|
38
|
+
"put(key, body, { cacheControl: 'public, max-age=3600' }) — printable ASCII, no line breaks",
|
|
39
|
+
);
|
|
40
|
+
headers['cache-control'] = options.cacheControl;
|
|
41
|
+
}
|
|
42
|
+
for (const [name, value] of Object.entries(options.metadata ?? {})) {
|
|
43
|
+
assert(
|
|
44
|
+
HEADER_TOKEN.test(name) && HEADER_VALUE.test(value),
|
|
45
|
+
`put() on the s3 disk was given metadata that cannot travel as an x-amz-meta-* header (name ${describeValue(name)}, value ${describeValue(value)})`,
|
|
46
|
+
'name each field with letters, digits and hyphens, and keep values printable ASCII — encodeURIComponent(value) anything else',
|
|
47
|
+
);
|
|
48
|
+
headers[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
49
|
+
}
|
|
50
|
+
if (options.retention !== undefined) {
|
|
51
|
+
headers['x-amz-object-lock-mode'] = options.retention.mode;
|
|
52
|
+
headers['x-amz-object-lock-retain-until-date'] = options.retention.retainUntil.toISOString();
|
|
53
|
+
}
|
|
54
|
+
if (options.legalHold !== undefined) {
|
|
55
|
+
headers['x-amz-object-lock-legal-hold'] = options.legalHold ? 'ON' : 'OFF';
|
|
56
|
+
}
|
|
57
|
+
return headers;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The signed PUT. `undefined` on success; the provider's refusal otherwise, for the driver to code. */
|
|
61
|
+
export async function signedPut(
|
|
62
|
+
wire: S3WireOptions,
|
|
63
|
+
key: string,
|
|
64
|
+
bytes: Uint8Array,
|
|
65
|
+
options: PutOptions,
|
|
66
|
+
): Promise<S3Refusal | undefined> {
|
|
67
|
+
const headers = putHeaders(bytes, options);
|
|
68
|
+
const result = await sendSigned(wire, { method: 'PUT', key, headers, body: bytes });
|
|
69
|
+
return result.ok ? undefined : result.refusal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "Nothing locks this", as the provider says it: no configuration on the object, or a bucket made
|
|
74
|
+
* without Object Lock at all. Asked BEFORE the absent-object test, because the first answers 404.
|
|
75
|
+
*/
|
|
76
|
+
function isUnlockedAnswer(refusal: S3Refusal): boolean {
|
|
77
|
+
if (refusal.code === 'NoSuchObjectLockConfiguration') return true;
|
|
78
|
+
if (refusal.code === 'ObjectLockConfigurationNotFoundError') return true;
|
|
79
|
+
return refusal.code === 'InvalidRequest' && /object lock/i.test(refusal.message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type LockRead =
|
|
83
|
+
| { readonly kind: 'lock'; readonly lock: ObjectLock }
|
|
84
|
+
| { readonly kind: 'refused'; readonly refusal: S3Refusal };
|
|
85
|
+
|
|
86
|
+
async function readSubresource(
|
|
87
|
+
wire: S3WireOptions,
|
|
88
|
+
key: string,
|
|
89
|
+
subresource: 'retention' | 'legal-hold',
|
|
90
|
+
): Promise<{ readonly xml: string } | { readonly unlocked: true } | S3Refusal> {
|
|
91
|
+
const result = await sendSigned(wire, { method: 'GET', key, subresource });
|
|
92
|
+
if (result.ok) return { xml: await result.response.text() };
|
|
93
|
+
return isUnlockedAnswer(result.refusal) ? { unlocked: true } : result.refusal;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unreadable = (key: string, what: string): S3Refusal => ({
|
|
97
|
+
name: 'S3Error',
|
|
98
|
+
code: 'UnreadableAnswer',
|
|
99
|
+
message: `the provider's ${what} for "${key}" did not parse`,
|
|
100
|
+
statusCode: 200,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Both halves of the lock, read in parallel. A refusal of either is the answer — a caller that
|
|
105
|
+
* asked "may I delete this?" must not hear "nothing locks it" because one read was denied.
|
|
106
|
+
*/
|
|
107
|
+
export async function readObjectLock(wire: S3WireOptions, key: string): Promise<LockRead> {
|
|
108
|
+
const [retention, hold] = await Promise.all([
|
|
109
|
+
readSubresource(wire, key, 'retention'),
|
|
110
|
+
readSubresource(wire, key, 'legal-hold'),
|
|
111
|
+
]);
|
|
112
|
+
if ('code' in retention) return { kind: 'refused', refusal: retention };
|
|
113
|
+
if ('code' in hold) return { kind: 'refused', refusal: hold };
|
|
114
|
+
let lock: ObjectLock = { legalHold: false };
|
|
115
|
+
if ('xml' in retention) {
|
|
116
|
+
const mode = xmlField(retention.xml, 'Mode');
|
|
117
|
+
const until = new Date(xmlField(retention.xml, 'RetainUntilDate') ?? '');
|
|
118
|
+
if (!isRetentionMode(mode) || !Number.isFinite(until.getTime())) {
|
|
119
|
+
return { kind: 'refused', refusal: unreadable(key, 'retention') };
|
|
120
|
+
}
|
|
121
|
+
lock = { retention: { mode, retainUntil: until }, legalHold: false };
|
|
122
|
+
}
|
|
123
|
+
if ('xml' in hold) {
|
|
124
|
+
const status = xmlField(hold.xml, 'Status');
|
|
125
|
+
if (status !== 'ON' && status !== 'OFF') {
|
|
126
|
+
return { kind: 'refused', refusal: unreadable(key, 'legal hold') };
|
|
127
|
+
}
|
|
128
|
+
lock = { ...lock, legalHold: status === 'ON' };
|
|
129
|
+
}
|
|
130
|
+
return { kind: 'lock', lock };
|
|
131
|
+
}
|