@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.
- package/changelog.md +24 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourcetransfer.d.ts +52 -15
- package/dist_ts/classes.giteasourcetransfer.js +123 -38
- package/dist_ts/forgeinventory.client.d.ts +21 -1
- package/dist_ts/forgeinventory.client.js +30 -5
- package/dist_ts/forgeinventory.transport.d.ts +21 -7
- package/dist_ts/forgeinventory.transport.js +15 -10
- package/dist_ts/giteasourcetransfer.documents.d.ts +35 -0
- package/dist_ts/giteasourcetransfer.documents.js +86 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/package.json +1 -1
- package/readme.md +10 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourcetransfer.ts +178 -41
- package/ts/forgeinventory.client.ts +48 -6
- package/ts/forgeinventory.transport.ts +33 -13
- package/ts/giteasourcetransfer.documents.ts +97 -0
- package/ts/index.ts +2 -0
|
@@ -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
|
|
103
|
-
*
|
|
104
|
-
* `Package.swift`, or
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
564
|
-
} else if (
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
|
646
|
+
let document: { bytes: Buffer; mediaType: string } | null;
|
|
578
647
|
try {
|
|
579
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
if (kindArg === '
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
+
location = null;
|
|
593
696
|
}
|
|
594
|
-
if (
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
return {
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
78
|
-
*
|
|
79
|
-
* sent or read. A streamed body that fails
|
|
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, {
|
|
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';
|