@foss.global/codefeed 5.1.0 → 5.2.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.
@@ -6,6 +6,11 @@ import { ForgeRequestClient } from './forgeinventory.client.js';
6
6
  import { LfsBatchClient } from './classes.lfsbatchclient.js';
7
7
  import type { ILfsRequestOptions, TLfsDownload } from './classes.lfsbatchclient.js';
8
8
  import { giteaCloneUrl, reserveMirrorScratch } from './giteasourceinventory.git.js';
9
+ import { cargoIndexPath, containerManifestMediaTypes, npmPackagePath, packageTypePattern, swiftPackageParts } from './giteasourceinventory.packages.js';
10
+
11
+ type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
12
+ type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
13
+ type IPackageBlob = plugins.fossInterfaces.data.IForgeSourcePackageBlob;
9
14
 
10
15
  export interface IGiteaSourceTransferOptions {
11
16
  /** Instance root, including an optional deployment subpath. HTTPS is required. */
@@ -30,6 +35,8 @@ export interface IGiteaSourceTransferOptions {
30
35
  lfsTransferTimeoutMs?: number;
31
36
  /** Wall-clock limit of one release asset download, its closing identity reads included; defaults to 30 minutes, at most six hours. */
32
37
  releaseAssetTimeoutMs?: number;
38
+ /** Wall-clock limit of one package blob or file download, its closing identity read included; defaults to 30 minutes, at most six hours. */
39
+ packageTransferTimeoutMs?: number;
33
40
  }
34
41
 
35
42
  /**
@@ -77,6 +84,71 @@ export interface IGiteaReleaseAssetDownload {
77
84
  stream: InstanceType<typeof plugins.stream.Readable>;
78
85
  }
79
86
 
87
+ /** A package as the source inventory recorded it: what a read names, and the owner id it checks Gitea still holds. */
88
+ export type TGiteaPackageSelector = Pick<IPackageRecord, 'owner' | 'ownerId' | 'type' | 'name'>;
89
+
90
+ /** A version as the source inventory recorded it; its id identifies it, so a tag pushed again is another version. */
91
+ export type TGiteaPackageVersionSelector = Pick<IPackageVersion, 'id' | 'version'>;
92
+
93
+ /** Content as the source inventory recorded it: the SHA-256 and size of a blob. */
94
+ export type TGiteaPackageContentSelector = Pick<IPackageBlob, 'sha256' | 'size'>;
95
+
96
+ /** A file of a version as the source inventory recorded it: its name and the content it holds. */
97
+ export interface IGiteaPackageFileSelector extends TGiteaPackageContentSelector {
98
+ name: string;
99
+ }
100
+
101
+ /**
102
+ * Metadata a package version carries beside its files, read as Gitea serves
103
+ * it: an npm version document, Swift release metadata, a Swift release's
104
+ * `Package.swift`, or a crate version's line of the Cargo sparse index.
105
+ */
106
+ export type TGiteaPackageDocumentKind = 'npm-version' | 'swift-release' | 'swift-manifest' | 'cargo-index-entry';
107
+
108
+ export interface IGiteaPackageReadOptions {
109
+ /** Aborts the read with `request-aborted`, also while a stream is read. */
110
+ signal?: AbortSignal;
111
+ }
112
+
113
+ /** A container manifest with the exact bytes and `Content-Type` Gitea serves. */
114
+ export interface IGiteaContainerManifest {
115
+ /** `sha256:` and the hex digest the bytes were checked against. */
116
+ digest: string;
117
+ /** The `Content-Type` header verbatim: the media type the manifest was pushed with. */
118
+ mediaType: string;
119
+ bytes: Buffer;
120
+ }
121
+
122
+ /** A version's metadata document with the exact bytes Gitea serves and their digest. */
123
+ export interface IGiteaPackageDocument {
124
+ kind: TGiteaPackageDocumentKind;
125
+ /** The id of the version the identity reads before and after the read confirmed. */
126
+ versionId: string;
127
+ /** For `cargo-index-entry`, the version's line of the sparse index without its newline. */
128
+ bytes: Buffer;
129
+ /** Lowercase hex SHA-256 of `bytes`. */
130
+ sha256: string;
131
+ /** The media type Gitea declared, without parameters. */
132
+ mediaType: string;
133
+ }
134
+
135
+ export interface IGiteaPackageDownload {
136
+ content: TGiteaPackageContentSelector;
137
+ /**
138
+ * The content's bytes, never held whole. The last chunk is held back until
139
+ * exactly `content.size` bytes have arrived, their SHA-256 equals
140
+ * `content.sha256` and, for a version's file, the closing identity read has
141
+ * passed. Destroying the stream ends the download.
142
+ */
143
+ stream: InstanceType<typeof plugins.stream.Readable>;
144
+ }
145
+
146
+ const packageContentCodes = {
147
+ invalidChunk: 'package-content-invalid-chunk', overrun: 'package-content-size-overrun', short: 'package-content-short-body',
148
+ };
149
+
150
+ const sha256Hex = (bytesArg: Uint8Array): string => plugins.crypto.createHash('sha256').update(bytesArg).digest('hex');
151
+
80
152
  /** Gitea names attachment content by a random UUID in canonical lowercase form. */
81
153
  const attachmentUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
82
154
 
@@ -87,19 +159,25 @@ export interface IGiteaMirrorImport {
87
159
  }
88
160
 
89
161
  /**
90
- * Reads a Gitea repository's Git data and LFS objects for its one-time
91
- * migration, with the reader's login and token: a mirror clone into a
92
- * GitManager the caller supplies, verified LFS object downloads and release
93
- * asset downloads. It never writes to Gitea: Git is read only through
94
- * `git-upload-pack`, LFS only through batch `download` requests and their
95
- * download actions, release assets only through GETs of their attachment.
162
+ * Reads a Gitea repository's Git data and LFS objects, and package content,
163
+ * for its one-time migration, with the reader's login and token: a mirror
164
+ * clone into a GitManager the caller supplies, verified LFS object downloads,
165
+ * release asset downloads, container manifests and blobs, package files and
166
+ * package metadata documents. It never writes to Gitea: Git is read only
167
+ * through `git-upload-pack`, LFS only through batch `download` requests and
168
+ * their download actions, release assets only through GETs of their
169
+ * attachment, packages only through GETs of the registries and the v1 package
170
+ * API.
96
171
  */
97
172
  export class GiteaSourceTransfer {
98
173
  readonly #options: {
99
174
  baseUrl: string; login: string; token: string; caCertificates?: string[];
100
175
  gitTimeoutMs: number; requestTimeoutMs: number; maxResponseBytes: number; lfsTransferTimeoutMs: number; releaseAssetTimeoutMs: number;
176
+ packageTransferTimeoutMs: number;
101
177
  };
102
178
  readonly #client: ForgeRequestClient;
179
+ /** The registry (`/v2`) takes the token as a Basic password with the reader's login. */
180
+ readonly #registryClient: ForgeRequestClient;
103
181
 
104
182
  constructor(optionsArg: IGiteaSourceTransferOptions) {
105
183
  const baseUrl = validation.httpUrl(optionsArg.baseUrl);
@@ -113,22 +191,34 @@ export class GiteaSourceTransfer {
113
191
  maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
114
192
  lfsTransferTimeoutMs: optionsArg.lfsTransferTimeoutMs ?? 30 * 60 * 1000,
115
193
  releaseAssetTimeoutMs: optionsArg.releaseAssetTimeoutMs ?? 30 * 60 * 1000,
194
+ packageTransferTimeoutMs: optionsArg.packageTransferTimeoutMs ?? 30 * 60 * 1000,
116
195
  };
117
196
  for (const [name, maximum] of [
118
197
  ['gitTimeoutMs', 6 * 60 * 60 * 1000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024], ['lfsTransferTimeoutMs', 6 * 60 * 60 * 1000],
119
- ['releaseAssetTimeoutMs', 6 * 60 * 60 * 1000],
198
+ ['releaseAssetTimeoutMs', 6 * 60 * 60 * 1000], ['packageTransferTimeoutMs', 6 * 60 * 60 * 1000],
120
199
  ] as const) {
121
200
  const value = limits[name];
122
201
  if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
123
202
  }
124
203
  const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
125
204
  this.#options = { baseUrl: baseUrl.replace(/\/+$/, ''), login: optionsArg.login, token: optionsArg.token, caCertificates, ...limits };
205
+ const requestTransport = caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates);
126
206
  this.#client = new ForgeRequestClient({
127
- transport: caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates),
207
+ transport: requestTransport,
128
208
  headers: { authorization: `token ${optionsArg.token}` },
129
209
  requestTimeoutMs: limits.requestTimeoutMs,
130
210
  maxResponseBytes: limits.maxResponseBytes,
131
211
  });
212
+ this.#registryClient = new ForgeRequestClient({
213
+ transport: requestTransport,
214
+ headers: { authorization: this.#basicAuthorization() },
215
+ requestTimeoutMs: limits.requestTimeoutMs,
216
+ maxResponseBytes: limits.maxResponseBytes,
217
+ });
218
+ }
219
+
220
+ #basicAuthorization(): string {
221
+ return `Basic ${Buffer.from(`${this.#options.login}:${this.#options.token}`).toString('base64')}`;
132
222
  }
133
223
 
134
224
  /** Validated, normalized instance root; contains no credentials or API suffix. */
@@ -342,6 +432,303 @@ export class GiteaSourceTransfer {
342
432
  return { releaseId, asset, stream: createTransferStream(transfer, chunks(), close) };
343
433
  }
344
434
 
435
+ /**
436
+ * Reads one container manifest by digest from the registry with the
437
+ * reader's Basic credentials: its exact bytes, which must hash to `digest`
438
+ * (`package-manifest-digest-mismatch`), and its `Content-Type` verbatim,
439
+ * which must be an OCI or Docker image manifest, index or manifest list
440
+ * (`package-manifest-media-type-invalid`). A manifest is content-addressed,
441
+ * so no identity read is needed. Reading it counts a download of the version
442
+ * holding it, an accepted read stamp (S10 ruling, item 8).
443
+ */
444
+ public async readContainerManifest(
445
+ packageArg: TGiteaPackageSelector, digestArg: string, optionsArg: IGiteaPackageReadOptions = {},
446
+ ): Promise<IGiteaContainerManifest> {
447
+ const selected = this.#containerPackage(packageArg);
448
+ if (typeof digestArg !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(digestArg)) throw new validation.ForgeInventoryError('package-content-invalid');
449
+ const response = await this.#registryClient.requestBytes(
450
+ new URL(`/v2/${selected.owner.toLowerCase()}/${selected.name}/manifests/${digestArg}`, this.#options.baseUrl),
451
+ { accept: containerManifestMediaTypes.join(', '), signal: optionsArg.signal },
452
+ );
453
+ const contentDigest = response.headers.get('docker-content-digest');
454
+ if (`sha256:${sha256Hex(response.bytes)}` !== digestArg || (contentDigest !== null && contentDigest.trim() !== digestArg)) {
455
+ throw new validation.ForgeInventoryError('package-manifest-digest-mismatch');
456
+ }
457
+ const mediaType = response.headers.get('content-type')?.trim() ?? '';
458
+ if (!containerManifestMediaTypes.includes(mediaType.toLowerCase())) throw new validation.ForgeInventoryError('package-manifest-media-type-invalid');
459
+ return { digest: digestArg, mediaType, bytes: response.bytes };
460
+ }
461
+
462
+ /**
463
+ * Downloads one container blob, a config or a layer, by digest from the
464
+ * registry with the reader's Basic credentials. The bytes stream verified:
465
+ * the last chunk is held back until exactly `content.size` bytes have
466
+ * arrived and their SHA-256 equals `content.sha256`. A blob is
467
+ * content-addressed, so no identity read is needed, and reading one counts
468
+ * no download. Rejects, or errors the stream, with a fixed code such as
469
+ * `http-<status>`, `package-content-size-mismatch` (a `content-length` that
470
+ * is not the size), `package-content-size-overrun`, `package-content-short-body`,
471
+ * `package-content-digest-mismatch` or `package-content-transfer-timed-out`.
472
+ */
473
+ public async downloadContainerBlob(
474
+ packageArg: TGiteaPackageSelector, contentArg: TGiteaPackageContentSelector, optionsArg: IGiteaPackageReadOptions = {},
475
+ ): Promise<IGiteaPackageDownload> {
476
+ const selected = this.#containerPackage(packageArg);
477
+ const content = this.#content(contentArg);
478
+ const stream = await this.#verifiedDownload(
479
+ new URL(`/v2/${selected.owner.toLowerCase()}/${selected.name}/blobs/sha256:${content.sha256}`, this.#options.baseUrl),
480
+ { authorization: this.#basicAuthorization() }, content, optionsArg.signal, null,
481
+ );
482
+ return { content: { sha256: content.sha256, size: content.size }, stream };
483
+ }
484
+
485
+ /**
486
+ * Downloads one file of a package version: a generic file, an npm tarball
487
+ * (`{package}/-/{version}/{file}`), a Swift source archive or a crate, by the
488
+ * route of the package's type with the reader's `token` authorization;
489
+ * container content is read with `readContainerManifest` and
490
+ * `downloadContainerBlob` (`package-type-unsupported`). The version is read
491
+ * by its id before the download and again after the last byte: it must
492
+ * still be the recorded version of the recorded owner, else
493
+ * `package-version-identity-changed`, or `http-404` once deleted. The bytes
494
+ * stream verified as for `downloadContainerBlob`. Reading a file counts a
495
+ * download of its version, an accepted read stamp (S10 ruling, item 8).
496
+ */
497
+ public async downloadPackageFile(
498
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, fileArg: IGiteaPackageFileSelector, optionsArg: IGiteaPackageReadOptions = {},
499
+ ): Promise<IGiteaPackageDownload> {
500
+ const selected = this.#packageSelector(packageArg);
501
+ const version = this.#versionSelector(versionArg);
502
+ const content = this.#content(fileArg);
503
+ if (typeof fileArg.name !== 'string' || !fileArg.name || /[\u0000-\u001f\u007f/]/.test(fileArg.name)) throw new validation.ForgeInventoryError('package-content-invalid');
504
+ const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(selected.owner)}`;
505
+ const encodedVersion = encodeURIComponent(version.version);
506
+ let url: URL;
507
+ const headers: Record<string, string> = { authorization: `token ${this.#options.token}` };
508
+ switch (selected.type) {
509
+ case 'generic':
510
+ url = new URL(`${registry}/generic/${encodeURIComponent(selected.name)}/${encodedVersion}/${encodeURIComponent(fileArg.name)}`);
511
+ break;
512
+ case 'npm':
513
+ url = new URL(`${registry}/npm/${npmPackagePath(selected.name)}/-/${encodedVersion}/${encodeURIComponent(fileArg.name)}`);
514
+ break;
515
+ case 'swift': {
516
+ const parts = swiftPackageParts(selected.name);
517
+ url = new URL(`${registry}/swift/${encodeURIComponent(parts.scope)}/${encodeURIComponent(parts.name)}/${encodedVersion}.zip`);
518
+ headers.accept = 'application/vnd.swift.registry.v1+zip';
519
+ break;
520
+ }
521
+ case 'cargo':
522
+ url = new URL(`${registry}/cargo/api/v1/crates/${encodeURIComponent(selected.name)}/${encodedVersion}/download`);
523
+ break;
524
+ default:
525
+ throw new validation.ForgeInventoryError('package-type-unsupported');
526
+ }
527
+ const { signal } = optionsArg;
528
+ await this.#versionIdentified(selected, version, signal);
529
+ let stream: InstanceType<typeof plugins.stream.Readable>;
530
+ try {
531
+ stream = await this.#verifiedDownload(url, headers, content, signal, (signalArg) => this.#versionIdentified(selected, version, signalArg));
532
+ } catch (error) {
533
+ if (signal?.aborted) throw error;
534
+ return this.#versionExplained(selected, version, error, signal);
535
+ }
536
+ return { content: { sha256: content.sha256, size: content.size }, stream };
537
+ }
538
+
539
+ /**
540
+ * Reads one metadata document of a package version with the reader's
541
+ * `token` authorization, bracketed by identity reads of the version as for
542
+ * `downloadPackageFile`: `npm-version` (the version document of an npm
543
+ * package), `swift-release` (Swift release metadata), `swift-manifest` (the
544
+ * release's `Package.swift`) or `cargo-index-entry` (the version's line of
545
+ * the crate's sparse index). The bytes are returned as Gitea serves them,
546
+ * with their SHA-256, which a consumer records beside the version id. None
547
+ * of these reads counts a download. A kind that does not belong to the
548
+ * package's type rejects with `package-document-kind-invalid`, a document
549
+ * of another media type with `invalid-content-type`, and a crate version
550
+ * missing from the index with `package-document-missing`.
551
+ */
552
+ public async readPackageDocument(
553
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, kindArg: TGiteaPackageDocumentKind, optionsArg: IGiteaPackageReadOptions = {},
554
+ ): Promise<IGiteaPackageDocument> {
555
+ const selected = this.#packageSelector(packageArg);
556
+ const version = this.#versionSelector(versionArg);
557
+ const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(selected.owner)}`;
558
+ const encodedVersion = encodeURIComponent(version.version);
559
+ let url: URL;
560
+ let accept: string;
561
+ let mediaType: string;
562
+ if (kindArg === 'npm-version' && selected.type === 'npm') {
563
+ [url, accept, mediaType] = [new URL(`${registry}/npm/${npmPackagePath(selected.name)}/${encodedVersion}`), 'application/json', 'application/json'];
564
+ } else if ((kindArg === 'swift-release' || kindArg === 'swift-manifest') && selected.type === 'swift') {
565
+ const parts = swiftPackageParts(selected.name);
566
+ const release = `${registry}/swift/${encodeURIComponent(parts.scope)}/${encodeURIComponent(parts.name)}/${encodedVersion}`;
567
+ [url, accept, mediaType] = kindArg === 'swift-release'
568
+ ? [new URL(release), 'application/vnd.swift.registry.v1+json', 'application/json']
569
+ : [new URL(`${release}/Package.swift`), 'application/vnd.swift.registry.v1+swift', 'text/x-swift'];
570
+ } else if (kindArg === 'cargo-index-entry' && selected.type === 'cargo') {
571
+ [url, accept, mediaType] = [new URL(`${registry}/cargo/${cargoIndexPath(selected.name)}`), 'text/plain', 'text/plain'];
572
+ } else {
573
+ throw new validation.ForgeInventoryError('package-document-kind-invalid');
574
+ }
575
+ const { signal } = optionsArg;
576
+ await this.#versionIdentified(selected, version, signal);
577
+ let response: Awaited<ReturnType<ForgeRequestClient['requestBytes']>>;
578
+ try {
579
+ response = await this.#client.requestBytes(url, { accept, signal });
580
+ } catch (error) {
581
+ if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
582
+ return this.#versionExplained(selected, version, error, signal);
583
+ }
584
+ if (response.mediaType !== mediaType) throw new validation.ForgeInventoryError('invalid-content-type');
585
+ let bytes = response.bytes;
586
+ if (kindArg === 'cargo-index-entry') {
587
+ let line: string | undefined;
588
+ try {
589
+ line = new TextDecoder('utf-8', { fatal: true }).decode(bytes).split('\n')
590
+ .find((lineArg) => lineArg !== '' && validation.record(JSON.parse(lineArg) as unknown).vers === version.version);
591
+ } catch {
592
+ throw new validation.ForgeInventoryError('invalid-cargo-index');
593
+ }
594
+ if (line === undefined) throw new validation.ForgeInventoryError('package-document-missing');
595
+ bytes = Buffer.from(line, 'utf8');
596
+ }
597
+ await this.#versionIdentified(selected, version, signal);
598
+ return { kind: kindArg, versionId: version.id, bytes, sha256: sha256Hex(bytes), mediaType };
599
+ }
600
+
601
+ /**
602
+ * Streams one download verified against `contentArg`: the response must be a
603
+ * 200 whose `content-length`, when sent, is the size; the last chunk is held
604
+ * back until the size and SHA-256 match and `identifiedArg`, when given, has
605
+ * passed once more.
606
+ */
607
+ async #verifiedDownload(
608
+ urlArg: URL, headersArg: Record<string, string>, contentArg: { sha256: string; size: string; bytes: number }, signalArg: AbortSignal | undefined,
609
+ identifiedArg: ((signalArg: AbortSignal) => Promise<void>) | null,
610
+ ): Promise<InstanceType<typeof plugins.stream.Readable>> {
611
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
612
+ const agents = transport.createNodeAgents(this.#options.caCertificates);
613
+ const close = () => agents.https?.destroy();
614
+ const transfer = createTransferBounds(signalArg, {
615
+ limitMs: this.#options.packageTransferTimeoutMs, idleMs: this.#options.requestTimeoutMs,
616
+ timedOutCode: 'package-content-transfer-timed-out', failedCode: 'package-content-transfer-failed',
617
+ });
618
+ let body: AsyncIterable<Uint8Array>;
619
+ try {
620
+ const response = await transfer.run(() => transport.sendNodeRequest(urlArg, { method: 'GET', headers: headersArg, signal: transfer.signal, agents }));
621
+ if (response.status !== 200 || !response.body) {
622
+ await transfer.run(() => discardBody(response.body));
623
+ throw new validation.ForgeInventoryError(`http-${response.status}`);
624
+ }
625
+ const length = response.headers.get('content-length');
626
+ if (length !== null && length.trim() !== contentArg.size) {
627
+ await transfer.run(() => discardBody(response.body));
628
+ throw new validation.ForgeInventoryError('package-content-size-mismatch');
629
+ }
630
+ body = response.body;
631
+ } catch (error) {
632
+ const failure = transfer.failure(error, 'package-content-download-failed');
633
+ transfer.end();
634
+ close();
635
+ throw failure;
636
+ }
637
+ const hash = plugins.crypto.createHash('sha256');
638
+ const complete = async () => {
639
+ if (hash.digest('hex') !== contentArg.sha256) throw new validation.ForgeInventoryError('package-content-digest-mismatch');
640
+ if (identifiedArg) {
641
+ transfer.touch();
642
+ await transfer.run(() => identifiedArg(transfer.signal));
643
+ }
644
+ };
645
+ const chunks = async function* (): AsyncGenerator<Uint8Array, void, undefined> {
646
+ try {
647
+ yield* heldBackChunks(transfer.watch(body), contentArg.bytes, packageContentCodes, { inspect: (chunkArg) => hash.update(chunkArg), complete });
648
+ } catch (error) {
649
+ throw transfer.failure(error, 'package-content-download-failed');
650
+ } finally {
651
+ transfer.end();
652
+ }
653
+ };
654
+ return createTransferStream(transfer, chunks(), close);
655
+ }
656
+
657
+ /**
658
+ * Reads the version by Gitea's v1 package API: it must still carry the
659
+ * recorded version id, owner id, type and name, else
660
+ * `package-version-identity-changed`; a deleted version answers `http-404`.
661
+ */
662
+ async #versionIdentified(packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, signalArg: AbortSignal | undefined): Promise<void> {
663
+ let record: Record<string, unknown>;
664
+ try {
665
+ record = validation.record((await this.#client.request(new URL(`${this.#options.baseUrl}/api/v1/packages/${encodeURIComponent(packageArg.owner)}/${
666
+ encodeURIComponent(packageArg.type)}/${encodeURIComponent(packageArg.name)}/${encodeURIComponent(versionArg.version)}`), { signal: signalArg })).body);
667
+ } catch (error) {
668
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
669
+ throw error;
670
+ }
671
+ if (validation.decimal(record.id) !== versionArg.id || validation.decimal(validation.record(record.owner).id) !== packageArg.ownerId
672
+ || record.type !== packageArg.type || record.name !== packageArg.name || record.version !== versionArg.version) {
673
+ throw new validation.ForgeInventoryError('package-version-identity-changed');
674
+ }
675
+ }
676
+
677
+ /** Rejects with `errorArg` unless the version's identity read explains it: then with `package-version-identity-changed` or `http-404`. */
678
+ async #versionExplained(
679
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, errorArg: unknown, signalArg: AbortSignal | undefined,
680
+ ): Promise<never> {
681
+ try {
682
+ await this.#versionIdentified(packageArg, versionArg, signalArg);
683
+ } catch (error) {
684
+ if (error instanceof validation.ForgeInventoryError && (error.code === 'package-version-identity-changed' || error.code === 'http-404')) throw error;
685
+ }
686
+ throw errorArg;
687
+ }
688
+
689
+ #packageSelector(packageArg: TGiteaPackageSelector): TGiteaPackageSelector {
690
+ try {
691
+ const segments = validation.segments(packageArg?.owner);
692
+ if (segments.length !== 1 || typeof packageArg.type !== 'string' || !packageTypePattern.test(packageArg.type)
693
+ || typeof packageArg.name !== 'string' || !packageArg.name || packageArg.name.length > 512 || /[\u0000-\u001f\u007f]/.test(packageArg.name)) throw new Error();
694
+ return { owner: segments[0], ownerId: validation.decimal(packageArg.ownerId), type: packageArg.type, name: packageArg.name };
695
+ } catch {
696
+ throw new validation.ForgeInventoryError('package-invalid');
697
+ }
698
+ }
699
+
700
+ /** A container package selector whose name is a registry image name, which `/v2` paths carry unencoded. */
701
+ #containerPackage(packageArg: TGiteaPackageSelector): TGiteaPackageSelector {
702
+ const selected = this.#packageSelector(packageArg);
703
+ if (selected.type !== 'container') throw new validation.ForgeInventoryError('package-type-unsupported');
704
+ if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(selected.name)) {
705
+ throw new validation.ForgeInventoryError('package-invalid');
706
+ }
707
+ return selected;
708
+ }
709
+
710
+ #versionSelector(versionArg: TGiteaPackageVersionSelector): TGiteaPackageVersionSelector {
711
+ try {
712
+ if (typeof versionArg?.version !== 'string' || !versionArg.version || /[\u0000-\u001f\u007f]/.test(versionArg.version)) throw new Error();
713
+ return { id: validation.decimal(versionArg.id), version: versionArg.version };
714
+ } catch {
715
+ throw new validation.ForgeInventoryError('package-version-invalid');
716
+ }
717
+ }
718
+
719
+ /** Content as recorded; sizes beyond `Number.MAX_SAFE_INTEGER` are refused. */
720
+ #content(contentArg: TGiteaPackageContentSelector): { sha256: string; size: string; bytes: number } {
721
+ try {
722
+ if (typeof contentArg?.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(contentArg.sha256)) throw new Error();
723
+ const size = validation.decimal(contentArg.size);
724
+ const bytes = Number(size);
725
+ if (!Number.isSafeInteger(bytes)) throw new Error();
726
+ return { sha256: contentArg.sha256, size, bytes };
727
+ } catch {
728
+ throw new validation.ForgeInventoryError('package-content-invalid');
729
+ }
730
+ }
731
+
345
732
  /**
346
733
  * Reads the repository by its id and checks that Gitea still names it
347
734
  * `owner/name`, without case as Gitea looks names up: other names reject
@@ -32,11 +32,39 @@ export interface IForgeJsonResponse {
32
32
  headers: transport.IForgeTransportResponse['headers'];
33
33
  }
34
34
 
35
+ export interface IForgeBytesRequestOptions {
36
+ signal?: AbortSignal;
37
+ /** Optional lower response-body budget for this request. */
38
+ maxResponseBytes?: number;
39
+ /** Additional request headers. */
40
+ headers?: Record<string, string>;
41
+ /** Sent as `accept`; the response may carry any content type, which the caller checks. */
42
+ accept: string;
43
+ }
44
+
45
+ export interface IForgeBytesResponse {
46
+ /** The response body exactly as it arrived. */
47
+ bytes: Buffer;
48
+ /** The response's media type without parameters, lowercased; null when it sent none. */
49
+ mediaType: string | null;
50
+ headers: transport.IForgeTransportResponse['headers'];
51
+ }
52
+
53
+ interface IReadOptions {
54
+ signal?: AbortSignal;
55
+ maxResponseBytes?: number;
56
+ headers?: Record<string, string>;
57
+ accept: string;
58
+ /** The media type the response must carry, checked before its body is read; any when absent. */
59
+ requiredMediaType?: string;
60
+ body?: { mediaType: string; bytes: Uint8Array<ArrayBuffer> };
61
+ }
62
+
35
63
  /**
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
39
- * `ForgeInventoryError` codes that never contain bodies, URLs or credentials.
64
+ * One bounded, credential-scoped read at a time, a GET or a POST with a JSON
65
+ * body: a per-request deadline, a response-size budget and, for JSON, a
66
+ * strict content type. Failures surface as `ForgeInventoryError` codes that
67
+ * never contain bodies, URLs or credentials.
40
68
  */
41
69
  export class ForgeRequestClient {
42
70
  readonly #options: IForgeRequestClientOptions;
@@ -49,6 +77,25 @@ export class ForgeRequestClient {
49
77
  public get metrics(): IForgeRequestMetrics { return { ...this.#metrics }; }
50
78
 
51
79
  public async request(url: URL, requestArg: IForgeRequestOptions = {}): Promise<IForgeJsonResponse> {
80
+ const mediaType = requestArg.mediaType ?? 'application/json';
81
+ const response = await this.#read(url, {
82
+ signal: requestArg.signal, maxResponseBytes: requestArg.maxResponseBytes, headers: requestArg.headers, accept: mediaType, requiredMediaType: mediaType,
83
+ ...(requestArg.json === undefined ? {} : { body: { mediaType, bytes: new TextEncoder().encode(JSON.stringify(requestArg.json)) } }),
84
+ });
85
+ try {
86
+ return { body: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.bytes)) as unknown, headers: response.headers };
87
+ } catch {
88
+ throw new validation.ForgeInventoryError('request-or-json-failed');
89
+ }
90
+ }
91
+
92
+ /** A GET whose body is returned as the bytes that arrived, with the media type the response declared. */
93
+ public async requestBytes(url: URL, requestArg: IForgeBytesRequestOptions): Promise<IForgeBytesResponse> {
94
+ const response = await this.#read(url, requestArg);
95
+ return { bytes: response.bytes, mediaType: mediaTypeOf(response.headers), headers: response.headers };
96
+ }
97
+
98
+ async #read(url: URL, requestArg: IReadOptions): Promise<{ bytes: Buffer; headers: transport.IForgeTransportResponse['headers'] }> {
52
99
  const { signal } = requestArg;
53
100
  const maxBytes = requestArg.maxResponseBytes ?? this.#options.maxResponseBytes;
54
101
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new validation.ForgeInventoryError('invalid-response-budget');
@@ -61,14 +108,16 @@ export class ForgeRequestClient {
61
108
  let bodyChunks: AsyncIterator<Uint8Array> | undefined;
62
109
  try {
63
110
  this.#metrics.requests++;
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);
111
+ const headers = {
112
+ accept: requestArg.accept, ...(requestArg.body ? { 'content-type': requestArg.body.mediaType } : {}), ...requestArg.headers, ...this.#options.headers,
113
+ };
114
+ const response = await this.#options.transport(url, headers, controller.signal, requestArg.body?.bytes);
68
115
  bodyChunks = response.body?.[Symbol.asyncIterator]();
69
116
  if (response.status === 429) this.#metrics.rateLimitedRequests++;
70
117
  if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
71
- if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== mediaType || !bodyChunks) throw new validation.ForgeInventoryError('invalid-content-type');
118
+ if ((requestArg.requiredMediaType !== undefined && mediaTypeOf(response.headers) !== requestArg.requiredMediaType) || !bodyChunks) {
119
+ throw new validation.ForgeInventoryError('invalid-content-type');
120
+ }
72
121
  const chunks: Uint8Array[] = [];
73
122
  let bytes = 0;
74
123
  while (true) {
@@ -79,10 +128,8 @@ export class ForgeRequestClient {
79
128
  if (controller.signal.aborted || Date.now() >= deadline) throw new validation.ForgeInventoryError('request-aborted-or-timed-out');
80
129
  chunks.push(result.value);
81
130
  }
82
- const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
83
- const body = JSON.parse(text) as unknown;
84
131
  if (controller.signal.aborted || Date.now() >= deadline) throw new validation.ForgeInventoryError('request-aborted-or-timed-out');
85
- return { body, headers: response.headers };
132
+ return { bytes: Buffer.concat(chunks), headers: response.headers };
86
133
  } catch (error) {
87
134
  if (error instanceof validation.ForgeInventoryError) throw error;
88
135
  throw new validation.ForgeInventoryError(controller.signal.aborted ? 'request-aborted-or-timed-out' : 'request-or-json-failed');
@@ -93,3 +140,7 @@ export class ForgeRequestClient {
93
140
  }
94
141
  }
95
142
  }
143
+
144
+ /** A response's media type without parameters, lowercased; null when it sent none. */
145
+ const mediaTypeOf = (headersArg: transport.IForgeTransportResponse['headers']): string | null =>
146
+ headersArg.get('content-type')?.split(';')[0].trim().toLowerCase() || null;