@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-s3.ts
CHANGED
|
@@ -4,11 +4,22 @@
|
|
|
4
4
|
// The client is built lazily on first use so importing this module never opens a socket, and
|
|
5
5
|
// credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
|
|
6
6
|
|
|
7
|
-
import { ConfigInvalidError, EnvMissingError, finiteCount, stringField } from '@ultimat3/core';
|
|
8
7
|
import {
|
|
8
|
+
type Clock,
|
|
9
|
+
finiteCount,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
NotImplementedError,
|
|
12
|
+
type Page,
|
|
13
|
+
pageOf,
|
|
14
|
+
renderFixLiteral,
|
|
15
|
+
renderFixShellArg,
|
|
16
|
+
systemClock,
|
|
17
|
+
} from '@ultimat3/core';
|
|
18
|
+
import {
|
|
19
|
+
assertListOptions,
|
|
20
|
+
assertPutOptions,
|
|
9
21
|
DEFAULT_CONTENT_TYPE,
|
|
10
22
|
type ListOptions,
|
|
11
|
-
type ListPage,
|
|
12
23
|
type PutOptions,
|
|
13
24
|
resolveListLimit,
|
|
14
25
|
type SignedUrlOptions,
|
|
@@ -20,61 +31,28 @@ import {
|
|
|
20
31
|
sha256Base64,
|
|
21
32
|
toBytes,
|
|
22
33
|
} from './driver';
|
|
34
|
+
import { isAbsentObject } from './driver-s3-absent';
|
|
35
|
+
import { buildClient, type S3ClientLike, type S3ListResultLike } from './driver-s3-client';
|
|
36
|
+
import { needsSignedPut, readObjectLock, signedPut } from './driver-s3-lock';
|
|
23
37
|
import { regionMismatch } from './driver-s3-region';
|
|
38
|
+
import type { S3FetchLike } from './driver-s3-signed';
|
|
24
39
|
import {
|
|
25
40
|
checksumMismatch,
|
|
26
41
|
deleteFailed,
|
|
42
|
+
getTooLarge,
|
|
43
|
+
isStorageError,
|
|
27
44
|
listFailed,
|
|
28
45
|
objectNotFound,
|
|
29
|
-
|
|
46
|
+
putFailed,
|
|
47
|
+
readFailed,
|
|
30
48
|
} from './errors';
|
|
49
|
+
import { assertObjectLockOptions, type ObjectLock } from './object-lock';
|
|
31
50
|
import { assertSafeKey } from './path';
|
|
32
51
|
import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
|
|
33
52
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
34
53
|
|
|
35
54
|
const DRIVER_NAME = 's3';
|
|
36
55
|
|
|
37
|
-
/** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
|
|
38
|
-
export interface S3FileLike {
|
|
39
|
-
/** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
|
|
40
|
-
write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
|
|
41
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
42
|
-
exists(): Promise<boolean>;
|
|
43
|
-
delete(): Promise<void>;
|
|
44
|
-
stream(): ReadableStream<Uint8Array>;
|
|
45
|
-
stat(): Promise<S3StatLike>;
|
|
46
|
-
presign(options: { method?: string; expiresIn?: number; type?: string }): string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface S3StatLike {
|
|
50
|
-
readonly size: number;
|
|
51
|
-
readonly type?: string | undefined;
|
|
52
|
-
readonly etag?: string | undefined;
|
|
53
|
-
readonly lastModified?: string | Date | undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface S3ListEntryLike {
|
|
57
|
-
readonly key?: string | undefined;
|
|
58
|
-
readonly size?: number | undefined;
|
|
59
|
-
readonly eTag?: string | undefined;
|
|
60
|
-
readonly lastModified?: string | Date | undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface S3ListResultLike {
|
|
64
|
-
readonly contents?: readonly S3ListEntryLike[] | undefined;
|
|
65
|
-
readonly isTruncated?: boolean | undefined;
|
|
66
|
-
readonly nextContinuationToken?: string | undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface S3ClientLike {
|
|
70
|
-
file(key: string): S3FileLike;
|
|
71
|
-
list(input: {
|
|
72
|
-
prefix?: string;
|
|
73
|
-
maxKeys?: number;
|
|
74
|
-
continuationToken?: string;
|
|
75
|
-
}): Promise<S3ListResultLike>;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
56
|
export interface S3DriverOptions {
|
|
79
57
|
readonly bucket: string;
|
|
80
58
|
/**
|
|
@@ -95,6 +73,15 @@ export interface S3DriverOptions {
|
|
|
95
73
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
96
74
|
/** Injected in tests; production constructs `Bun.S3Client`. */
|
|
97
75
|
readonly client?: S3ClientLike | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Injected in tests; production uses the global `fetch`. Carries the SIGNED requests the Bun
|
|
78
|
+
* client has no option for — a `put()` with `metadata`, `cacheControl`, `retention` or
|
|
79
|
+
* `legalHold`, and `retentionOf()` — signed by core's `signAwsRequest` against the same
|
|
80
|
+
* endpoint, region and credentials.
|
|
81
|
+
*/
|
|
82
|
+
readonly fetch?: S3FetchLike | undefined;
|
|
83
|
+
/** Signs the signed requests and screens a `retainUntil`. Default `systemClock`. */
|
|
84
|
+
readonly clock?: Clock | undefined;
|
|
98
85
|
/**
|
|
99
86
|
* Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
|
|
100
87
|
* upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
|
|
@@ -102,91 +89,20 @@ export interface S3DriverOptions {
|
|
|
102
89
|
* and S3's single-PUT limit is 5GB regardless of what this says.
|
|
103
90
|
*/
|
|
104
91
|
readonly maxPutBytes?: number | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Ceiling on ONE `get()`, because `get()` buffers the whole object — and on this disk nothing
|
|
94
|
+
* bounds what a presigned PUT stored (`SignedUrlOptions.maxBytes`). Defaults to `maxPutBytes`;
|
|
95
|
+
* an object past it is read with `stream()`.
|
|
96
|
+
*/
|
|
97
|
+
readonly maxGetBytes?: number | undefined;
|
|
105
98
|
}
|
|
106
99
|
|
|
107
|
-
interface S3ClientConstructor {
|
|
108
|
-
new (options: Record<string, unknown>): S3ClientLike;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function requireEnv(
|
|
112
|
-
env: Readonly<Record<string, string | undefined>>,
|
|
113
|
-
name: string,
|
|
114
|
-
partner: string,
|
|
115
|
-
): string {
|
|
116
|
-
const value = env[name];
|
|
117
|
-
if (value === undefined || value === '') {
|
|
118
|
-
throw new EnvMissingError({
|
|
119
|
-
cause: `${name} is not set, so the s3 disk cannot authenticate`,
|
|
120
|
-
fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
|
|
121
|
-
meta: { missing: name },
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return value;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
128
|
-
if (options.client !== undefined) return options.client;
|
|
129
|
-
if (options.bucket === '') {
|
|
130
|
-
throw new ConfigInvalidError({
|
|
131
|
-
cause: 's3 disk was defined without a bucket',
|
|
132
|
-
fix: 'set storage.disks.<name>.bucket in app.config.ts',
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
const env = options.env ?? process.env;
|
|
136
|
-
const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
|
|
137
|
-
const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
|
|
138
|
-
const Client = (Bun as unknown as { S3Client?: S3ClientConstructor }).S3Client;
|
|
139
|
-
if (Client === undefined) {
|
|
140
|
-
throw new ConfigInvalidError({
|
|
141
|
-
cause: 'Bun.S3Client is unavailable in this runtime',
|
|
142
|
-
fix: 'upgrade the runtime: bun upgrade # the s3 disk needs bun >= 1.3',
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
const tokenVar = options.sessionTokenEnv;
|
|
146
|
-
const sessionToken = tokenVar === undefined ? undefined : env[tokenVar];
|
|
147
|
-
// Bun's flag is the inverse: path style means "not virtual hosted".
|
|
148
|
-
const pathStyle = options.forcePathStyle;
|
|
149
|
-
return new Client({
|
|
150
|
-
bucket: options.bucket,
|
|
151
|
-
accessKeyId: requireEnv(env, idVar, secretVar),
|
|
152
|
-
secretAccessKey: requireEnv(env, secretVar, idVar),
|
|
153
|
-
...(options.region === undefined ? {} : { region: options.region }),
|
|
154
|
-
...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
|
|
155
|
-
...(pathStyle === undefined ? {} : { virtualHostedStyle: !pathStyle }),
|
|
156
|
-
...(sessionToken === undefined ? {} : { sessionToken }),
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const toDate = (value: string | Date | undefined): Date =>
|
|
161
|
-
value === undefined ? new Date(0) : value instanceof Date ? value : new Date(value);
|
|
162
|
-
|
|
163
|
-
/** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
|
|
164
|
-
function numberField(value: unknown, key: string): number | undefined {
|
|
165
|
-
if (typeof value !== 'object' || value === null) return undefined;
|
|
166
|
-
try {
|
|
167
|
-
const held = (value as Record<string, unknown>)[key];
|
|
168
|
-
return typeof held === 'number' ? held : undefined;
|
|
169
|
-
} catch {
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** The provider codes that mean "there is nothing at that key", and nothing wider. */
|
|
175
|
-
const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
|
|
176
|
-
|
|
177
100
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* reported all four as deleted — which is how an erasure sweep certifies data it never removed.
|
|
181
|
-
*
|
|
182
|
-
* Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
|
|
183
|
-
* every field of a value this process did not build is a getter that can throw.
|
|
101
|
+
* A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
|
|
102
|
+
* `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
|
|
184
103
|
*/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
|
|
188
|
-
return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
|
|
189
|
-
}
|
|
104
|
+
const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
|
|
105
|
+
value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
|
|
190
106
|
|
|
191
107
|
/**
|
|
192
108
|
* Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
|
|
@@ -194,25 +110,22 @@ function isAbsentObject(error: unknown): boolean {
|
|
|
194
110
|
* `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
|
|
195
111
|
* meets while writing the call, not while answering a security review.
|
|
196
112
|
*/
|
|
197
|
-
function refuseUnsupportedPut(bucket: string,
|
|
198
|
-
if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
|
|
199
|
-
const uri = `s3://${bucket}/${key}`;
|
|
200
|
-
throw storageNotImplemented(
|
|
201
|
-
'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
|
|
202
|
-
`drop metadata/cacheControl from put(), or set them out of band: ` +
|
|
203
|
-
`aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
|
|
204
|
-
);
|
|
205
|
-
}
|
|
113
|
+
function refuseUnsupportedPut(bucket: string, putOptions?: PutOptions): void {
|
|
206
114
|
const sse = putOptions?.serverSideEncryption;
|
|
207
115
|
if (sse === undefined) return;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
116
|
+
// The key id is the caller's own option, spliced into single-quoted JSON: `renderFixLiteral`
|
|
117
|
+
// escapes the `"` and `\` JSON needs, and a `'` — the one byte live inside single quotes — makes
|
|
118
|
+
// it the placeholder rather than ending the argument (plan 101 row S12).
|
|
119
|
+
const keyId = sse.kmsKeyId?.includes("'") === false ? sse.kmsKeyId : undefined;
|
|
120
|
+
const bucketArg = renderFixShellArg(bucket, "'<bucket>'");
|
|
121
|
+
throw new NotImplementedError({
|
|
122
|
+
cause:
|
|
123
|
+
'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
|
|
124
|
+
fix:
|
|
125
|
+
sse.algorithm === 'aws:kms'
|
|
126
|
+
? `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":${renderFixLiteral(keyId, '"<key-arn>"')}},"BucketKeyEnabled":true}]}'`
|
|
127
|
+
: `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'`,
|
|
128
|
+
});
|
|
216
129
|
}
|
|
217
130
|
|
|
218
131
|
export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
@@ -224,6 +137,16 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
224
137
|
options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
|
|
225
138
|
1,
|
|
226
139
|
);
|
|
140
|
+
const maxGetBytes = finiteCount(
|
|
141
|
+
'the s3 driver',
|
|
142
|
+
'maxGetBytes',
|
|
143
|
+
options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
|
|
144
|
+
1,
|
|
145
|
+
);
|
|
146
|
+
// The key `defineStorage` registered this disk under: a not-found `fix:` is `disk('<it>')…`,
|
|
147
|
+
// and the driver kind there is `X_STORAGE_DISK_UNKNOWN` on any app that named its disk.
|
|
148
|
+
let diskName = DRIVER_NAME;
|
|
149
|
+
const clock = options.clock ?? systemClock;
|
|
227
150
|
let client: S3ClientLike | undefined;
|
|
228
151
|
const conn = (): S3ClientLike => {
|
|
229
152
|
client ??= buildClient(options);
|
|
@@ -240,28 +163,68 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
240
163
|
const mismatch = regionMismatch(options.bucket, error);
|
|
241
164
|
if (mismatch !== undefined) throw mismatch;
|
|
242
165
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
166
|
+
/**
|
|
167
|
+
* A write the provider refused, coded: a denied `s3:PutObject`, a throttle, an expired
|
|
168
|
+
* credential. The bare `S3Error` used to escape `put()` and `copy()` exactly as it once escaped
|
|
169
|
+
* `list()` — no code, no fix, an anonymous 500.
|
|
170
|
+
*/
|
|
171
|
+
const refusedWrite =
|
|
172
|
+
(key: string) =>
|
|
173
|
+
(error: unknown): never => {
|
|
174
|
+
// A refusal this process already coded — a screened header, a missing credential — is its
|
|
175
|
+
// own answer, never re-read as the provider refusing the write.
|
|
176
|
+
if (isUltimateError(error)) throw error;
|
|
177
|
+
throwIfMisconfigured(error);
|
|
178
|
+
throw putFailed(
|
|
179
|
+
DRIVER_NAME,
|
|
180
|
+
key,
|
|
181
|
+
error,
|
|
182
|
+
`aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`,
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A read the provider refused, coded — `refusedWrite`'s twin. A 404 is "not found" wherever it
|
|
188
|
+
* arrives: an object deleted between `exists()` and the read is absent, not unreadable.
|
|
189
|
+
*/
|
|
190
|
+
const refusedRead =
|
|
191
|
+
(key: string) =>
|
|
192
|
+
(error: unknown): never => {
|
|
193
|
+
if (isStorageError(error)) throw error;
|
|
194
|
+
if (isAbsentObject(error)) throw objectNotFound(diskName, key);
|
|
195
|
+
throwIfMisconfigured(error);
|
|
196
|
+
throw readFailed(
|
|
197
|
+
DRIVER_NAME,
|
|
198
|
+
key,
|
|
199
|
+
error,
|
|
200
|
+
`aws s3api head-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(key, "'<key>'")}`,
|
|
201
|
+
);
|
|
202
|
+
};
|
|
203
|
+
const present = (key: string): Promise<boolean> =>
|
|
204
|
+
conn().file(key).exists().catch(refusedRead(key));
|
|
247
205
|
|
|
248
206
|
const statObject = async (key: string): Promise<StorageObject> => {
|
|
249
|
-
const stat = await conn().file(key).stat();
|
|
207
|
+
const stat = await conn().file(key).stat().catch(refusedRead(key));
|
|
250
208
|
return {
|
|
251
209
|
key,
|
|
252
210
|
size: stat.size,
|
|
253
211
|
contentType: stat.type ?? DEFAULT_CONTENT_TYPE,
|
|
254
212
|
etag: stat.etag ?? '',
|
|
255
|
-
|
|
213
|
+
...dated(stat.lastModified),
|
|
256
214
|
};
|
|
257
215
|
};
|
|
258
216
|
|
|
259
217
|
return {
|
|
260
218
|
name: DRIVER_NAME,
|
|
219
|
+
registerAs(name: string): void {
|
|
220
|
+
diskName = name;
|
|
221
|
+
},
|
|
261
222
|
|
|
262
223
|
async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
|
|
263
224
|
const safe = assertSafeKey(key);
|
|
264
|
-
refuseUnsupportedPut(options.bucket,
|
|
225
|
+
refuseUnsupportedPut(options.bucket, putOptions);
|
|
226
|
+
assertPutOptions(DRIVER_NAME, putOptions);
|
|
227
|
+
assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
|
|
265
228
|
// Buffered on purpose: size and checksum must be known before the object exists — so this
|
|
266
229
|
// path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
|
|
267
230
|
// rather than a hope. User uploads never come through here: they go direct to the bucket
|
|
@@ -272,25 +235,49 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
272
235
|
const actual = sha256Base64(bytes);
|
|
273
236
|
if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
|
|
274
237
|
}
|
|
238
|
+
if (putOptions !== undefined && needsSignedPut(putOptions)) {
|
|
239
|
+
// Off the Bun client only for what it cannot send: `S3File.write` takes a type, an ACL and
|
|
240
|
+
// a storage class and no header. One request either way — never a PUT then a second call.
|
|
241
|
+
const refusal = await signedPut(options, safe, bytes, putOptions).catch(refusedWrite(safe));
|
|
242
|
+
if (refusal !== undefined) refusedWrite(safe)(refusal);
|
|
243
|
+
// Stored — the PUT succeeded with them — so reported, as the local disk reports its own.
|
|
244
|
+
return {
|
|
245
|
+
...(await statObject(safe)),
|
|
246
|
+
...(putOptions.cacheControl === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: { cacheControl: putOptions.cacheControl }),
|
|
249
|
+
...(putOptions.metadata === undefined ? {} : { metadata: { ...putOptions.metadata } }),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
275
252
|
await conn()
|
|
276
253
|
.file(safe)
|
|
277
254
|
.write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
|
|
278
|
-
.catch(
|
|
255
|
+
.catch(refusedWrite(safe));
|
|
279
256
|
return statObject(safe);
|
|
280
257
|
},
|
|
281
258
|
|
|
259
|
+
async stat(key: string): Promise<StorageObject | undefined> {
|
|
260
|
+
const safe = assertSafeKey(key);
|
|
261
|
+
return (await present(safe)) ? statObject(safe) : undefined;
|
|
262
|
+
},
|
|
263
|
+
|
|
282
264
|
async get(key: string): Promise<StorageRead> {
|
|
283
265
|
const safe = assertSafeKey(key);
|
|
284
266
|
const file = conn().file(safe);
|
|
285
|
-
if (!(await
|
|
286
|
-
|
|
287
|
-
|
|
267
|
+
if (!(await present(safe))) throw objectNotFound(diskName, safe);
|
|
268
|
+
// The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
|
|
269
|
+
// bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
|
|
270
|
+
// the uploader chose. Refused on the provider's own size before a byte is read.
|
|
271
|
+
const object = await statObject(safe);
|
|
272
|
+
if (object.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, object.size, maxGetBytes);
|
|
273
|
+
const bytes = new Uint8Array(await file.arrayBuffer().catch(refusedRead(safe)));
|
|
274
|
+
return { object, bytes };
|
|
288
275
|
},
|
|
289
276
|
|
|
290
277
|
async stream(key: string): Promise<ReadableStream<Uint8Array>> {
|
|
291
278
|
const safe = assertSafeKey(key);
|
|
292
279
|
const file = conn().file(safe);
|
|
293
|
-
if (!(await
|
|
280
|
+
if (!(await present(safe))) throw objectNotFound(diskName, safe);
|
|
294
281
|
return file.stream();
|
|
295
282
|
},
|
|
296
283
|
|
|
@@ -305,12 +292,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
305
292
|
const source = assertSafeKey(from);
|
|
306
293
|
const destination = assertSafeKey(to);
|
|
307
294
|
const file = conn().file(source);
|
|
308
|
-
if (!(await
|
|
309
|
-
const stat = await file.stat();
|
|
295
|
+
if (!(await present(source))) throw objectNotFound(diskName, source);
|
|
296
|
+
const stat = await file.stat().catch(refusedRead(source));
|
|
310
297
|
await conn()
|
|
311
298
|
.file(destination)
|
|
312
299
|
.write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE })
|
|
313
|
-
.catch(
|
|
300
|
+
.catch(refusedWrite(destination));
|
|
314
301
|
return statObject(destination);
|
|
315
302
|
},
|
|
316
303
|
|
|
@@ -327,19 +314,36 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
327
314
|
DRIVER_NAME,
|
|
328
315
|
safe,
|
|
329
316
|
error,
|
|
330
|
-
`
|
|
317
|
+
// `isSafeKey` admits `$(…)`, a backtick and `;` — legal key bytes, live shell syntax.
|
|
318
|
+
`grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")}`,
|
|
331
319
|
);
|
|
332
320
|
}
|
|
333
321
|
},
|
|
334
322
|
|
|
335
323
|
async exists(key: string): Promise<boolean> {
|
|
336
|
-
return
|
|
324
|
+
return present(assertSafeKey(key));
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/** Signed `GET ?retention` and `?legal-hold`, in parallel. A bucket with no lock reports none. */
|
|
328
|
+
async retentionOf(key: string): Promise<ObjectLock> {
|
|
329
|
+
const safe = assertSafeKey(key);
|
|
330
|
+
const read = await readObjectLock(options, safe).catch(refusedRead(safe));
|
|
331
|
+
if (read.kind === 'lock') return read.lock;
|
|
332
|
+
if (isAbsentObject(read.refusal)) throw objectNotFound(diskName, safe);
|
|
333
|
+
throwIfMisconfigured(read.refusal);
|
|
334
|
+
throw readFailed(
|
|
335
|
+
DRIVER_NAME,
|
|
336
|
+
safe,
|
|
337
|
+
read.refusal,
|
|
338
|
+
`aws s3api get-object-retention --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")} # reproduces the refusal; grant s3:GetObjectRetention and s3:GetObjectLegalHold to the app's role`,
|
|
339
|
+
);
|
|
337
340
|
},
|
|
338
341
|
|
|
339
|
-
async list(listOptions?: ListOptions): Promise<
|
|
342
|
+
async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
|
|
340
343
|
const prefix = listOptions?.prefix ?? '';
|
|
341
344
|
// Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
|
|
342
345
|
// go straight through, while the local disk answered a complete-looking empty page.
|
|
346
|
+
assertListOptions(listOptions);
|
|
343
347
|
const maxKeys = resolveListLimit(listOptions?.limit);
|
|
344
348
|
// `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
|
|
345
349
|
// misconfigured, and it already answers with its own code and its own fix.
|
|
@@ -349,7 +353,9 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
349
353
|
result = await client.list({
|
|
350
354
|
maxKeys,
|
|
351
355
|
...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
|
|
352
|
-
...(listOptions?.cursor ===
|
|
356
|
+
...(typeof listOptions?.cursor === 'string'
|
|
357
|
+
? { continuationToken: listOptions.cursor }
|
|
358
|
+
: {}),
|
|
353
359
|
});
|
|
354
360
|
} catch (error) {
|
|
355
361
|
// A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
|
|
@@ -360,7 +366,11 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
360
366
|
DRIVER_NAME,
|
|
361
367
|
prefix,
|
|
362
368
|
error,
|
|
363
|
-
|
|
369
|
+
// Two whole lines rather than a ternary INSIDE one: each substitution is then a screen
|
|
370
|
+
// the fix-shell-arg guard can read as one.
|
|
371
|
+
prefix === ''
|
|
372
|
+
? `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 ${renderFixShellArg(options.bucket, "'<bucket>'")}`
|
|
373
|
+
: `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 ${renderFixShellArg(options.bucket, "'<bucket>'")} --prefix ${renderFixShellArg(prefix, "'<prefix>'")}`,
|
|
364
374
|
);
|
|
365
375
|
}
|
|
366
376
|
const objects: StorageListEntry[] = [];
|
|
@@ -375,13 +385,16 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
375
385
|
key: entry.key,
|
|
376
386
|
size: entry.size ?? 0,
|
|
377
387
|
etag: entry.eTag ?? '',
|
|
378
|
-
|
|
388
|
+
...dated(entry.lastModified),
|
|
379
389
|
});
|
|
380
390
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
391
|
+
// `IsTruncated` decides and `NextContinuationToken` is the cursor: a token beside
|
|
392
|
+
// `IsTruncated: false` is not a next page, and a truncated answer with no token has none to
|
|
393
|
+
// offer — both are the last page, never a cursor that fetches an empty one.
|
|
394
|
+
return pageOf(
|
|
395
|
+
objects,
|
|
396
|
+
result.isTruncated === true ? (result.nextContinuationToken ?? null) : null,
|
|
397
|
+
);
|
|
385
398
|
},
|
|
386
399
|
|
|
387
400
|
/**
|