@foss.global/codefeed 2.1.0 → 3.1.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.
@@ -0,0 +1,614 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as transport from './forgeinventory.transport.js';
3
+ import * as validation from './forgeinventory.validation.js';
4
+ import { ForgeRequestClient } from './forgeinventory.client.js';
5
+ import type { IForgeRequestMetrics } from './forgeinventory.client.js';
6
+
7
+ /**
8
+ * The credential of an LFS endpoint. It is sent only as the `Authorization`
9
+ * header of batch requests to the configured endpoint, never to action hrefs
10
+ * and never in a URL, error or result.
11
+ */
12
+ export type TLfsBatchCredential =
13
+ /** HTTP Basic, such as a forge login with an access token as the password. */
14
+ | { kind: 'basic'; username: string; password: string }
15
+ /** A complete `Authorization` header value, such as `Bearer <token>`. */
16
+ | { kind: 'authorization'; value: string };
17
+
18
+ export interface ILfsBatchClientOptions {
19
+ /**
20
+ * The repository's LFS endpoint, normally `<repository>.git/info/lfs`:
21
+ * `https://`, or `http://` only to an IP literal in 127.0.0.0/8 or `[::1]`.
22
+ * No user information, query or fragment.
23
+ */
24
+ endpoint: string;
25
+ credential: TLfsBatchCredential;
26
+ /** PEM certificates that replace the default trust store for this client's HTTPS connections. */
27
+ caCertificates?: string[];
28
+ /** Deadline of a batch or verify request, and of the response headers and every idle gap of a transfer; defaults to 60 s, at most 300 s. */
29
+ requestTimeoutMs?: number;
30
+ /** Wall-clock limit of one object's upload or download; defaults to 30 minutes, at most six hours. */
31
+ transferTimeoutMs?: number;
32
+ /** Response-size budget of a batch answer; defaults to 4 MiB, at most 64 MiB. */
33
+ maxResponseBytes?: number;
34
+ /** Objects per batch request; defaults to 100, at most 100. */
35
+ batchSize?: number;
36
+ }
37
+
38
+ /** One LFS object: its SHA-256 as 64 lowercase hex digits and its size in bytes. */
39
+ export interface ILfsObjectId {
40
+ oid: string;
41
+ size: number;
42
+ }
43
+
44
+ export interface ILfsUploadObject extends ILfsObjectId {
45
+ /**
46
+ * Opens the object's bytes, called only when the server asks for them. The
47
+ * source must end when `signalArg` aborts; a Node.js `Readable` is also
48
+ * destroyed when the upload ends early.
49
+ */
50
+ open: (signalArg: AbortSignal) => AsyncIterable<Uint8Array> | Promise<AsyncIterable<Uint8Array>>;
51
+ }
52
+
53
+ export interface ILfsUploadResult extends ILfsObjectId {
54
+ /** `uploaded` when this call sent the object, `present` when the server already held it. */
55
+ outcome: 'uploaded' | 'present';
56
+ }
57
+
58
+ export interface ILfsPresence extends ILfsObjectId {
59
+ status: 'held' | 'missing';
60
+ }
61
+
62
+ /** A download: a stream of the verified object, or `missing` when the server answered error 404. */
63
+ export type TLfsDownload =
64
+ | (ILfsObjectId & { status: 'available'; stream: InstanceType<typeof plugins.stream.Readable> })
65
+ | (ILfsObjectId & { status: 'missing' });
66
+
67
+ export interface ILfsRequestOptions {
68
+ signal?: AbortSignal;
69
+ }
70
+
71
+ interface ILfsAction {
72
+ href: URL;
73
+ headers: Record<string, string>;
74
+ /** When the server stops honouring the href, in epoch milliseconds; null when it set no expiry. */
75
+ expiresAt: number | null;
76
+ }
77
+
78
+ type TLfsActionName = 'upload' | 'verify' | 'download';
79
+
80
+ type TBatchEntry = ILfsObjectId & { actions: Record<string, ILfsAction>; errorCode: number | null };
81
+
82
+ const lfsMediaType = 'application/vnd.git-lfs+json';
83
+ /** Objects per batch request; the Git LFS reference server and Gitea accept 100. */
84
+ const maxBatchSize = 100;
85
+ /** Response bytes read from a PUT or verify answer before the rest is discarded. */
86
+ const maxDiscardedResponseBytes = 64 * 1024;
87
+ const maxActionHeaders = 32;
88
+ const maxHeaderValueLength = 8 * 1024;
89
+ const maxHrefLength = 8 * 1024;
90
+ /** Request headers the client owns; an action that sets one is refused. */
91
+ const reservedHeaderNames: ReadonlySet<string> = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'te', 'trailer', 'keep-alive', 'expect']);
92
+ const headerNamePattern = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
93
+ /** The longest safety margin before an action's expiry: an action that expires sooner is fetched again. */
94
+ const maxExpiryMarginMs = 30_000;
95
+ /** `expires_in` values beyond a century are refused, which keeps every expiry an exact epoch millisecond. */
96
+ const maxExpiresInSeconds = 100 * 366 * 24 * 60 * 60;
97
+ /** Go's zero `time.Time`, which the git-lfs client reads as no expiry. */
98
+ const goZeroTime = '0001-01-01T00:00:00Z';
99
+
100
+ /**
101
+ * The client's URL policy, for the endpoint and for every action href: an
102
+ * `https:` URL, or an `http:` URL whose host is an IP literal in 127.0.0.0/8
103
+ * or `[::1]`, without user information or fragment. Host names over HTTP,
104
+ * `localhost` included, are refused, because a name can resolve elsewhere.
105
+ */
106
+ export const isAllowedLfsUrl = (urlArg: URL): boolean => {
107
+ if (urlArg.username || urlArg.password || urlArg.hash) return false;
108
+ if (urlArg.protocol === 'https:') return urlArg.hostname !== '';
109
+ if (urlArg.protocol !== 'http:') return false;
110
+ // The URL parser normalizes every IPv4 spelling (`127.1`, `0x7f.0.0.1`) to dotted decimal and IPv6 to its short form.
111
+ if (urlArg.hostname === '[::1]') return true;
112
+ return plugins.net.isIPv4(urlArg.hostname) && urlArg.hostname.split('.')[0] === '127';
113
+ };
114
+
115
+ const objectIdPattern = /^[0-9a-f]{64}$/;
116
+
117
+ const assertObjectId = (objectArg: ILfsObjectId): void => {
118
+ if (typeof objectArg?.oid !== 'string' || !objectIdPattern.test(objectArg.oid) || !Number.isSafeInteger(objectArg.size) || objectArg.size < 0) {
119
+ throw new validation.ForgeInventoryError('invalid-lfs-object');
120
+ }
121
+ };
122
+
123
+ /** Refuses empty lists and repeated oids. */
124
+ const assertObjectList = (objectsArg: ILfsObjectId[]): void => {
125
+ if (!Array.isArray(objectsArg)) throw new validation.ForgeInventoryError('invalid-lfs-object');
126
+ const seen = new Set<string>();
127
+ for (const object of objectsArg) {
128
+ assertObjectId(object);
129
+ if (seen.has(object.oid)) throw new validation.ForgeInventoryError('duplicate-lfs-object');
130
+ seen.add(object.oid);
131
+ }
132
+ };
133
+
134
+ const credentialHeader = (credentialArg: TLfsBatchCredential): string => {
135
+ const unsafe = (valueArg: unknown) => typeof valueArg !== 'string' || !valueArg || /[\u0000-\u001f\u007f]/.test(valueArg);
136
+ if (credentialArg?.kind === 'basic') {
137
+ if (unsafe(credentialArg.username) || unsafe(credentialArg.password) || credentialArg.username.includes(':')) {
138
+ throw new validation.ForgeInventoryError('invalid-credential');
139
+ }
140
+ return `Basic ${Buffer.from(`${credentialArg.username}:${credentialArg.password}`).toString('base64')}`;
141
+ }
142
+ if (credentialArg?.kind === 'authorization') {
143
+ if (unsafe(credentialArg.value) || credentialArg.value.length > maxHeaderValueLength) throw new validation.ForgeInventoryError('invalid-credential');
144
+ return credentialArg.value;
145
+ }
146
+ throw new validation.ForgeInventoryError('invalid-credential');
147
+ };
148
+
149
+ /**
150
+ * An action's expiry in epoch milliseconds, or null without one. `expires_in`
151
+ * (whole seconds after the answer arrived) is preferred over `expires_at`
152
+ * (RFC 3339), as the batch API specifies. Like the git-lfs client, an
153
+ * `expires_in` of 0 and Go's zero time mean no expiry, because Go servers
154
+ * emit them for an unset value. Any other malformed value fails with
155
+ * `invalid-lfs-batch`.
156
+ */
157
+ const actionExpiry = (actionArg: Record<string, unknown>, receivedAtArg: number): number | null => {
158
+ const expiresIn = actionArg.expires_in;
159
+ if (expiresIn !== undefined && expiresIn !== null) {
160
+ if (!Number.isSafeInteger(expiresIn) || Math.abs(expiresIn as number) > maxExpiresInSeconds) throw new validation.ForgeInventoryError('invalid-lfs-batch');
161
+ if (expiresIn !== 0) return receivedAtArg + (expiresIn as number) * 1000;
162
+ }
163
+ const expiresAt = actionArg.expires_at;
164
+ if (expiresAt === undefined || expiresAt === null || expiresAt === goZeroTime) return null;
165
+ let text: string | null;
166
+ try {
167
+ text = validation.timestamp(expiresAt);
168
+ } catch {
169
+ throw new validation.ForgeInventoryError('invalid-lfs-batch');
170
+ }
171
+ if (text === null) throw new validation.ForgeInventoryError('invalid-lfs-batch');
172
+ return Date.parse(text);
173
+ };
174
+
175
+ /**
176
+ * Passes the bytes of `sourceArg` on while it counts and hashes them. The last
177
+ * chunk is held back until the source ends with exactly `sizeArg` bytes whose
178
+ * SHA-256 is `oidArg`, so a consumer never receives a complete body that is
179
+ * wrong; otherwise it throws `lfs-size-overrun`, `lfs-short-body` or
180
+ * `lfs-digest-mismatch`.
181
+ */
182
+ async function* verifiedChunks(sourceArg: AsyncIterable<Uint8Array>, oidArg: string, sizeArg: number): AsyncGenerator<Uint8Array, void, undefined> {
183
+ const hash = plugins.crypto.createHash('sha256');
184
+ let bytes = 0;
185
+ let held: Uint8Array | null = null;
186
+ for await (const chunk of sourceArg) {
187
+ if (!(chunk instanceof Uint8Array)) throw new validation.ForgeInventoryError('invalid-lfs-chunk');
188
+ if (chunk.byteLength === 0) continue;
189
+ bytes += chunk.byteLength;
190
+ if (bytes > sizeArg) throw new validation.ForgeInventoryError('lfs-size-overrun');
191
+ hash.update(chunk);
192
+ if (held) yield held;
193
+ held = chunk;
194
+ }
195
+ if (bytes !== sizeArg) throw new validation.ForgeInventoryError('lfs-short-body');
196
+ if (hash.digest('hex') !== oidArg) throw new validation.ForgeInventoryError('lfs-digest-mismatch');
197
+ if (held) yield held;
198
+ }
199
+
200
+ /**
201
+ * Reads and drops at most `maxDiscardedResponseBytes` of a response body that
202
+ * carries nothing the client uses, so its connection can be reused; a longer
203
+ * or failing body is discarded with its connection.
204
+ */
205
+ const discardBody = async (bodyArg: AsyncIterable<Uint8Array> | null): Promise<void> => {
206
+ if (!bodyArg) return;
207
+ const chunks = bodyArg[Symbol.asyncIterator]();
208
+ let bytes = 0;
209
+ try {
210
+ while (bytes <= maxDiscardedResponseBytes) {
211
+ const result = await chunks.next();
212
+ if (result.done) return;
213
+ bytes += result.value.byteLength;
214
+ }
215
+ } catch {
216
+ // The status already decided the outcome.
217
+ } finally {
218
+ try { await chunks.return?.(); } catch { /* The connection is discarded. */ }
219
+ }
220
+ };
221
+
222
+ /**
223
+ * A provider-neutral Git LFS batch API client with the `basic` transfer
224
+ * adapter, bound to one LFS endpoint and its credential: uploads objects the
225
+ * server lacks, checks which objects it holds and downloads verified objects.
226
+ * Every request is bounded by deadlines, size budgets and an optional
227
+ * `AbortSignal`; redirects are never followed; errors are fixed
228
+ * `ForgeInventoryError` codes that carry no URL, header, credential or
229
+ * response text.
230
+ */
231
+ export class LfsBatchClient {
232
+ readonly #endpoint: URL;
233
+ readonly #batchUrl: URL;
234
+ readonly #agents: transport.INodeRequestAgents;
235
+ readonly #client: ForgeRequestClient;
236
+ readonly #limits: { requestTimeoutMs: number; transferTimeoutMs: number; batchSize: number; expiryMarginMs: number };
237
+
238
+ constructor(optionsArg: ILfsBatchClientOptions) {
239
+ let endpoint: URL;
240
+ try {
241
+ endpoint = new URL(optionsArg.endpoint);
242
+ } catch {
243
+ throw new validation.ForgeInventoryError('unsafe-lfs-url');
244
+ }
245
+ if (!isAllowedLfsUrl(endpoint) || endpoint.search) throw new validation.ForgeInventoryError('unsafe-lfs-url');
246
+ const authorization = credentialHeader(optionsArg.credential);
247
+ const limits = {
248
+ requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
249
+ transferTimeoutMs: optionsArg.transferTimeoutMs ?? 30 * 60 * 1000,
250
+ maxResponseBytes: optionsArg.maxResponseBytes ?? 4 * 1024 * 1024,
251
+ batchSize: optionsArg.batchSize ?? maxBatchSize,
252
+ };
253
+ for (const [name, maximum] of [
254
+ ['requestTimeoutMs', 300_000], ['transferTimeoutMs', 6 * 60 * 60 * 1000], ['maxResponseBytes', 64 * 1024 * 1024], ['batchSize', maxBatchSize],
255
+ ] as const) {
256
+ const value = limits[name];
257
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
258
+ }
259
+ const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
260
+ this.#endpoint = new URL(endpoint.href.replace(/\/+$/, ''));
261
+ this.#batchUrl = new URL(`${this.#endpoint.href}/objects/batch`);
262
+ // Hrefs must keep the endpoint's protocol, so a plain-HTTP agent exists only for a loopback endpoint.
263
+ this.#agents = transport.createNodeAgents(caCertificates, endpoint.protocol === 'http:');
264
+ const agents = this.#agents;
265
+ this.#client = new ForgeRequestClient({
266
+ transport: async (urlArg, headersArg, signalArg, bodyArg) => {
267
+ const { sent: _sent, ...response } = await transport.sendNodeRequest(urlArg, { method: bodyArg ? 'POST' : 'GET', headers: headersArg, body: bodyArg, signal: signalArg, agents });
268
+ return response;
269
+ },
270
+ headers: { authorization },
271
+ requestTimeoutMs: limits.requestTimeoutMs,
272
+ maxResponseBytes: limits.maxResponseBytes,
273
+ });
274
+ this.#limits = {
275
+ requestTimeoutMs: limits.requestTimeoutMs, transferTimeoutMs: limits.transferTimeoutMs, batchSize: limits.batchSize,
276
+ // An action is used only while it stays valid for this margin, the time a request may take to reach the server.
277
+ expiryMarginMs: Math.min(maxExpiryMarginMs, limits.requestTimeoutMs),
278
+ };
279
+ }
280
+
281
+ /** Batch requests sent and their response bytes; object transfers are not counted. */
282
+ public get metrics(): IForgeRequestMetrics { return this.#client.metrics; }
283
+
284
+ /**
285
+ * Uploads every object the server lacks: one batch `upload` request per
286
+ * `batchSize` objects, then for each object with an `upload` action a PUT of
287
+ * exactly `size` bytes, verified against `oid` while they stream, and the
288
+ * `verify` action when the server sent one. An object without an `upload`
289
+ * action is already present. Objects are sent one at a time after their
290
+ * batch answer; an object whose upload or verify action would expire within
291
+ * the expiry margin (30 s, or `requestTimeoutMs` when that is shorter) is
292
+ * asked for again alone, once, and fails with `lfs-action-expired` when the
293
+ * fresh answer is again too short-lived. Rejects on the first object that
294
+ * fails.
295
+ */
296
+ public async upload(objectsArg: ILfsUploadObject[], optionsArg: ILfsRequestOptions = {}): Promise<ILfsUploadResult[]> {
297
+ assertObjectList(objectsArg);
298
+ for (const object of objectsArg) if (typeof object.open !== 'function') throw new validation.ForgeInventoryError('invalid-lfs-object');
299
+ const results: ILfsUploadResult[] = [];
300
+ for (let index = 0; index < objectsArg.length; index += this.#limits.batchSize) {
301
+ const chunk = objectsArg.slice(index, index + this.#limits.batchSize);
302
+ const entries = await this.#batch('upload', chunk, optionsArg.signal);
303
+ for (const object of chunk) {
304
+ let entry = entries.get(object.oid)!;
305
+ // Objects are sent one at a time, so a late object's actions may have expired since its batch answer.
306
+ if (!this.#current(entry, ['upload', 'verify'])) entry = await this.#rebatch('upload', object, ['upload', 'verify'], optionsArg.signal);
307
+ if (entry.errorCode !== null) throw new validation.ForgeInventoryError(`lfs-object-error-${entry.errorCode}`);
308
+ const upload = entry.actions.upload;
309
+ if (!upload) {
310
+ results.push({ oid: object.oid, size: object.size, outcome: 'present' });
311
+ continue;
312
+ }
313
+ await this.#put(object, upload, optionsArg.signal);
314
+ let verify = entry.actions.verify;
315
+ if (verify && !this.#current(entry, ['verify'])) {
316
+ // The PUT outlasted the verify action. A server that answers with an upload action again does not hold the object.
317
+ const fresh = await this.#rebatch('upload', object, ['verify'], optionsArg.signal);
318
+ if (fresh.errorCode !== null) throw new validation.ForgeInventoryError(`lfs-object-error-${fresh.errorCode}`);
319
+ if (fresh.actions.upload) throw new validation.ForgeInventoryError('lfs-action-expired');
320
+ verify = fresh.actions.verify;
321
+ }
322
+ if (verify) await this.#verify(object, verify, optionsArg.signal);
323
+ results.push({ oid: object.oid, size: object.size, outcome: 'uploaded' });
324
+ }
325
+ }
326
+ return results;
327
+ }
328
+
329
+ /**
330
+ * Asks the server through batch `download` requests which objects it holds,
331
+ * without downloading them: a download action means `held`, error 404
332
+ * `missing`. Any other object error rejects with `lfs-object-error-<code>`.
333
+ * The actions are not used, so their expiry is validated but not enforced.
334
+ */
335
+ public async checkPresence(objectsArg: ILfsObjectId[], optionsArg: ILfsRequestOptions = {}): Promise<ILfsPresence[]> {
336
+ assertObjectList(objectsArg);
337
+ const results: ILfsPresence[] = [];
338
+ for (let index = 0; index < objectsArg.length; index += this.#limits.batchSize) {
339
+ const chunk = objectsArg.slice(index, index + this.#limits.batchSize);
340
+ const entries = await this.#batch('download', chunk, optionsArg.signal);
341
+ for (const object of chunk) {
342
+ results.push({ oid: object.oid, size: object.size, status: this.#downloadAction(entries.get(object.oid)!) === null ? 'missing' : 'held' });
343
+ }
344
+ }
345
+ return results;
346
+ }
347
+
348
+ /**
349
+ * Downloads one object: a batch `download` request, then a GET of the
350
+ * action's href with the action's headers; a download action that would
351
+ * expire within the expiry margin is asked for again once, as for `upload`.
352
+ * Resolves with `missing` when the
353
+ * server answered error 404, otherwise with a stream of the object that
354
+ * never holds it whole. The stream errors with `lfs-size-overrun`,
355
+ * `lfs-short-body` or `lfs-digest-mismatch` before its last chunk when the
356
+ * bytes are not the object, with `request-aborted` when `signal` aborts and
357
+ * with `lfs-transfer-timed-out` when the transfer exceeds
358
+ * `transferTimeoutMs` or stalls for `requestTimeoutMs`. Destroying the
359
+ * stream ends the download.
360
+ */
361
+ public async download(objectArg: ILfsObjectId, optionsArg: ILfsRequestOptions = {}): Promise<TLfsDownload> {
362
+ assertObjectId(objectArg);
363
+ const { oid, size } = objectArg;
364
+ const { signal } = optionsArg;
365
+ let entry = (await this.#batch('download', [{ oid, size }], signal)).get(oid)!;
366
+ if (this.#downloadAction(entry) !== null && !this.#current(entry, ['download'])) entry = await this.#rebatch('download', { oid, size }, ['download'], signal);
367
+ const action = this.#downloadAction(entry);
368
+ if (action === null) return { oid, size, status: 'missing' };
369
+ const transfer = this.#transfer(signal);
370
+ try {
371
+ const response = await transfer.run(() => transport.sendNodeRequest(action.href, {
372
+ method: 'GET', headers: action.headers, signal: transfer.signal, agents: this.#agents,
373
+ }));
374
+ if (response.status !== 200 || !response.body) {
375
+ await discardBody(response.body);
376
+ throw new validation.ForgeInventoryError(`http-${response.status}`);
377
+ }
378
+ const length = response.headers.get('content-length');
379
+ if (length !== null && length.trim() !== String(size)) {
380
+ await discardBody(response.body);
381
+ throw new validation.ForgeInventoryError('lfs-size-mismatch');
382
+ }
383
+ const body = response.body;
384
+ const chunks = async function* (): AsyncGenerator<Uint8Array, void, undefined> {
385
+ try {
386
+ yield* verifiedChunks(transfer.watch(body), oid, size);
387
+ } catch (error) {
388
+ throw transfer.failure(error, 'lfs-download-failed');
389
+ } finally {
390
+ transfer.end();
391
+ }
392
+ };
393
+ const stream = plugins.stream.Readable.from(chunks(), { objectMode: false });
394
+ // A consumer that destroys the stream ends the download, also while a read is pending.
395
+ stream.once('close', () => transfer.end());
396
+ return { oid, size, status: 'available', stream };
397
+ } catch (error) {
398
+ transfer.end();
399
+ throw transfer.failure(error, 'lfs-download-failed');
400
+ }
401
+ }
402
+
403
+ /** Destroys this client's pooled connections. Idle ones close on their own after a few seconds. */
404
+ public close(): void {
405
+ this.#agents.http?.destroy();
406
+ this.#agents.https?.destroy();
407
+ }
408
+
409
+ /** A download action, or null for error 404. */
410
+ #downloadAction(entryArg: TBatchEntry): ILfsAction | null {
411
+ if (entryArg.errorCode === 404) return null;
412
+ if (entryArg.errorCode !== null) throw new validation.ForgeInventoryError(`lfs-object-error-${entryArg.errorCode}`);
413
+ const download = entryArg.actions.download;
414
+ if (!download) throw new validation.ForgeInventoryError('invalid-lfs-batch');
415
+ return download;
416
+ }
417
+
418
+ /** One batch request; answers every requested object exactly once, keyed by oid. */
419
+ async #batch(operationArg: 'upload' | 'download', objectsArg: ILfsObjectId[], signalArg?: AbortSignal): Promise<Map<string, TBatchEntry>> {
420
+ const response = await this.#client.request(this.#batchUrl, {
421
+ signal: signalArg,
422
+ mediaType: lfsMediaType,
423
+ json: { operation: operationArg, transfers: ['basic'], objects: objectsArg.map((objectArg) => ({ oid: objectArg.oid, size: objectArg.size })) },
424
+ });
425
+ // `expires_in` counts from the moment the answer arrived.
426
+ const receivedAt = Date.now();
427
+ const body = validation.record(response.body);
428
+ if (body.transfer !== undefined && body.transfer !== null && body.transfer !== 'basic') throw new validation.ForgeInventoryError('unsupported-lfs-transfer');
429
+ if (!Array.isArray(body.objects) || body.objects.length !== objectsArg.length) throw new validation.ForgeInventoryError('invalid-lfs-batch');
430
+ const requested = new Map(objectsArg.map((objectArg) => [objectArg.oid, objectArg.size]));
431
+ const entries = new Map<string, TBatchEntry>();
432
+ for (const value of body.objects) {
433
+ const object = validation.record(value);
434
+ const size = typeof object.oid === 'string' ? requested.get(object.oid) : undefined;
435
+ if (size === undefined || object.size !== size || entries.has(object.oid as string)) throw new validation.ForgeInventoryError('invalid-lfs-batch');
436
+ let errorCode: number | null = null;
437
+ if (object.error !== undefined && object.error !== null) {
438
+ const code = validation.record(object.error).code;
439
+ if (!Number.isSafeInteger(code) || (code as number) < 100 || (code as number) > 599) throw new validation.ForgeInventoryError('invalid-lfs-batch');
440
+ errorCode = code as number;
441
+ }
442
+ const actions: Record<string, ILfsAction> = {};
443
+ if (errorCode === null && object.actions !== undefined && object.actions !== null) {
444
+ for (const [name, actionValue] of Object.entries(validation.record(object.actions))) {
445
+ if (name === 'upload' || name === 'verify' || name === 'download') actions[name] = this.#action(actionValue, receivedAt);
446
+ }
447
+ }
448
+ entries.set(object.oid as string, { oid: object.oid as string, size, actions, errorCode });
449
+ }
450
+ return entries;
451
+ }
452
+
453
+ /**
454
+ * An action's href and headers. The href must pass the URL policy and keep
455
+ * the endpoint's protocol, so a server cannot move a transfer to plain HTTP
456
+ * or, from a loopback endpoint, to a name; its headers may not set
457
+ * connection-level fields.
458
+ */
459
+ #action(valueArg: unknown, receivedAtArg: number): ILfsAction {
460
+ const action = validation.record(valueArg);
461
+ if (typeof action.href !== 'string' || action.href.length > maxHrefLength) throw new validation.ForgeInventoryError('invalid-lfs-action');
462
+ let href: URL;
463
+ try {
464
+ href = new URL(action.href);
465
+ } catch {
466
+ throw new validation.ForgeInventoryError('invalid-lfs-action');
467
+ }
468
+ if (!isAllowedLfsUrl(href) || href.protocol !== this.#endpoint.protocol) throw new validation.ForgeInventoryError('unsafe-lfs-href');
469
+ const headers: Record<string, string> = {};
470
+ if (action.header !== undefined && action.header !== null) {
471
+ const entries = Object.entries(validation.record(action.header));
472
+ if (entries.length > maxActionHeaders) throw new validation.ForgeInventoryError('invalid-lfs-action');
473
+ for (const [name, value] of entries) {
474
+ const key = name.toLowerCase();
475
+ if (!headerNamePattern.test(name) || reservedHeaderNames.has(key) || key in headers ||
476
+ typeof value !== 'string' || value.length > maxHeaderValueLength || /[\u0000\r\n]/.test(value)) {
477
+ throw new validation.ForgeInventoryError('invalid-lfs-action');
478
+ }
479
+ headers[key] = value;
480
+ }
481
+ }
482
+ return { href, headers, expiresAt: actionExpiry(action, receivedAtArg) };
483
+ }
484
+
485
+ /** Whether every named action the entry carries stays valid for at least the expiry margin. */
486
+ #current(entryArg: TBatchEntry, namesArg: TLfsActionName[]): boolean {
487
+ const deadline = Date.now() + this.#limits.expiryMarginMs;
488
+ return namesArg.every((nameArg) => {
489
+ const expiresAt = entryArg.actions[nameArg]?.expiresAt ?? null;
490
+ return expiresAt === null || expiresAt > deadline;
491
+ });
492
+ }
493
+
494
+ /**
495
+ * Asks for one object's actions again, once. An answer whose named actions
496
+ * would again expire within the margin fails with `lfs-action-expired`.
497
+ */
498
+ async #rebatch(operationArg: 'upload' | 'download', objectArg: ILfsObjectId, namesArg: TLfsActionName[], signalArg?: AbortSignal): Promise<TBatchEntry> {
499
+ const entry = (await this.#batch(operationArg, [{ oid: objectArg.oid, size: objectArg.size }], signalArg)).get(objectArg.oid)!;
500
+ if (entry.errorCode === null && !this.#current(entry, namesArg)) throw new validation.ForgeInventoryError('lfs-action-expired');
501
+ return entry;
502
+ }
503
+
504
+ async #put(objectArg: ILfsUploadObject, actionArg: ILfsAction, signalArg?: AbortSignal): Promise<void> {
505
+ const transfer = this.#transfer(signalArg);
506
+ let source: AsyncIterable<Uint8Array> | undefined;
507
+ try {
508
+ try {
509
+ source = await transfer.run(async () => objectArg.open(transfer.signal));
510
+ } catch (error) {
511
+ throw transfer.failure(error, 'lfs-source-failed');
512
+ }
513
+ const response = await transfer.run(() => transport.sendNodeRequest(actionArg.href, {
514
+ method: 'PUT',
515
+ headers: { ...actionArg.headers, 'content-type': 'application/octet-stream', 'content-length': String(objectArg.size) },
516
+ body: verifiedChunks(transfer.watch(source!), objectArg.oid, objectArg.size),
517
+ signal: transfer.signal,
518
+ agents: this.#agents,
519
+ }));
520
+ if (response.status < 200 || response.status > 299) {
521
+ await discardBody(response.body);
522
+ throw new validation.ForgeInventoryError(`http-${response.status}`);
523
+ }
524
+ // A server that answers before the body is complete has not stored the object.
525
+ await transfer.run(() => response.sent);
526
+ await transfer.run(() => discardBody(response.body));
527
+ } catch (error) {
528
+ throw transfer.failure(error, 'lfs-upload-failed');
529
+ } finally {
530
+ transfer.end();
531
+ if (source instanceof plugins.stream.Readable) source.destroy();
532
+ }
533
+ }
534
+
535
+ async #verify(objectArg: ILfsObjectId, actionArg: ILfsAction, signalArg?: AbortSignal): Promise<void> {
536
+ const transfer = this.#transfer(signalArg, this.#limits.requestTimeoutMs);
537
+ try {
538
+ const response = await transfer.run(() => transport.sendNodeRequest(actionArg.href, {
539
+ method: 'POST',
540
+ headers: { ...actionArg.headers, accept: lfsMediaType, 'content-type': lfsMediaType },
541
+ body: new TextEncoder().encode(JSON.stringify({ oid: objectArg.oid, size: objectArg.size })),
542
+ signal: transfer.signal,
543
+ agents: this.#agents,
544
+ }));
545
+ await transfer.run(() => discardBody(response.body));
546
+ if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
547
+ } catch (error) {
548
+ throw transfer.failure(error, 'lfs-verify-failed');
549
+ } finally {
550
+ transfer.end();
551
+ }
552
+ }
553
+
554
+ /**
555
+ * The bounds of one transfer: the caller's signal, a wall-clock limit and an
556
+ * idle limit that `watch` re-arms on every chunk. Whichever ends it first
557
+ * decides the failure code.
558
+ */
559
+ #transfer(signalArg: AbortSignal | undefined, limitMsArg = this.#limits.transferTimeoutMs) {
560
+ const controller = new AbortController();
561
+ let reason: string | null = null;
562
+ const stop = (reasonArg: string) => {
563
+ reason ??= reasonArg;
564
+ controller.abort();
565
+ };
566
+ const abort = () => stop('request-aborted');
567
+ if (signalArg?.aborted) abort();
568
+ signalArg?.addEventListener('abort', abort, { once: true });
569
+ const deadline = setTimeout(() => stop('lfs-transfer-timed-out'), limitMsArg);
570
+ let idle: ReturnType<typeof setTimeout> | undefined;
571
+ const rearm = () => {
572
+ clearTimeout(idle);
573
+ if (!controller.signal.aborted) idle = setTimeout(() => stop('lfs-transfer-timed-out'), this.#limits.requestTimeoutMs);
574
+ };
575
+ rearm();
576
+ const end = () => {
577
+ clearTimeout(deadline);
578
+ clearTimeout(idle);
579
+ signalArg?.removeEventListener('abort', abort);
580
+ stop('transfer-ended');
581
+ };
582
+ const failure = (errorArg: unknown, fallbackArg: string): validation.ForgeInventoryError => {
583
+ if (reason !== null && reason !== 'transfer-ended') return new validation.ForgeInventoryError(reason);
584
+ return errorArg instanceof validation.ForgeInventoryError ? errorArg : new validation.ForgeInventoryError(fallbackArg);
585
+ };
586
+ return {
587
+ signal: controller.signal,
588
+ end,
589
+ failure,
590
+ /** Runs one step of the transfer; it rejects as soon as the transfer ends, even if the step does not. */
591
+ run: async <T>(stepArg: () => Promise<T>): Promise<T> => {
592
+ if (controller.signal.aborted) throw failure(undefined, 'request-aborted');
593
+ let onAbort!: () => void;
594
+ const aborted = new Promise<never>((_resolve, reject) => {
595
+ onAbort = () => reject(failure(undefined, 'request-aborted'));
596
+ controller.signal.addEventListener('abort', onAbort, { once: true });
597
+ });
598
+ try {
599
+ return await Promise.race([stepArg(), aborted]);
600
+ } finally {
601
+ controller.signal.removeEventListener('abort', onAbort);
602
+ }
603
+ },
604
+ /** Passes chunks on and re-arms the idle limit on each one. */
605
+ watch: async function* (sourceArg: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, undefined> {
606
+ for await (const chunk of sourceArg) {
607
+ if (controller.signal.aborted) throw failure(undefined, 'lfs-transfer-failed');
608
+ rearm();
609
+ yield chunk;
610
+ }
611
+ },
612
+ };
613
+ }
614
+ }
@@ -21,6 +21,10 @@ export interface IForgeRequestOptions {
21
21
  maxResponseBytes?: number;
22
22
  /** Additional request headers, such as a provider's impersonation header. */
23
23
  headers?: Record<string, string>;
24
+ /** JSON media type sent as `accept` and required of the response; defaults to `application/json`. */
25
+ mediaType?: string;
26
+ /** Sends this value as a JSON body of `mediaType` in a POST instead of a GET, for reads that take a request body. */
27
+ json?: unknown;
24
28
  }
25
29
 
26
30
  export interface IForgeJsonResponse {
@@ -29,8 +33,9 @@ export interface IForgeJsonResponse {
29
33
  }
30
34
 
31
35
  /**
32
- * One bounded, credential-scoped JSON GET at a time: a per-request deadline, a
33
- * response-size budget and a strict JSON content type. Failures surface as
36
+ * One bounded, credential-scoped JSON read at a time, a GET or a POST with a
37
+ * JSON body: a per-request deadline, a response-size budget and a strict JSON
38
+ * content type. Failures surface as
34
39
  * `ForgeInventoryError` codes that never contain bodies, URLs or credentials.
35
40
  */
36
41
  export class ForgeRequestClient {
@@ -56,12 +61,14 @@ export class ForgeRequestClient {
56
61
  let bodyChunks: AsyncIterator<Uint8Array> | undefined;
57
62
  try {
58
63
  this.#metrics.requests++;
59
- const headers = { accept: 'application/json', ...requestArg.headers, ...this.#options.headers };
60
- const response = await this.#options.transport(url, headers, controller.signal);
64
+ const mediaType = requestArg.mediaType ?? 'application/json';
65
+ const requestBody = requestArg.json === undefined ? undefined : new TextEncoder().encode(JSON.stringify(requestArg.json));
66
+ const headers = { accept: mediaType, ...(requestBody ? { 'content-type': mediaType } : {}), ...requestArg.headers, ...this.#options.headers };
67
+ const response = await this.#options.transport(url, headers, controller.signal, requestBody);
61
68
  bodyChunks = response.body?.[Symbol.asyncIterator]();
62
69
  if (response.status === 429) this.#metrics.rateLimitedRequests++;
63
70
  if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
64
- if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json' || !bodyChunks) throw new validation.ForgeInventoryError('invalid-content-type');
71
+ if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== mediaType || !bodyChunks) throw new validation.ForgeInventoryError('invalid-content-type');
65
72
  const chunks: Uint8Array[] = [];
66
73
  let bytes = 0;
67
74
  while (true) {