@opengeni/storage 0.2.68 → 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.
- package/dist/bounded-object-read.d.ts +76 -0
- package/dist/bounded-object-write.d.ts +53 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +815 -13
- package/dist/index.js.map +1 -1
- package/dist/object-storage-bounded.d.ts +21 -0
- package/package.json +3 -3
- package/src/bounded-object-read.ts +323 -0
- package/src/bounded-object-write.ts +269 -0
- package/src/index.ts +300 -11
- package/src/object-storage-bounded.ts +297 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdtemp, open, rm, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createBoundedObjectReadPort,
|
|
8
|
+
type BoundedObjectReadPort,
|
|
9
|
+
type VersionedRangeObjectBackend,
|
|
10
|
+
} from "./bounded-object-read";
|
|
11
|
+
import {
|
|
12
|
+
createBoundedImmutableObjectWritePort,
|
|
13
|
+
type BoundedImmutableObjectWritePort,
|
|
14
|
+
type ImmutableContentAddressedWriteBackend,
|
|
15
|
+
type ImmutableContentAddressedWriteSession,
|
|
16
|
+
} from "./bounded-object-write";
|
|
17
|
+
import type { ObjectStorage } from "./index";
|
|
18
|
+
|
|
19
|
+
const SNAPSHOT_KEY_PREFIX = "editable-artifacts/snapshots/sha256/";
|
|
20
|
+
|
|
21
|
+
export type ObjectStorageBoundedPorts = Readonly<{
|
|
22
|
+
read: BoundedObjectReadPort;
|
|
23
|
+
write: BoundedImmutableObjectWritePort;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
export type ObjectStorageBoundedPortsOptions = Readonly<{
|
|
27
|
+
/** Static trusted namespace ending in `/sha256/`. */
|
|
28
|
+
keyPrefix?: string;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bounded immutable snapshot ports over the standalone object-storage driver.
|
|
33
|
+
* Reads are provider-version pinned. Writes use a canonical digest key and are
|
|
34
|
+
* independently range-read and hashed by the shared verification layer.
|
|
35
|
+
*
|
|
36
|
+
* Provider uploads and readback are streaming. Staging uses one private local
|
|
37
|
+
* file so content-addressed naming never requires retaining the object in RAM.
|
|
38
|
+
* There is no whole-object fallback when a provider lacks these primitives.
|
|
39
|
+
*/
|
|
40
|
+
export function createObjectStorageBoundedPorts(
|
|
41
|
+
storage: ObjectStorage,
|
|
42
|
+
options: ObjectStorageBoundedPortsOptions = {},
|
|
43
|
+
): ObjectStorageBoundedPorts {
|
|
44
|
+
if (!storage.headObject || !storage.getObjectRange || !storage.putObjectStreamIfAbsent) {
|
|
45
|
+
throw new Error("Object storage lacks streaming immutable create/versioned range primitives");
|
|
46
|
+
}
|
|
47
|
+
const keyPrefix = validateKeyPrefix(options.keyPrefix ?? SNAPSHOT_KEY_PREFIX);
|
|
48
|
+
const referenceHash = (reference: string) => hashForReference(reference, keyPrefix);
|
|
49
|
+
const read = createBoundedObjectReadPort(versionedBackend(storage, referenceHash));
|
|
50
|
+
const write = createBoundedImmutableObjectWritePort({
|
|
51
|
+
backend: immutableWriteBackend(storage, keyPrefix),
|
|
52
|
+
readback: read,
|
|
53
|
+
});
|
|
54
|
+
return Object.freeze({ read, write });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function versionedBackend(
|
|
58
|
+
storage: ObjectStorage,
|
|
59
|
+
hashForReferenceValue: (reference: string) => string,
|
|
60
|
+
): VersionedRangeObjectBackend {
|
|
61
|
+
const headObject = storage.headObject!;
|
|
62
|
+
const getObjectRange = storage.getObjectRange!;
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
async describe(input: Parameters<VersionedRangeObjectBackend["describe"]>[0]) {
|
|
65
|
+
const expectedHash = hashForReferenceValue(input.opaqueReference);
|
|
66
|
+
throwIfAborted(input.signal);
|
|
67
|
+
const head = await headObject(input.opaqueReference);
|
|
68
|
+
throwIfAborted(input.signal);
|
|
69
|
+
if (!head) return null;
|
|
70
|
+
if (
|
|
71
|
+
!Number.isSafeInteger(head.ContentLength) ||
|
|
72
|
+
head.ContentLength! < 0 ||
|
|
73
|
+
typeof head.VersionToken !== "string" ||
|
|
74
|
+
head.VersionToken.length < 1 ||
|
|
75
|
+
head.VersionToken.length > 2048 ||
|
|
76
|
+
head.Metadata?.sha256 !== expectedHash
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("Immutable object metadata is invalid");
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
byteSize: head.ContentLength!,
|
|
82
|
+
versionToken: head.VersionToken,
|
|
83
|
+
immutableReference: true as const,
|
|
84
|
+
...(head.ContentType ? { contentType: head.ContentType } : {}),
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
async readRange(input: Parameters<VersionedRangeObjectBackend["readRange"]>[0]) {
|
|
88
|
+
hashForReferenceValue(input.opaqueReference);
|
|
89
|
+
throwIfAborted(input.signal);
|
|
90
|
+
const result = await getObjectRange({
|
|
91
|
+
key: input.opaqueReference,
|
|
92
|
+
start: input.start,
|
|
93
|
+
endInclusive: input.endInclusive,
|
|
94
|
+
expectedVersionToken: input.expectedVersionToken,
|
|
95
|
+
});
|
|
96
|
+
throwIfAborted(input.signal);
|
|
97
|
+
if (!result) return null;
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
bytes: result.bytes.slice(),
|
|
100
|
+
versionToken: result.versionToken,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function immutableWriteBackend(
|
|
107
|
+
storage: ObjectStorage,
|
|
108
|
+
keyPrefix: string,
|
|
109
|
+
): ImmutableContentAddressedWriteBackend {
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
async begin(
|
|
112
|
+
input: Parameters<ImmutableContentAddressedWriteBackend["begin"]>[0],
|
|
113
|
+
): Promise<ImmutableContentAddressedWriteSession> {
|
|
114
|
+
validateContentType(input.contentType);
|
|
115
|
+
throwIfAborted(input.signal);
|
|
116
|
+
const stagingDirectory = await mkdtemp(join(tmpdir(), "opengeni-artifact-write-"));
|
|
117
|
+
const stagingPath = join(stagingDirectory, "payload");
|
|
118
|
+
let handle: FileHandle | null = null;
|
|
119
|
+
try {
|
|
120
|
+
await chmod(stagingDirectory, 0o700);
|
|
121
|
+
throwIfAborted(input.signal);
|
|
122
|
+
handle = await open(stagingPath, "wx", 0o600);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
let byteSize = 0;
|
|
128
|
+
let closed = false;
|
|
129
|
+
let cleaned = false;
|
|
130
|
+
const digest = createHash("sha256");
|
|
131
|
+
const cleanup = async () => {
|
|
132
|
+
if (cleaned) return;
|
|
133
|
+
cleaned = true;
|
|
134
|
+
if (handle) {
|
|
135
|
+
const closing = handle;
|
|
136
|
+
handle = null;
|
|
137
|
+
await closing.close().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
async write(chunk: Uint8Array, signal?: AbortSignal) {
|
|
143
|
+
if (closed) throw new Error("Immutable write session is closed");
|
|
144
|
+
throwIfAborted(signal);
|
|
145
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
|
|
146
|
+
throw new Error("Immutable write chunk is invalid");
|
|
147
|
+
}
|
|
148
|
+
await writeAll(handle!, chunk, byteSize);
|
|
149
|
+
digest.update(chunk);
|
|
150
|
+
byteSize += chunk.byteLength;
|
|
151
|
+
},
|
|
152
|
+
async commit(commit: Parameters<ImmutableContentAddressedWriteSession["commit"]>[0]) {
|
|
153
|
+
if (closed) throw new Error("Immutable write session is closed");
|
|
154
|
+
closed = true;
|
|
155
|
+
try {
|
|
156
|
+
throwIfAborted(commit.signal);
|
|
157
|
+
if (commit.contentType !== input.contentType || commit.byteSize !== byteSize) {
|
|
158
|
+
throw new Error("Immutable write commit metadata changed");
|
|
159
|
+
}
|
|
160
|
+
const contentHash = `sha256:${digest.digest("hex")}`;
|
|
161
|
+
if (contentHash !== commit.contentHash) {
|
|
162
|
+
throw new Error("Immutable write digest changed");
|
|
163
|
+
}
|
|
164
|
+
const opaqueReference = `${keyPrefix}${contentHash.slice("sha256:".length)}`;
|
|
165
|
+
const closing = handle!;
|
|
166
|
+
handle = null;
|
|
167
|
+
await closing.close();
|
|
168
|
+
const existing = await storage.headObject!(opaqueReference);
|
|
169
|
+
if (existing) {
|
|
170
|
+
assertStoredObject(existing, byteSize, contentHash, input.contentType);
|
|
171
|
+
} else {
|
|
172
|
+
await storage.putObjectStreamIfAbsent!({
|
|
173
|
+
key: opaqueReference,
|
|
174
|
+
contentType: input.contentType,
|
|
175
|
+
chunks: fileChunks(stagingPath, byteSize, commit.signal),
|
|
176
|
+
byteSize,
|
|
177
|
+
sha256: contentHash,
|
|
178
|
+
...(commit.signal ? { signal: commit.signal } : {}),
|
|
179
|
+
});
|
|
180
|
+
const stored = await storage.headObject!(opaqueReference);
|
|
181
|
+
if (!stored) throw new Error("Immutable object was not visible after write");
|
|
182
|
+
assertStoredObject(stored, byteSize, contentHash, input.contentType);
|
|
183
|
+
}
|
|
184
|
+
throwIfAborted(commit.signal);
|
|
185
|
+
return Object.freeze({ opaqueReference });
|
|
186
|
+
} finally {
|
|
187
|
+
await cleanup();
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
async abort() {
|
|
191
|
+
closed = true;
|
|
192
|
+
byteSize = 0;
|
|
193
|
+
await cleanup();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertStoredObject(
|
|
201
|
+
head: Awaited<ReturnType<NonNullable<ObjectStorage["headObject"]>>>,
|
|
202
|
+
byteSize: number,
|
|
203
|
+
contentHash: string,
|
|
204
|
+
contentType: string,
|
|
205
|
+
): void {
|
|
206
|
+
if (
|
|
207
|
+
!head ||
|
|
208
|
+
head.ContentLength !== byteSize ||
|
|
209
|
+
head.Metadata?.sha256 !== contentHash ||
|
|
210
|
+
head.ContentType !== contentType ||
|
|
211
|
+
typeof head.VersionToken !== "string" ||
|
|
212
|
+
head.VersionToken.length < 1
|
|
213
|
+
) {
|
|
214
|
+
throw new Error("Immutable content-addressed object conflicts with stored metadata");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hashForReference(reference: string, keyPrefix: string): string {
|
|
219
|
+
const match = new RegExp(`^${escapeRegExp(keyPrefix)}([0-9a-f]{64})$`, "u").exec(reference);
|
|
220
|
+
if (!match) throw new Error("Immutable snapshot reference is malformed");
|
|
221
|
+
return `sha256:${match[1]}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateKeyPrefix(value: string): string {
|
|
225
|
+
if (
|
|
226
|
+
typeof value !== "string" ||
|
|
227
|
+
!/^editable-artifacts\/[a-z0-9/-]+\/sha256\/$/u.test(value) ||
|
|
228
|
+
value.includes("//") ||
|
|
229
|
+
value.includes("..")
|
|
230
|
+
) {
|
|
231
|
+
throw new Error("Immutable object key prefix is invalid");
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function escapeRegExp(value: string): string {
|
|
237
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateContentType(value: string): void {
|
|
241
|
+
if (
|
|
242
|
+
typeof value !== "string" ||
|
|
243
|
+
value.length < 1 ||
|
|
244
|
+
value.length > 256 ||
|
|
245
|
+
/[\u0000-\u001f\u007f]/u.test(value)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error("Immutable object content type is invalid");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function* fileChunks(
|
|
252
|
+
path: string,
|
|
253
|
+
expectedByteSize: number,
|
|
254
|
+
signal: AbortSignal | undefined,
|
|
255
|
+
): AsyncIterableIterator<Uint8Array> {
|
|
256
|
+
const handle = await open(path, "r");
|
|
257
|
+
try {
|
|
258
|
+
const buffer = new Uint8Array(1024 * 1024);
|
|
259
|
+
let offset = 0;
|
|
260
|
+
while (offset < expectedByteSize) {
|
|
261
|
+
throwIfAborted(signal);
|
|
262
|
+
const length = Math.min(buffer.byteLength, expectedByteSize - offset);
|
|
263
|
+
let filled = 0;
|
|
264
|
+
while (filled < length) {
|
|
265
|
+
const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
|
|
266
|
+
if (bytesRead <= 0) throw new Error("Immutable staging file was truncated");
|
|
267
|
+
filled += bytesRead;
|
|
268
|
+
}
|
|
269
|
+
offset += filled;
|
|
270
|
+
yield buffer.slice(0, filled);
|
|
271
|
+
}
|
|
272
|
+
const extra = new Uint8Array(1);
|
|
273
|
+
if ((await handle.read(extra, 0, 1, expectedByteSize)).bytesRead !== 0) {
|
|
274
|
+
throw new Error("Immutable staging file exceeded its committed size");
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
await handle.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function writeAll(handle: FileHandle, bytes: Uint8Array, position: number): Promise<void> {
|
|
282
|
+
let offset = 0;
|
|
283
|
+
while (offset < bytes.byteLength) {
|
|
284
|
+
const { bytesWritten } = await handle.write(
|
|
285
|
+
bytes,
|
|
286
|
+
offset,
|
|
287
|
+
bytes.byteLength - offset,
|
|
288
|
+
position + offset,
|
|
289
|
+
);
|
|
290
|
+
if (bytesWritten <= 0) throw new Error("Immutable staging write was truncated");
|
|
291
|
+
offset += bytesWritten;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
296
|
+
if (signal?.aborted) throw new Error("Object storage operation was cancelled");
|
|
297
|
+
}
|