@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.
@@ -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, with nothing on a filesystem to
3
- // create or clean up. Not a deployment's disk: a restart is every object gone.
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 { resolveSigningSecret } from './driver-local';
26
- import { checksumMismatch, objectNotFound, storageNotImplemented } from './errors';
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 MemoryDriverOptions = Omit<LocalDriverOptions, 'root'>;
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
- lastModified: new Date(object.lastModified.getTime()),
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 memoryDriver(options: MemoryDriverOptions = {}): MemoryStorageDriver {
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(DRIVER_NAME, key);
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 storageNotImplemented(
100
- 'server-side encryption on the memory driver (it holds bytes in a Map)',
101
- 'drop serverSideEncryption from put(), and encrypt the disk itself — an s3Driver over a bucket with a default KMS rule',
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, { bytes: bytes.slice(), object: snapshot(object) });
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 hit = found(assertSafeKey(key));
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
- const source = found(assertSafeKey(from));
138
- const destination = assertSafeKey(to);
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
- stored.delete(assertSafeKey(key));
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<ListPage> {
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
- const last = page.at(-1);
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
+ }