@foss.global/codefeed 5.2.0 → 5.3.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.
@@ -7,6 +7,8 @@ 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
9
  import { cargoIndexPath, containerManifestMediaTypes, npmPackagePath, packageTypePattern, swiftPackageParts } from './giteasourceinventory.packages.js';
10
+ import { canonicalPackageDocument, swiftManifestVersionPattern } from './giteasourcetransfer.documents.js';
11
+ import type { TGiteaCanonicalPackageDocumentKind } from './giteasourcetransfer.documents.js';
10
12
 
11
13
  type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
12
14
  type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
@@ -99,17 +101,29 @@ export interface IGiteaPackageFileSelector extends TGiteaPackageContentSelector
99
101
  }
100
102
 
101
103
  /**
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.
104
+ * Metadata a package version carries beside its files: an npm version
105
+ * document, Swift release metadata, a Swift release's manifest
106
+ * (`Package.swift`, or an alternate `Package@swift-<v>.swift`), a crate
107
+ * version's line of the Cargo sparse index, or the description the Cargo
108
+ * search serves for a crate's latest version.
105
109
  */
106
- export type TGiteaPackageDocumentKind = 'npm-version' | 'swift-release' | 'swift-manifest' | 'cargo-index-entry';
110
+ export type TGiteaPackageDocumentKind = TGiteaCanonicalPackageDocumentKind | 'swift-manifest';
107
111
 
108
112
  export interface IGiteaPackageReadOptions {
109
113
  /** Aborts the read with `request-aborted`, also while a stream is read. */
110
114
  signal?: AbortSignal;
111
115
  }
112
116
 
117
+ /** Reads an alternate Swift manifest. */
118
+ export interface IGiteaSwiftManifestReadOptions extends IGiteaPackageReadOptions {
119
+ /**
120
+ * The Swift version Gitea keys the alternate manifest by, trimmed as its
121
+ * `TrimmedVersionString` writes it (`5.8`, `5.9.1`; the patch only when it
122
+ * is not zero).
123
+ */
124
+ swiftVersion: string;
125
+ }
126
+
113
127
  /** A container manifest with the exact bytes and `Content-Type` Gitea serves. */
114
128
  export interface IGiteaContainerManifest {
115
129
  /** `sha256:` and the hex digest the bytes were checked against. */
@@ -119,12 +133,19 @@ export interface IGiteaContainerManifest {
119
133
  bytes: Buffer;
120
134
  }
121
135
 
122
- /** A version's metadata document with the exact bytes Gitea serves and their digest. */
136
+ /**
137
+ * A version's metadata document and its digest: a JSON document in its
138
+ * canonical form (`canonicalPackageDocument`), a Swift manifest verbatim.
139
+ */
123
140
  export interface IGiteaPackageDocument {
124
141
  kind: TGiteaPackageDocumentKind;
125
142
  /** The id of the version the identity reads before and after the read confirmed. */
126
143
  versionId: string;
127
- /** For `cargo-index-entry`, the version's line of the sparse index without its newline. */
144
+ /**
145
+ * The canonical JSON for every kind but `swift-manifest`, whose bytes are
146
+ * the stored manifest as Gitea serves it. The bytes Gitea served for a JSON
147
+ * document are not returned: their key order differs from read to read.
148
+ */
128
149
  bytes: Buffer;
129
150
  /** Lowercase hex SHA-256 of `bytes`. */
130
151
  sha256: string;
@@ -149,6 +170,11 @@ const packageContentCodes = {
149
170
 
150
171
  const sha256Hex = (bytesArg: Uint8Array): string => plugins.crypto.createHash('sha256').update(bytesArg).digest('hex');
151
172
 
173
+ /** Entries asked of one Cargo search page; Gitea serves at most its `MAX_RESPONSE_ITEMS` (50 by default). */
174
+ const cargoSearchPageSize = 50;
175
+ /** Search pages read for one crate's description before `cargo-search-over-bound`: 10,000 entries at 50 a page. */
176
+ const cargoSearchMaxPages = 200;
177
+
152
178
  /** Gitea names attachment content by a random UUID in canonical lowercase form. */
153
179
  const attachmentUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
154
180
 
@@ -539,63 +565,174 @@ export class GiteaSourceTransfer {
539
565
  /**
540
566
  * Reads one metadata document of a package version with the reader's
541
567
  * `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`.
568
+ * `downloadPackageFile`. None of these reads counts a download.
569
+ *
570
+ * - `npm-version`: the npm version document.
571
+ * - `swift-release`: Swift release metadata.
572
+ * - `swift-manifest`: the release's `Package.swift`, which must answer 200;
573
+ * with `swiftVersion`, the alternate manifest Gitea keys by that version,
574
+ * read without following a redirect. It resolves null only when Gitea
575
+ * answers 303 to the release's own `Package.swift` URL, its way of saying
576
+ * it holds no such manifest; a redirect anywhere else rejects with
577
+ * `swift-manifest-redirect-unexpected`, any other status with
578
+ * `http-<status>`.
579
+ * - `cargo-index-entry`: the version's line of the crate's sparse index;
580
+ * `package-document-missing` when the index has none.
581
+ * - `cargo-description`: `{"description":…}` of the crate's entry in the
582
+ * Cargo search (`q=<name>`, its exact name), which describes only the
583
+ * version with the highest id: its `max_version` must be this version,
584
+ * else `package-document-missing`, as when no entry has the name.
585
+ *
586
+ * A manifest comes back verbatim, every other document in its canonical
587
+ * form (`canonicalPackageDocument`, `document-not-canonical` when it has
588
+ * none), each with the SHA-256 of the returned bytes, which a consumer
589
+ * records beside the version id. A kind that does not belong to the
590
+ * package's type, or `swiftVersion` with another kind, rejects with
591
+ * `package-document-kind-invalid`, a `swiftVersion` Gitea would not key a
592
+ * manifest by with `swift-version-invalid`, both before any request; a
593
+ * document of another media type rejects with `invalid-content-type`.
551
594
  */
552
595
  public async readPackageDocument(
553
- packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, kindArg: TGiteaPackageDocumentKind, optionsArg: IGiteaPackageReadOptions = {},
554
- ): Promise<IGiteaPackageDocument> {
596
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, kindArg: 'swift-manifest', optionsArg: IGiteaSwiftManifestReadOptions,
597
+ ): Promise<IGiteaPackageDocument | null>;
598
+ public async readPackageDocument(
599
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, kindArg: TGiteaPackageDocumentKind, optionsArg?: IGiteaPackageReadOptions,
600
+ ): Promise<IGiteaPackageDocument>;
601
+ public async readPackageDocument(
602
+ packageArg: TGiteaPackageSelector, versionArg: TGiteaPackageVersionSelector, kindArg: TGiteaPackageDocumentKind,
603
+ optionsArg: IGiteaPackageReadOptions & { swiftVersion?: string } = {},
604
+ ): Promise<IGiteaPackageDocument | null> {
555
605
  const selected = this.#packageSelector(packageArg);
556
606
  const version = this.#versionSelector(versionArg);
607
+ const { signal, swiftVersion } = optionsArg;
608
+ if (swiftVersion !== undefined) {
609
+ if (kindArg !== 'swift-manifest') throw new validation.ForgeInventoryError('package-document-kind-invalid');
610
+ if (typeof swiftVersion !== 'string' || !swiftManifestVersionPattern.test(swiftVersion)) throw new validation.ForgeInventoryError('swift-version-invalid');
611
+ }
557
612
  const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(selected.owner)}`;
558
613
  const encodedVersion = encodeURIComponent(version.version);
559
- let url: URL;
560
- let accept: string;
561
- let mediaType: string;
614
+ let read: (signalArg: AbortSignal | undefined) => Promise<{ bytes: Buffer; mediaType: string } | null>;
562
615
  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') {
616
+ read = (signalArg) => this.#documentBytes(new URL(`${registry}/npm/${npmPackagePath(selected.name)}/${encodedVersion}`), 'application/json', 'application/json', signalArg);
617
+ } else if (kindArg === 'swift-release' && selected.type === 'swift') {
565
618
  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'];
619
+ const release = new URL(`${registry}/swift/${encodeURIComponent(parts.scope)}/${encodeURIComponent(parts.name)}/${encodedVersion}`);
620
+ read = (signalArg) => this.#documentBytes(release, 'application/vnd.swift.registry.v1+json', 'application/json', signalArg);
621
+ } else if (kindArg === 'swift-manifest' && selected.type === 'swift') {
622
+ const parts = swiftPackageParts(selected.name);
623
+ read = (signalArg) => this.#swiftManifest(selected, parts, version, swiftVersion ?? null, signalArg);
570
624
  } else if (kindArg === 'cargo-index-entry' && selected.type === 'cargo') {
571
- [url, accept, mediaType] = [new URL(`${registry}/cargo/${cargoIndexPath(selected.name)}`), 'text/plain', 'text/plain'];
625
+ const url = new URL(`${registry}/cargo/${cargoIndexPath(selected.name)}`);
626
+ read = async (signalArg) => {
627
+ const index = await this.#documentBytes(url, 'text/plain', 'text/plain', signalArg);
628
+ let line: string | undefined;
629
+ try {
630
+ line = new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).split('\n')
631
+ .find((lineArg) => lineArg !== '' && validation.record(JSON.parse(lineArg) as unknown).vers === version.version);
632
+ } catch {
633
+ throw new validation.ForgeInventoryError('invalid-cargo-index');
634
+ }
635
+ if (line === undefined) throw new validation.ForgeInventoryError('package-document-missing');
636
+ return { bytes: Buffer.from(line, 'utf8'), mediaType: index.mediaType };
637
+ };
638
+ } else if (kindArg === 'cargo-description' && selected.type === 'cargo') {
639
+ // Gitea's crate name rule, checked before any request.
640
+ void cargoIndexPath(selected.name);
641
+ read = (signalArg) => this.#cargoDescription(registry, selected.name, version.version, signalArg);
572
642
  } else {
573
643
  throw new validation.ForgeInventoryError('package-document-kind-invalid');
574
644
  }
575
- const { signal } = optionsArg;
576
645
  await this.#versionIdentified(selected, version, signal);
577
- let response: Awaited<ReturnType<ForgeRequestClient['requestBytes']>>;
646
+ let document: { bytes: Buffer; mediaType: string } | null;
578
647
  try {
579
- response = await this.#client.requestBytes(url, { accept, signal });
648
+ document = await read(signal);
580
649
  } catch (error) {
581
650
  if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
582
651
  return this.#versionExplained(selected, version, error, signal);
583
652
  }
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;
653
+ await this.#versionIdentified(selected, version, signal);
654
+ if (document === null) return null;
655
+ if (kindArg === 'swift-manifest') {
656
+ return { kind: kindArg, versionId: version.id, bytes: document.bytes, sha256: sha256Hex(document.bytes), mediaType: document.mediaType };
657
+ }
658
+ const canonical = canonicalPackageDocument(kindArg, document.bytes);
659
+ return { kind: kindArg, versionId: version.id, bytes: canonical.bytes, sha256: canonical.sha256, mediaType: document.mediaType };
660
+ }
661
+
662
+ /** A document's body, which must carry `mediaTypeArg`. */
663
+ async #documentBytes(urlArg: URL, acceptArg: string, mediaTypeArg: string, signalArg: AbortSignal | undefined): Promise<{ bytes: Buffer; mediaType: string }> {
664
+ const response = await this.#client.requestBytes(urlArg, { accept: acceptArg, signal: signalArg });
665
+ if (response.mediaType !== mediaTypeArg) throw new validation.ForgeInventoryError('invalid-content-type');
666
+ return { bytes: response.bytes, mediaType: mediaTypeArg };
667
+ }
668
+
669
+ /**
670
+ * A Swift release's `Package.swift`, or with `swiftVersionArg` its
671
+ * alternate manifest; null when Gitea redirects the alternate one to the
672
+ * release's own `Package.swift` (303), which is how it answers a version it
673
+ * holds no manifest for. No redirect is followed.
674
+ */
675
+ async #swiftManifest(
676
+ packageArg: TGiteaPackageSelector, partsArg: { scope: string; name: string }, versionArg: TGiteaPackageVersionSelector, swiftVersionArg: string | null,
677
+ signalArg: AbortSignal | undefined,
678
+ ): Promise<{ bytes: Buffer; mediaType: string } | null> {
679
+ const release = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(packageArg.owner)}/swift/${encodeURIComponent(partsArg.scope)}/${
680
+ encodeURIComponent(partsArg.name)}/${encodeURIComponent(versionArg.version)}/Package.swift`;
681
+ const url = new URL(release);
682
+ if (swiftVersionArg !== null) url.searchParams.set('swift-version', swiftVersionArg);
683
+ const response = await this.#client.requestBytesOrRedirect(url, { accept: 'application/vnd.swift.registry.v1+swift', signal: signalArg });
684
+ if (response.kind === 'redirect') {
685
+ if (swiftVersionArg === null) throw new validation.ForgeInventoryError(`http-${response.status}`);
686
+ // Gitea's answer for a missing alternate manifest: 303 to the release's
687
+ // `Package.swift` under its configured root, with the owner lowercased
688
+ // and scope, name and version as the request named them.
689
+ const expected = new URL(`${this.#options.baseUrl}/api/packages/${packageArg.owner.toLowerCase()}/swift/${partsArg.scope}/${partsArg.name}/${
690
+ versionArg.version}/Package.swift`);
691
+ let location: URL | null = null;
588
692
  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);
693
+ location = response.location === null ? null : new URL(response.location, url);
591
694
  } catch {
592
- throw new validation.ForgeInventoryError('invalid-cargo-index');
695
+ location = null;
593
696
  }
594
- if (line === undefined) throw new validation.ForgeInventoryError('package-document-missing');
595
- bytes = Buffer.from(line, 'utf8');
697
+ if (response.status !== 303 || location === null || location.href !== expected.href) {
698
+ throw new validation.ForgeInventoryError('swift-manifest-redirect-unexpected');
699
+ }
700
+ return null;
596
701
  }
597
- await this.#versionIdentified(selected, version, signal);
598
- return { kind: kindArg, versionId: version.id, bytes, sha256: sha256Hex(bytes), mediaType };
702
+ if (response.mediaType !== 'text/x-swift') throw new validation.ForgeInventoryError('invalid-content-type');
703
+ return { bytes: response.bytes, mediaType: 'text/x-swift' };
704
+ }
705
+
706
+ /**
707
+ * `{"description":…}` of the crate's entry in the Cargo search, read page by
708
+ * page until the entry with exactly `nameArg` turns up. Gitea's search
709
+ * matches names by substring and serves one entry per crate, describing its
710
+ * version with the highest id, whose version `max_version` names.
711
+ */
712
+ async #cargoDescription(registryArg: string, nameArg: string, versionArg: string, signalArg: AbortSignal | undefined): Promise<{ bytes: Buffer; mediaType: string }> {
713
+ let seen = 0;
714
+ for (let page = 1; page <= cargoSearchMaxPages; page++) {
715
+ const url = new URL(`${registryArg}/cargo/api/v1/crates`);
716
+ url.searchParams.set('q', nameArg);
717
+ url.searchParams.set('page', String(page));
718
+ url.searchParams.set('per_page', String(cargoSearchPageSize));
719
+ const body = validation.record((await this.#client.request(url, { signal: signalArg })).body);
720
+ if (!Array.isArray(body.crates)) throw new validation.ForgeInventoryError('invalid-cargo-search');
721
+ const total = validation.record(body.meta).total;
722
+ if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0) throw new validation.ForgeInventoryError('invalid-cargo-search');
723
+ for (const entry of body.crates) {
724
+ const crate = validation.record(entry);
725
+ if (typeof crate.name !== 'string' || typeof crate.max_version !== 'string' || typeof crate.description !== 'string') {
726
+ throw new validation.ForgeInventoryError('invalid-cargo-search');
727
+ }
728
+ if (crate.name !== nameArg) continue;
729
+ if (crate.max_version !== versionArg) throw new validation.ForgeInventoryError('package-document-missing');
730
+ return { bytes: Buffer.from(JSON.stringify({ description: crate.description }), 'utf8'), mediaType: 'application/json' };
731
+ }
732
+ seen += body.crates.length;
733
+ if (body.crates.length === 0 || seen >= total) throw new validation.ForgeInventoryError('package-document-missing');
734
+ }
735
+ throw new validation.ForgeInventoryError('cargo-search-over-bound');
599
736
  }
600
737
 
601
738
  /**
@@ -1,4 +1,4 @@
1
- import type * as transport from './forgeinventory.transport.js';
1
+ import * as transport from './forgeinventory.transport.js';
2
2
  import * as validation from './forgeinventory.validation.js';
3
3
 
4
4
  export interface IForgeRequestMetrics {
@@ -50,6 +50,17 @@ export interface IForgeBytesResponse {
50
50
  headers: transport.IForgeTransportResponse['headers'];
51
51
  }
52
52
 
53
+ /** A redirect a read returned instead of refusing: never followed, its body discarded. */
54
+ export interface IForgeRedirectResponse {
55
+ /** One of 301, 302, 303, 307 and 308. */
56
+ status: number;
57
+ /** The `Location` header verbatim; null when the response sent none. */
58
+ location: string | null;
59
+ }
60
+
61
+ /** The answer of `requestBytesOrRedirect`: the body of a 200, or the redirect. */
62
+ export type TForgeBytesOrRedirect = ({ kind: 'bytes' } & IForgeBytesResponse) | ({ kind: 'redirect' } & IForgeRedirectResponse);
63
+
53
64
  interface IReadOptions {
54
65
  signal?: AbortSignal;
55
66
  maxResponseBytes?: number;
@@ -58,8 +69,14 @@ interface IReadOptions {
58
69
  /** The media type the response must carry, checked before its body is read; any when absent. */
59
70
  requiredMediaType?: string;
60
71
  body?: { mediaType: string; bytes: Uint8Array<ArrayBuffer> };
72
+ /** `return` resolves a redirect status as `redirect` instead of rejecting; defaults to `refuse`. */
73
+ redirect?: transport.TForgeRedirectMode;
61
74
  }
62
75
 
76
+ type TReadResult =
77
+ | { kind: 'bytes'; bytes: Buffer; headers: transport.IForgeTransportResponse['headers'] }
78
+ | ({ kind: 'redirect' } & IForgeRedirectResponse);
79
+
63
80
  /**
64
81
  * One bounded, credential-scoped read at a time, a GET or a POST with a JSON
65
82
  * body: a per-request deadline, a response-size budget and, for JSON, a
@@ -78,7 +95,7 @@ export class ForgeRequestClient {
78
95
 
79
96
  public async request(url: URL, requestArg: IForgeRequestOptions = {}): Promise<IForgeJsonResponse> {
80
97
  const mediaType = requestArg.mediaType ?? 'application/json';
81
- const response = await this.#read(url, {
98
+ const response = await this.#body(url, {
82
99
  signal: requestArg.signal, maxResponseBytes: requestArg.maxResponseBytes, headers: requestArg.headers, accept: mediaType, requiredMediaType: mediaType,
83
100
  ...(requestArg.json === undefined ? {} : { body: { mediaType, bytes: new TextEncoder().encode(JSON.stringify(requestArg.json)) } }),
84
101
  });
@@ -91,11 +108,32 @@ export class ForgeRequestClient {
91
108
 
92
109
  /** A GET whose body is returned as the bytes that arrived, with the media type the response declared. */
93
110
  public async requestBytes(url: URL, requestArg: IForgeBytesRequestOptions): Promise<IForgeBytesResponse> {
94
- const response = await this.#read(url, requestArg);
111
+ const response = await this.#body(url, requestArg);
95
112
  return { bytes: response.bytes, mediaType: mediaTypeOf(response.headers), headers: response.headers };
96
113
  }
97
114
 
98
- async #read(url: URL, requestArg: IReadOptions): Promise<{ bytes: Buffer; headers: transport.IForgeTransportResponse['headers'] }> {
115
+ /**
116
+ * A GET like `requestBytes`, except that a redirect status resolves as
117
+ * `{ kind: 'redirect', status, location }` instead of rejecting. The
118
+ * redirect is never followed and its body is discarded unread; any status
119
+ * other than 200 and a redirect still rejects with `http-<status>`.
120
+ */
121
+ public async requestBytesOrRedirect(url: URL, requestArg: IForgeBytesRequestOptions): Promise<TForgeBytesOrRedirect> {
122
+ const response = await this.#read(url, { ...requestArg, redirect: 'return' });
123
+ return response.kind === 'redirect'
124
+ ? response
125
+ : { kind: 'bytes', bytes: response.bytes, mediaType: mediaTypeOf(response.headers), headers: response.headers };
126
+ }
127
+
128
+ /** A read that refuses redirects, so it always answers a body. */
129
+ async #body(url: URL, requestArg: Omit<IReadOptions, 'redirect'>): Promise<{ bytes: Buffer; headers: transport.IForgeTransportResponse['headers'] }> {
130
+ const response = await this.#read(url, requestArg);
131
+ // The transport refuses every redirect status in this mode, so a redirect never arrives here.
132
+ if (response.kind === 'redirect') throw new validation.ForgeInventoryError('request-or-json-failed');
133
+ return response;
134
+ }
135
+
136
+ async #read(url: URL, requestArg: IReadOptions): Promise<TReadResult> {
99
137
  const { signal } = requestArg;
100
138
  const maxBytes = requestArg.maxResponseBytes ?? this.#options.maxResponseBytes;
101
139
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new validation.ForgeInventoryError('invalid-response-budget');
@@ -111,9 +149,13 @@ export class ForgeRequestClient {
111
149
  const headers = {
112
150
  accept: requestArg.accept, ...(requestArg.body ? { 'content-type': requestArg.body.mediaType } : {}), ...requestArg.headers, ...this.#options.headers,
113
151
  };
114
- const response = await this.#options.transport(url, headers, controller.signal, requestArg.body?.bytes);
152
+ const response = await this.#options.transport(url, headers, controller.signal, requestArg.body?.bytes, { redirect: requestArg.redirect ?? 'refuse' });
115
153
  bodyChunks = response.body?.[Symbol.asyncIterator]();
116
154
  if (response.status === 429) this.#metrics.rateLimitedRequests++;
155
+ if (requestArg.redirect === 'return' && transport.redirectStatuses.has(response.status)) {
156
+ // The body is discarded unread by the `finally` below.
157
+ return { kind: 'redirect', status: response.status, location: response.headers.get('location') };
158
+ }
117
159
  if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
118
160
  if ((requestArg.requiredMediaType !== undefined && mediaTypeOf(response.headers) !== requestArg.requiredMediaType) || !bodyChunks) {
119
161
  throw new validation.ForgeInventoryError('invalid-content-type');
@@ -129,7 +171,7 @@ export class ForgeRequestClient {
129
171
  chunks.push(result.value);
130
172
  }
131
173
  if (controller.signal.aborted || Date.now() >= deadline) throw new validation.ForgeInventoryError('request-aborted-or-timed-out');
132
- return { bytes: Buffer.concat(chunks), headers: response.headers };
174
+ return { kind: 'bytes', bytes: Buffer.concat(chunks), headers: response.headers };
133
175
  } catch (error) {
134
176
  if (error instanceof validation.ForgeInventoryError) throw error;
135
177
  throw new validation.ForgeInventoryError(controller.signal.aborted ? 'request-aborted-or-timed-out' : 'request-or-json-failed');
@@ -8,16 +8,29 @@ export interface IForgeTransportResponse {
8
8
  body: AsyncIterable<Uint8Array> | null;
9
9
  }
10
10
 
11
+ /**
12
+ * What a transport does with a redirect status: `refuse` rejects it, `return`
13
+ * resolves it as the response it is, `Location` included. Neither follows it.
14
+ */
15
+ export type TForgeRedirectMode = 'refuse' | 'return';
16
+
17
+ export interface IForgeTransportOptions {
18
+ /** Defaults to `refuse`. */
19
+ redirect?: TForgeRedirectMode;
20
+ }
21
+
11
22
  /**
12
23
  * Sends one credential-scoped GET, or a POST of `body` when one is given, and
13
- * resolves once response headers arrive. A redirect status rejects and is
14
- * never followed; aborting `signal` discards the connection, also while the
15
- * body is still being read.
24
+ * resolves once response headers arrive. A redirect status is never followed:
25
+ * it rejects, unless `options.redirect` is `return`. Aborting `signal`
26
+ * discards the connection, also while the body is still being read.
16
27
  */
17
- export type TForgeTransport = (url: URL, headers: Record<string, string>, signal: AbortSignal, body?: Uint8Array<ArrayBuffer>) => Promise<IForgeTransportResponse>;
28
+ export type TForgeTransport = (
29
+ url: URL, headers: Record<string, string>, signal: AbortSignal, body?: Uint8Array<ArrayBuffer>, options?: IForgeTransportOptions,
30
+ ) => Promise<IForgeTransportResponse>;
18
31
 
19
32
  /** The fetch statuses whose redirect mode `error` turns them into a network error. */
20
- const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
33
+ export const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
21
34
 
22
35
  /**
23
36
  * Idle keep-alive sockets close after this delay, or earlier when the server
@@ -27,8 +40,10 @@ const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
27
40
  const idleSocketTimeoutMs = 4_000;
28
41
 
29
42
  /** Node's global `fetch` with the process-wide default trust store. */
30
- export const globalFetchTransport: TForgeTransport = async (url, headers, signal, body) => {
31
- const response = await fetch(url, { method: body ? 'POST' : 'GET', headers, body, redirect: 'error', credentials: 'omit', signal });
43
+ export const globalFetchTransport: TForgeTransport = async (url, headers, signal, body, options) => {
44
+ // Node's fetch answers `manual` with the redirect response itself, status and `Location` included.
45
+ const redirect = options?.redirect === 'return' ? 'manual' : 'error';
46
+ const response = await fetch(url, { method: body ? 'POST' : 'GET', headers, body, redirect, credentials: 'omit', signal });
32
47
  return { status: response.status, headers: response.headers, body: response.body };
33
48
  };
34
49
 
@@ -45,6 +60,8 @@ export interface INodeRequest {
45
60
  body?: Uint8Array | AsyncIterable<Uint8Array>;
46
61
  signal: AbortSignal;
47
62
  agents: INodeRequestAgents;
63
+ /** Defaults to `refuse`. */
64
+ redirect?: TForgeRedirectMode;
48
65
  }
49
66
 
50
67
  export interface INodeResponse extends IForgeTransportResponse {
@@ -74,9 +91,10 @@ const drained = (requestArg: InstanceType<typeof plugins.http.ClientRequest>): P
74
91
 
75
92
  /**
76
93
  * Sends one request through `node:http` or `node:https` and resolves once
77
- * response headers arrive. A redirect status rejects and is never followed;
78
- * aborting `signal` discards the connection, also while a body is still being
79
- * sent or read. A streamed body that fails destroys the request and rejects.
94
+ * response headers arrive. A redirect status is never followed: it rejects,
95
+ * unless `redirect` is `return`. Aborting `signal` discards the connection,
96
+ * also while a body is still being sent or read. A streamed body that fails
97
+ * destroys the request and rejects.
80
98
  */
81
99
  export const sendNodeRequest = (url: URL, requestArg: INodeRequest): Promise<INodeResponse> => new Promise<INodeResponse>((resolve, reject) => {
82
100
  const { signal } = requestArg;
@@ -126,7 +144,7 @@ export const sendNodeRequest = (url: URL, requestArg: INodeRequest): Promise<INo
126
144
  request.on('response', (response) => {
127
145
  try {
128
146
  const status = response.statusCode ?? 0;
129
- if (redirectStatuses.has(status)) throw new Error('Refused redirect.');
147
+ if (redirectStatuses.has(status) && requestArg.redirect !== 'return') throw new Error('Refused redirect.');
130
148
  // Same combination rules as fetch: repeated fields join with ", ".
131
149
  const responseHeaders = new Headers();
132
150
  for (let index = 0; index < response.rawHeaders.length; index += 2) {
@@ -149,8 +167,10 @@ export const sendNodeRequest = (url: URL, requestArg: INodeRequest): Promise<INo
149
167
  */
150
168
  export const createHttpsTransport = (caCertificates: string[]): TForgeTransport => {
151
169
  const agents = createNodeAgents(caCertificates);
152
- return async (url, headers, signal, body) => {
153
- const { sent: _sent, ...response } = await sendNodeRequest(url, { method: body ? 'POST' : 'GET', headers, body, signal, agents });
170
+ return async (url, headers, signal, body, options) => {
171
+ const { sent: _sent, ...response } = await sendNodeRequest(url, {
172
+ method: body ? 'POST' : 'GET', headers, body, signal, agents, redirect: options?.redirect,
173
+ });
154
174
  return response;
155
175
  };
156
176
  };
@@ -0,0 +1,97 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as validation from './forgeinventory.validation.js';
3
+
4
+ /**
5
+ * Package metadata documents that are JSON, which `canonicalPackageDocument`
6
+ * brings into one form: an npm version document, Swift release metadata, a
7
+ * crate version's line of the Cargo sparse index, and `{"description":…}`
8
+ * of a crate's latest version as the Cargo search serves it.
9
+ */
10
+ export type TGiteaCanonicalPackageDocumentKind = 'npm-version' | 'swift-release' | 'cargo-index-entry' | 'cargo-description';
11
+
12
+ export interface IGiteaCanonicalPackageDocument {
13
+ /** The canonical JSON, UTF-8. */
14
+ bytes: Buffer;
15
+ /** Lowercase hex SHA-256 of `bytes`. */
16
+ sha256: string;
17
+ }
18
+
19
+ /**
20
+ * The top-level member each kind removes: state the forge changes without
21
+ * replacing the version, which the source inventory records apart (a Cargo
22
+ * version's `yanked`, an npm version's `deprecated`).
23
+ */
24
+ const mutableStateMembers: Readonly<Record<TGiteaCanonicalPackageDocumentKind, string | null>> = Object.freeze({
25
+ 'npm-version': 'deprecated',
26
+ 'swift-release': null,
27
+ 'cargo-index-entry': 'yanked',
28
+ 'cargo-description': null,
29
+ });
30
+
31
+ /**
32
+ * Orders two strings by Unicode code point. UTF-16 code-unit order (the
33
+ * default sort) differs from it where a surrogate pair meets U+E000 to U+FFFF.
34
+ */
35
+ const compareCodePoints = (leftArg: string, rightArg: string): number => {
36
+ let left = 0;
37
+ let right = 0;
38
+ while (left < leftArg.length && right < rightArg.length) {
39
+ const leftPoint = leftArg.codePointAt(left)!;
40
+ const rightPoint = rightArg.codePointAt(right)!;
41
+ if (leftPoint !== rightPoint) return leftPoint - rightPoint;
42
+ left += leftPoint > 0xffff ? 2 : 1;
43
+ right += rightPoint > 0xffff ? 2 : 1;
44
+ }
45
+ return (leftArg.length - left) - (rightArg.length - right);
46
+ };
47
+
48
+ /** Compact JSON with object keys in code-point order at every depth, arrays in order and only safe integers as numbers. */
49
+ const canonicalJson = (valueArg: unknown): string => {
50
+ if (valueArg === null || typeof valueArg === 'boolean' || typeof valueArg === 'string') return JSON.stringify(valueArg);
51
+ if (typeof valueArg === 'number') {
52
+ if (!Number.isSafeInteger(valueArg)) throw new validation.ForgeInventoryError('document-not-canonical');
53
+ return JSON.stringify(valueArg);
54
+ }
55
+ if (Array.isArray(valueArg)) return `[${valueArg.map(canonicalJson).join(',')}]`;
56
+ const record = valueArg as Record<string, unknown>;
57
+ return `{${Object.keys(record).sort(compareCodePoints).map((keyArg) => `${JSON.stringify(keyArg)}:${canonicalJson(record[keyArg])}`).join(',')}}`;
58
+ };
59
+
60
+ /**
61
+ * The canonical form of a package metadata document and its SHA-256, which a
62
+ * consumer records and compares instead of the bytes Gitea served: Gitea 28
63
+ * writes JSON objects held in maps (a crate's `features`, npm's maps) in a
64
+ * different key order on every read, and documents carry state that changes
65
+ * without a new version. In this order: decode as UTF-8 (fatal), parse as a
66
+ * JSON object, remove the kind's top-level mutable-state member (`yanked`
67
+ * for `cargo-index-entry`, `deprecated` for `npm-version`), and write it
68
+ * compact, object keys sorted by code point at every depth, arrays in their
69
+ * order, strings and safe integers as `JSON.stringify` writes them. Anything
70
+ * else, a number that is not a safe integer included, rejects with
71
+ * `document-not-canonical`; an unknown kind with
72
+ * `package-document-kind-invalid`. The same document gives the same bytes
73
+ * whatever order its keys arrived in.
74
+ */
75
+ export const canonicalPackageDocument = (kindArg: TGiteaCanonicalPackageDocumentKind, bytesArg: Uint8Array): IGiteaCanonicalPackageDocument => {
76
+ if (!Object.hasOwn(mutableStateMembers, kindArg)) throw new validation.ForgeInventoryError('package-document-kind-invalid');
77
+ let canonical: string;
78
+ try {
79
+ const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytesArg)) as unknown;
80
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new validation.ForgeInventoryError('document-not-canonical');
81
+ const member = mutableStateMembers[kindArg];
82
+ if (member !== null) delete (parsed as Record<string, unknown>)[member];
83
+ canonical = canonicalJson(parsed);
84
+ } catch {
85
+ // Invalid UTF-8 or JSON, a non-object, an unsafe number, or nesting deeper than the stack.
86
+ throw new validation.ForgeInventoryError('document-not-canonical');
87
+ }
88
+ const bytes = Buffer.from(canonical, 'utf8');
89
+ return { bytes, sha256: plugins.crypto.createHash('sha256').update(bytes).digest('hex') };
90
+ };
91
+
92
+ /**
93
+ * A Swift version as Gitea keys an alternate manifest (`Package@swift-<v>.swift`),
94
+ * trimmed by its `TrimmedVersionString`: major and minor, and the patch only
95
+ * when it is not zero.
96
+ */
97
+ export const swiftManifestVersionPattern = /^(?:0|[1-9]\d{0,17})\.(?:0|[1-9]\d{0,17})(?:\.[1-9]\d{0,17})?$/;
package/ts/index.ts CHANGED
@@ -492,3 +492,5 @@ export * from './classes.giteasourcetransfer.js';
492
492
  export * from './classes.lfsbatchclient.js';
493
493
  export { GitScratchBudget, mirrorScratchBytes } from './giteasourceinventory.git.js';
494
494
  export { composeGiteaPackageContent } from './giteasourceinventory.packages.js';
495
+ export { canonicalPackageDocument } from './giteasourcetransfer.documents.js';
496
+ export type { IGiteaCanonicalPackageDocument, TGiteaCanonicalPackageDocumentKind } from './giteasourcetransfer.documents.js';