@opengeni/storage 0.2.75 → 0.2.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Version-pinned, bounded object reads for large trusted-server workloads.
3
+ *
4
+ * This surface deliberately deals in opaque references. Provider keys, bucket
5
+ * names, signed URLs, and provider diagnostics stay behind the backend
6
+ * callback and are never included in returned values or public errors.
7
+ */
8
+
9
+ export const DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES = 1024 * 1024;
10
+ export const MAX_BOUNDED_OBJECT_CHUNK_BYTES = 8 * 1024 * 1024;
11
+
12
+ export type BoundedObjectReadErrorCode =
13
+ | "aborted"
14
+ | "backend_failure"
15
+ | "invalid_request"
16
+ | "object_changed"
17
+ | "object_missing"
18
+ | "size_limit"
19
+ | "truncated";
20
+
21
+ export class BoundedObjectReadError extends Error {
22
+ readonly code: BoundedObjectReadErrorCode;
23
+
24
+ constructor(code: BoundedObjectReadErrorCode) {
25
+ super(messageForCode(code));
26
+ this.name = "BoundedObjectReadError";
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ export type VersionedObjectDescription = Readonly<{
32
+ byteSize: number;
33
+ /** Opaque provider generation/etag token. It never crosses this package. */
34
+ versionToken: string;
35
+ /**
36
+ * Explicit adapter assertion that the opaque reference resolves forever to
37
+ * this provider generation (for example a version id or immutable
38
+ * content-addressed object). Mutable raw keys must never set this flag.
39
+ */
40
+ immutableReference: true;
41
+ contentType?: string;
42
+ }>;
43
+
44
+ export type VersionedObjectRange = Readonly<{
45
+ bytes: Uint8Array;
46
+ versionToken: string;
47
+ }>;
48
+
49
+ /**
50
+ * Provider adapter used by the range reader. Implementations should use a
51
+ * provider-native generation/version/If-Match condition whenever available.
52
+ * Returning a different version is treated as replacement, never as data.
53
+ */
54
+ export interface VersionedRangeObjectBackend {
55
+ describe(input: {
56
+ opaqueReference: string;
57
+ signal?: AbortSignal;
58
+ }): Promise<VersionedObjectDescription | null>;
59
+ readRange(input: {
60
+ opaqueReference: string;
61
+ start: number;
62
+ endInclusive: number;
63
+ expectedVersionToken: string;
64
+ signal?: AbortSignal;
65
+ }): Promise<VersionedObjectRange | null>;
66
+ close?(input: { opaqueReference: string }): void | Promise<void>;
67
+ }
68
+
69
+ export interface BoundedObjectRead {
70
+ readonly byteSize: number;
71
+ readonly contentType: string | undefined;
72
+ /** A read handle is intentionally single-use. */
73
+ chunks(input?: { chunkBytes?: number; signal?: AbortSignal }): AsyncIterable<Uint8Array>;
74
+ /** Revalidate the immutable provider generation after all consumers finish. */
75
+ assertUnchanged(signal?: AbortSignal): Promise<void>;
76
+ close(): Promise<void>;
77
+ }
78
+
79
+ export interface BoundedObjectReadPort {
80
+ open(input: {
81
+ opaqueReference: string;
82
+ maxBytes: number;
83
+ expectedByteSize?: number;
84
+ signal?: AbortSignal;
85
+ }): Promise<BoundedObjectRead>;
86
+ }
87
+
88
+ /**
89
+ * Adds uniform limits, exact-range checks, replacement fencing, cancellation,
90
+ * and diagnostic scrubbing around a provider-specific range backend.
91
+ */
92
+ export function createBoundedObjectReadPort(
93
+ backend: VersionedRangeObjectBackend,
94
+ ): BoundedObjectReadPort {
95
+ return Object.freeze({
96
+ async open(input: {
97
+ opaqueReference: string;
98
+ maxBytes: number;
99
+ expectedByteSize?: number;
100
+ signal?: AbortSignal;
101
+ }): Promise<BoundedObjectRead> {
102
+ validateReference(input.opaqueReference);
103
+ assertPositiveSafeInteger(input.maxBytes);
104
+ if (input.expectedByteSize !== undefined) {
105
+ assertNonnegativeSafeInteger(input.expectedByteSize);
106
+ }
107
+ throwIfAborted(input.signal);
108
+ const description = await scrubBackendFailure(async () =>
109
+ backend.describe({
110
+ opaqueReference: input.opaqueReference,
111
+ ...(input.signal ? { signal: input.signal } : {}),
112
+ }),
113
+ );
114
+ // Provider adapters are allowed to receive AbortSignal, but correctness
115
+ // cannot depend on every SDK honouring it while an I/O promise is active.
116
+ throwIfAborted(input.signal);
117
+ if (!description) throw new BoundedObjectReadError("object_missing");
118
+ validateDescription(description);
119
+ if (description.byteSize > input.maxBytes) {
120
+ throw new BoundedObjectReadError("size_limit");
121
+ }
122
+ if (input.expectedByteSize !== undefined && description.byteSize !== input.expectedByteSize) {
123
+ throw new BoundedObjectReadError("truncated");
124
+ }
125
+ return new VersionPinnedBoundedObjectRead(backend, input.opaqueReference, description);
126
+ },
127
+ });
128
+ }
129
+
130
+ class VersionPinnedBoundedObjectRead implements BoundedObjectRead {
131
+ readonly byteSize: number;
132
+ readonly contentType: string | undefined;
133
+ readonly #backend: VersionedRangeObjectBackend;
134
+ readonly #opaqueReference: string;
135
+ readonly #versionToken: string;
136
+ #claimed = false;
137
+ #closed = false;
138
+
139
+ constructor(
140
+ backend: VersionedRangeObjectBackend,
141
+ opaqueReference: string,
142
+ description: VersionedObjectDescription,
143
+ ) {
144
+ this.#backend = backend;
145
+ this.#opaqueReference = opaqueReference;
146
+ this.#versionToken = description.versionToken;
147
+ this.byteSize = description.byteSize;
148
+ this.contentType = description.contentType;
149
+ }
150
+
151
+ chunks(
152
+ input: {
153
+ chunkBytes?: number;
154
+ signal?: AbortSignal;
155
+ } = {},
156
+ ): AsyncIterable<Uint8Array> {
157
+ if (this.#closed || this.#claimed) {
158
+ throw new BoundedObjectReadError("invalid_request");
159
+ }
160
+ const chunkBytes = input.chunkBytes ?? DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
161
+ assertPositiveSafeInteger(chunkBytes);
162
+ if (chunkBytes > MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
163
+ throw new BoundedObjectReadError("size_limit");
164
+ }
165
+ this.#claimed = true;
166
+ return this.#streamChunks(chunkBytes, input.signal);
167
+ }
168
+
169
+ async *#streamChunks(
170
+ chunkBytes: number,
171
+ signal: AbortSignal | undefined,
172
+ ): AsyncIterableIterator<Uint8Array> {
173
+ let offset = 0;
174
+ let completed = false;
175
+ try {
176
+ while (offset < this.byteSize) {
177
+ throwIfAborted(signal);
178
+ if (this.#closed) {
179
+ throw new BoundedObjectReadError("invalid_request");
180
+ }
181
+ const endInclusive = Math.min(this.byteSize - 1, offset + chunkBytes - 1);
182
+ const range = await scrubBackendFailure(async () =>
183
+ this.#backend.readRange({
184
+ opaqueReference: this.#opaqueReference,
185
+ start: offset,
186
+ endInclusive,
187
+ expectedVersionToken: this.#versionToken,
188
+ ...(signal ? { signal } : {}),
189
+ }),
190
+ );
191
+ throwIfAborted(signal);
192
+ if (!range) {
193
+ throw new BoundedObjectReadError("object_changed");
194
+ }
195
+ if (range.versionToken !== this.#versionToken) {
196
+ throw new BoundedObjectReadError("object_changed");
197
+ }
198
+ const expectedLength = endInclusive - offset + 1;
199
+ if (range.bytes.byteLength !== expectedLength) {
200
+ throw new BoundedObjectReadError("truncated");
201
+ }
202
+ offset += range.bytes.byteLength;
203
+ // Detach consumers from mutable provider buffers.
204
+ yield range.bytes.slice();
205
+ }
206
+ throwIfAborted(signal);
207
+ completed = true;
208
+ } finally {
209
+ // Keep a successfully consumed handle live for the mandatory final
210
+ // generation check. Early return, failure, and cancellation clean up
211
+ // immediately; normal callers close after assertUnchanged().
212
+ if (!completed) await this.close();
213
+ }
214
+ }
215
+
216
+ async assertUnchanged(signal?: AbortSignal): Promise<void> {
217
+ if (this.#closed) {
218
+ throw new BoundedObjectReadError("invalid_request");
219
+ }
220
+ throwIfAborted(signal);
221
+ const current = await scrubBackendFailure(async () =>
222
+ this.#backend.describe({
223
+ opaqueReference: this.#opaqueReference,
224
+ ...(signal ? { signal } : {}),
225
+ }),
226
+ );
227
+ throwIfAborted(signal);
228
+ if (
229
+ !current ||
230
+ current.versionToken !== this.#versionToken ||
231
+ current.byteSize !== this.byteSize
232
+ ) {
233
+ throw new BoundedObjectReadError("object_changed");
234
+ }
235
+ }
236
+
237
+ async close(): Promise<void> {
238
+ if (this.#closed) return;
239
+ this.#closed = true;
240
+ if (!this.#backend.close) return;
241
+ try {
242
+ await this.#backend.close({ opaqueReference: this.#opaqueReference });
243
+ } catch {
244
+ // Cleanup is best-effort and must not replace the verification result.
245
+ }
246
+ }
247
+ }
248
+
249
+ async function scrubBackendFailure<T>(operation: () => Promise<T>): Promise<T> {
250
+ try {
251
+ return await operation();
252
+ } catch (error) {
253
+ if (error instanceof BoundedObjectReadError) throw error;
254
+ throw new BoundedObjectReadError("backend_failure");
255
+ }
256
+ }
257
+
258
+ function validateReference(value: string): void {
259
+ if (
260
+ typeof value !== "string" ||
261
+ value.length < 1 ||
262
+ value.length > 2048 ||
263
+ value.trim() !== value ||
264
+ /[\u0000-\u001f\u007f]/.test(value)
265
+ ) {
266
+ throw new BoundedObjectReadError("invalid_request");
267
+ }
268
+ }
269
+
270
+ function validateDescription(value: VersionedObjectDescription): void {
271
+ assertNonnegativeSafeInteger(value.byteSize);
272
+ if (value.immutableReference !== true) {
273
+ throw new BoundedObjectReadError("backend_failure");
274
+ }
275
+ if (
276
+ typeof value.versionToken !== "string" ||
277
+ value.versionToken.length < 1 ||
278
+ value.versionToken.length > 2048
279
+ ) {
280
+ throw new BoundedObjectReadError("backend_failure");
281
+ }
282
+ if (
283
+ value.contentType !== undefined &&
284
+ (value.contentType.length < 1 || value.contentType.length > 256)
285
+ ) {
286
+ throw new BoundedObjectReadError("backend_failure");
287
+ }
288
+ }
289
+
290
+ function assertPositiveSafeInteger(value: number): void {
291
+ if (!Number.isSafeInteger(value) || value <= 0) {
292
+ throw new BoundedObjectReadError("invalid_request");
293
+ }
294
+ }
295
+
296
+ function assertNonnegativeSafeInteger(value: number): void {
297
+ if (!Number.isSafeInteger(value) || value < 0) {
298
+ throw new BoundedObjectReadError("invalid_request");
299
+ }
300
+ }
301
+
302
+ function throwIfAborted(signal: AbortSignal | undefined): void {
303
+ if (signal?.aborted) throw new BoundedObjectReadError("aborted");
304
+ }
305
+
306
+ function messageForCode(code: BoundedObjectReadErrorCode): string {
307
+ switch (code) {
308
+ case "aborted":
309
+ return "Bounded object read was cancelled";
310
+ case "backend_failure":
311
+ return "Bounded object read failed";
312
+ case "invalid_request":
313
+ return "Bounded object read request is invalid";
314
+ case "object_changed":
315
+ return "Bounded object changed during verification";
316
+ case "object_missing":
317
+ return "Bounded object is unavailable";
318
+ case "size_limit":
319
+ return "Bounded object exceeds the configured limit";
320
+ case "truncated":
321
+ return "Bounded object length does not match its immutable metadata";
322
+ }
323
+ }
@@ -0,0 +1,269 @@
1
+ import { createHash } from "node:crypto";
2
+ import { MAX_BOUNDED_OBJECT_CHUNK_BYTES, type BoundedObjectReadPort } from "./bounded-object-read";
3
+
4
+ export type BoundedObjectWriteErrorCode =
5
+ | "aborted"
6
+ | "backend_failure"
7
+ | "content_hash_mismatch"
8
+ | "invalid_request"
9
+ | "readback_mismatch"
10
+ | "size_limit"
11
+ | "truncated";
12
+
13
+ export class BoundedObjectWriteError extends Error {
14
+ readonly code: BoundedObjectWriteErrorCode;
15
+
16
+ constructor(code: BoundedObjectWriteErrorCode) {
17
+ super(writeErrorMessage(code));
18
+ this.name = "BoundedObjectWriteError";
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ export interface ImmutableContentAddressedWriteSession {
24
+ write(chunk: Uint8Array, signal?: AbortSignal): Promise<void>;
25
+ /**
26
+ * Atomically promotes staged bytes to an immutable content-addressed object.
27
+ * The returned reference must resolve forever to this exact generation.
28
+ */
29
+ commit(input: {
30
+ byteSize: number;
31
+ contentHash: string;
32
+ contentType: string;
33
+ signal?: AbortSignal;
34
+ }): Promise<{ opaqueReference: string }>;
35
+ abort(): void | Promise<void>;
36
+ }
37
+
38
+ export interface ImmutableContentAddressedWriteBackend {
39
+ begin(input: {
40
+ contentType: string;
41
+ signal?: AbortSignal;
42
+ }): Promise<ImmutableContentAddressedWriteSession>;
43
+ }
44
+
45
+ export type BoundedImmutableObjectWriteResult = Readonly<{
46
+ opaqueReference: string;
47
+ byteSize: number;
48
+ contentHash: string;
49
+ contentType: string;
50
+ }>;
51
+
52
+ export interface BoundedImmutableObjectWritePort {
53
+ write(input: {
54
+ chunks: AsyncIterable<Uint8Array>;
55
+ contentType: string;
56
+ maxBytes: number;
57
+ expectedByteSize?: number;
58
+ expectedContentHash?: string;
59
+ signal?: AbortSignal;
60
+ }): Promise<BoundedImmutableObjectWriteResult>;
61
+ }
62
+
63
+ /**
64
+ * Streams to provider staging, atomically promotes by digest, then streams the
65
+ * immutable object back and hashes it independently before returning its
66
+ * opaque reference. Neither direction accumulates the complete object.
67
+ */
68
+ export function createBoundedImmutableObjectWritePort(input: {
69
+ backend: ImmutableContentAddressedWriteBackend;
70
+ readback: BoundedObjectReadPort;
71
+ }): BoundedImmutableObjectWritePort {
72
+ return Object.freeze({
73
+ async write(request: {
74
+ chunks: AsyncIterable<Uint8Array>;
75
+ contentType: string;
76
+ maxBytes: number;
77
+ expectedByteSize?: number;
78
+ expectedContentHash?: string;
79
+ signal?: AbortSignal;
80
+ }): Promise<BoundedImmutableObjectWriteResult> {
81
+ validateWriteRequest(request);
82
+ throwIfAborted(request.signal);
83
+ let session: ImmutableContentAddressedWriteSession | undefined;
84
+ let committed = false;
85
+ try {
86
+ session = await scrubWriteBackend(async () =>
87
+ input.backend.begin({
88
+ contentType: request.contentType,
89
+ ...(request.signal ? { signal: request.signal } : {}),
90
+ }),
91
+ );
92
+ const digest = createHash("sha256");
93
+ let byteSize = 0;
94
+ for await (const sourceChunk of request.chunks) {
95
+ throwIfAborted(request.signal);
96
+ if (!(sourceChunk instanceof Uint8Array) || sourceChunk.byteLength === 0) {
97
+ throw new BoundedObjectWriteError("truncated");
98
+ }
99
+ if (sourceChunk.byteLength > MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
100
+ throw new BoundedObjectWriteError("size_limit");
101
+ }
102
+ byteSize += sourceChunk.byteLength;
103
+ if (byteSize > request.maxBytes) {
104
+ throw new BoundedObjectWriteError("size_limit");
105
+ }
106
+ const chunk = sourceChunk.slice();
107
+ digest.update(chunk);
108
+ await scrubWriteBackend(async () => session!.write(chunk, request.signal));
109
+ }
110
+ // A backend may ignore AbortSignal and resolve the final write after the
111
+ // caller has cancelled. Recheck before publishing staged bytes; without
112
+ // this fence a one-chunk upload could still be committed after abort.
113
+ throwIfAborted(request.signal);
114
+ if (request.expectedByteSize !== undefined && byteSize !== request.expectedByteSize) {
115
+ throw new BoundedObjectWriteError("truncated");
116
+ }
117
+ const contentHash = `sha256:${digest.digest("hex")}`;
118
+ if (
119
+ request.expectedContentHash !== undefined &&
120
+ contentHash !== request.expectedContentHash
121
+ ) {
122
+ throw new BoundedObjectWriteError("content_hash_mismatch");
123
+ }
124
+ const promoted = await scrubWriteBackend(async () =>
125
+ session!.commit({
126
+ byteSize,
127
+ contentHash,
128
+ contentType: request.contentType,
129
+ ...(request.signal ? { signal: request.signal } : {}),
130
+ }),
131
+ );
132
+ validateOpaqueReference(promoted.opaqueReference);
133
+ committed = true;
134
+ // A provider may finish promotion after cancellation despite receiving
135
+ // the signal. The immutable object can be swept, but it must never be
136
+ // returned to a caller as a successful publication candidate.
137
+ throwIfAborted(request.signal);
138
+
139
+ const reader = await input.readback.open({
140
+ opaqueReference: promoted.opaqueReference,
141
+ maxBytes: request.maxBytes,
142
+ expectedByteSize: byteSize,
143
+ ...(request.signal ? { signal: request.signal } : {}),
144
+ });
145
+ try {
146
+ if (reader.contentType !== undefined && reader.contentType !== request.contentType) {
147
+ throw new BoundedObjectWriteError("readback_mismatch");
148
+ }
149
+ const readbackHash = createHash("sha256");
150
+ let readbackBytes = 0;
151
+ for await (const chunk of reader.chunks({
152
+ ...(request.signal ? { signal: request.signal } : {}),
153
+ })) {
154
+ readbackBytes += chunk.byteLength;
155
+ if (readbackBytes > byteSize) {
156
+ throw new BoundedObjectWriteError("readback_mismatch");
157
+ }
158
+ readbackHash.update(chunk);
159
+ }
160
+ const readbackContentHash = `sha256:${readbackHash.digest("hex")}`;
161
+ if (readbackBytes !== byteSize || readbackContentHash !== contentHash) {
162
+ throw new BoundedObjectWriteError("readback_mismatch");
163
+ }
164
+ await reader.assertUnchanged(request.signal);
165
+ throwIfAborted(request.signal);
166
+ } finally {
167
+ await reader.close();
168
+ }
169
+ return Object.freeze({
170
+ opaqueReference: promoted.opaqueReference,
171
+ byteSize,
172
+ contentHash,
173
+ contentType: request.contentType,
174
+ });
175
+ } catch (error) {
176
+ throw mapWriteFailure(error, request.signal);
177
+ } finally {
178
+ if (session && !committed) {
179
+ try {
180
+ await session.abort();
181
+ } catch {
182
+ // Best-effort staging cleanup; a sweeper remains the final guard.
183
+ }
184
+ }
185
+ }
186
+ },
187
+ });
188
+ }
189
+
190
+ function validateWriteRequest(input: {
191
+ contentType: string;
192
+ maxBytes: number;
193
+ expectedByteSize?: number;
194
+ expectedContentHash?: string;
195
+ }): void {
196
+ if (!Number.isSafeInteger(input.maxBytes) || input.maxBytes <= 0) {
197
+ throw new BoundedObjectWriteError("invalid_request");
198
+ }
199
+ if (
200
+ input.expectedByteSize !== undefined &&
201
+ (!Number.isSafeInteger(input.expectedByteSize) || input.expectedByteSize < 0)
202
+ ) {
203
+ throw new BoundedObjectWriteError("invalid_request");
204
+ }
205
+ if (
206
+ typeof input.contentType !== "string" ||
207
+ input.contentType.length < 1 ||
208
+ input.contentType.length > 256 ||
209
+ /[\u0000-\u001f\u007f]/.test(input.contentType)
210
+ ) {
211
+ throw new BoundedObjectWriteError("invalid_request");
212
+ }
213
+ if (
214
+ input.expectedContentHash !== undefined &&
215
+ !/^sha256:[0-9a-f]{64}$/.test(input.expectedContentHash)
216
+ ) {
217
+ throw new BoundedObjectWriteError("invalid_request");
218
+ }
219
+ }
220
+
221
+ function validateOpaqueReference(value: string): void {
222
+ if (
223
+ typeof value !== "string" ||
224
+ value.length < 1 ||
225
+ value.length > 2048 ||
226
+ value.trim() !== value ||
227
+ /[\u0000-\u001f\u007f]/.test(value)
228
+ ) {
229
+ throw new BoundedObjectWriteError("backend_failure");
230
+ }
231
+ }
232
+
233
+ async function scrubWriteBackend<T>(operation: () => Promise<T>): Promise<T> {
234
+ try {
235
+ return await operation();
236
+ } catch (error) {
237
+ if (error instanceof BoundedObjectWriteError) throw error;
238
+ throw new BoundedObjectWriteError("backend_failure");
239
+ }
240
+ }
241
+
242
+ function mapWriteFailure(error: unknown, signal: AbortSignal | undefined): BoundedObjectWriteError {
243
+ if (error instanceof BoundedObjectWriteError) return error;
244
+ if (signal?.aborted) return new BoundedObjectWriteError("aborted");
245
+ return new BoundedObjectWriteError("backend_failure");
246
+ }
247
+
248
+ function throwIfAborted(signal: AbortSignal | undefined): void {
249
+ if (signal?.aborted) throw new BoundedObjectWriteError("aborted");
250
+ }
251
+
252
+ function writeErrorMessage(code: BoundedObjectWriteErrorCode): string {
253
+ switch (code) {
254
+ case "aborted":
255
+ return "Bounded object write was cancelled";
256
+ case "backend_failure":
257
+ return "Bounded object write failed";
258
+ case "content_hash_mismatch":
259
+ return "Bounded object digest does not match expected canonical bytes";
260
+ case "invalid_request":
261
+ return "Bounded object write request is invalid";
262
+ case "readback_mismatch":
263
+ return "Immutable object read-back verification failed";
264
+ case "size_limit":
265
+ return "Bounded object write exceeds the configured limit";
266
+ case "truncated":
267
+ return "Bounded object write length does not match expected canonical bytes";
268
+ }
269
+ }