@foss.global/codefeed 5.2.0 → 5.4.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.
@@ -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');
@@ -1,5 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
+ import * as transport from './forgeinventory.transport.js';
2
3
  import * as validation from './forgeinventory.validation.js';
4
+ import type { IForgeRequestMetrics } from './forgeinventory.client.js';
3
5
 
4
6
  /** Response bytes read from an unused answer before the rest is discarded. */
5
7
  const maxDiscardedResponseBytes = 64 * 1024;
@@ -201,3 +203,89 @@ export const createTransferStream = (
201
203
  });
202
204
  return stream;
203
205
  };
206
+
207
+ /** Passes `sourceArg`'s chunks on, adding each one's bytes to `metricsArg` as it arrives. */
208
+ async function* countedBytes(sourceArg: AsyncIterable<Uint8Array>, metricsArg: IForgeRequestMetrics): AsyncGenerator<Uint8Array, void, undefined> {
209
+ for await (const chunk of sourceArg) {
210
+ if (chunk instanceof Uint8Array) metricsArg.responseBytes += chunk.byteLength;
211
+ yield chunk;
212
+ }
213
+ }
214
+
215
+ /** The `heldBackChunks` codes of a package content download. */
216
+ const packageContentCodes = {
217
+ invalidChunk: 'package-content-invalid-chunk', overrun: 'package-content-size-overrun', short: 'package-content-short-body',
218
+ };
219
+
220
+ /** The reader settings a verified package content download runs with. */
221
+ export interface IVerifiedPackageDownloadOptions {
222
+ /** PEM certificates that replace the default trust store for the download's connection. */
223
+ caCertificates?: string[];
224
+ /** Wall-clock limit of the whole download, the closing `identifiedArg` read included. */
225
+ packageTransferTimeoutMs: number;
226
+ /** Idle limit of the download, re-armed on every chunk and before the closing `identifiedArg` read. */
227
+ requestTimeoutMs: number;
228
+ /** Counts the request, a 429 answer and every body byte that arrives, as `ForgeRequestClient` counts its reads. */
229
+ metrics?: IForgeRequestMetrics;
230
+ }
231
+
232
+ /**
233
+ * Streams one package content download verified against `contentArg`, sent
234
+ * over node:https with the caller's CA certificates: the response must be a
235
+ * 200 whose `content-length`, when sent, is the size; the last chunk is held
236
+ * back until the size and SHA-256 match and `identifiedArg`, when given, has
237
+ * passed once more.
238
+ */
239
+ export const verifiedPackageDownload = async (
240
+ optionsArg: IVerifiedPackageDownloadOptions, urlArg: URL, headersArg: Record<string, string>,
241
+ contentArg: { sha256: string; size: string; bytes: number }, signalArg: AbortSignal | undefined,
242
+ identifiedArg: ((signalArg: AbortSignal) => Promise<void>) | null,
243
+ ): Promise<InstanceType<typeof plugins.stream.Readable>> => {
244
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
245
+ const agents = transport.createNodeAgents(optionsArg.caCertificates);
246
+ const close = () => agents.https?.destroy();
247
+ const transfer = createTransferBounds(signalArg, {
248
+ limitMs: optionsArg.packageTransferTimeoutMs, idleMs: optionsArg.requestTimeoutMs,
249
+ timedOutCode: 'package-content-transfer-timed-out', failedCode: 'package-content-transfer-failed',
250
+ });
251
+ const { metrics } = optionsArg;
252
+ let body: AsyncIterable<Uint8Array>;
253
+ try {
254
+ if (metrics) metrics.requests++;
255
+ const response = await transfer.run(() => transport.sendNodeRequest(urlArg, { method: 'GET', headers: headersArg, signal: transfer.signal, agents }));
256
+ if (metrics && response.status === 429) metrics.rateLimitedRequests++;
257
+ if (response.status !== 200 || !response.body) {
258
+ await transfer.run(() => discardBody(response.body));
259
+ throw new validation.ForgeInventoryError(`http-${response.status}`);
260
+ }
261
+ const length = response.headers.get('content-length');
262
+ if (length !== null && length.trim() !== contentArg.size) {
263
+ await transfer.run(() => discardBody(response.body));
264
+ throw new validation.ForgeInventoryError('package-content-size-mismatch');
265
+ }
266
+ body = metrics ? countedBytes(response.body, metrics) : response.body;
267
+ } catch (error) {
268
+ const failure = transfer.failure(error, 'package-content-download-failed');
269
+ transfer.end();
270
+ close();
271
+ throw failure;
272
+ }
273
+ const hash = plugins.crypto.createHash('sha256');
274
+ const complete = async () => {
275
+ if (hash.digest('hex') !== contentArg.sha256) throw new validation.ForgeInventoryError('package-content-digest-mismatch');
276
+ if (identifiedArg) {
277
+ transfer.touch();
278
+ await transfer.run(() => identifiedArg(transfer.signal));
279
+ }
280
+ };
281
+ const chunks = async function* (): AsyncGenerator<Uint8Array, void, undefined> {
282
+ try {
283
+ yield* heldBackChunks(transfer.watch(body), contentArg.bytes, packageContentCodes, { inspect: (chunkArg) => hash.update(chunkArg), complete });
284
+ } catch (error) {
285
+ throw transfer.failure(error, 'package-content-download-failed');
286
+ } finally {
287
+ transfer.end();
288
+ }
289
+ };
290
+ return createTransferStream(transfer, chunks(), close);
291
+ };
@@ -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
  };
@@ -1,5 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import * as validation from './forgeinventory.validation.js';
3
+ import type { ForgeRequestClient } from './forgeinventory.client.js';
4
+ import { swiftManifestKey } from './giteasourcetransfer.documents.js';
3
5
 
4
6
  type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
5
7
  type IPackageRef = plugins.fossInterfaces.data.IForgeSourcePackageRef;
@@ -64,6 +66,172 @@ export const swiftPackageParts = (nameArg: string): { scope: string; name: strin
64
66
  return { scope: match[1], name: match[2] };
65
67
  };
66
68
 
69
+ /**
70
+ * A Swift release's `Package.swift`, or with `swiftVersionArg` its alternate
71
+ * manifest, read with `clientArg`'s `token` authorization; null when Gitea
72
+ * redirects the alternate one to the release's own `Package.swift` (303),
73
+ * which is how it answers a version it holds no manifest for. No redirect is
74
+ * followed: any other redirect rejects with `swift-manifest-redirect-unexpected`,
75
+ * a redirect of `Package.swift` itself with `http-<status>`, and a body that
76
+ * is not `text/x-swift` with `invalid-content-type`. The read counts no
77
+ * download.
78
+ */
79
+ export const readSwiftManifest = async (
80
+ clientArg: ForgeRequestClient, baseUrlArg: string, ownerArg: string, partsArg: { scope: string; name: string }, versionArg: string,
81
+ swiftVersionArg: string | null, signalArg: AbortSignal | undefined,
82
+ ): Promise<{ bytes: Buffer; mediaType: string } | null> => {
83
+ const url = new URL(`${baseUrlArg}/api/packages/${encodeURIComponent(ownerArg)}/swift/${encodeURIComponent(partsArg.scope)}/${
84
+ encodeURIComponent(partsArg.name)}/${encodeURIComponent(versionArg)}/Package.swift`);
85
+ if (swiftVersionArg !== null) url.searchParams.set('swift-version', swiftVersionArg);
86
+ const response = await clientArg.requestBytesOrRedirect(url, { accept: 'application/vnd.swift.registry.v1+swift', signal: signalArg });
87
+ if (response.kind === 'redirect') {
88
+ if (swiftVersionArg === null) throw new validation.ForgeInventoryError(`http-${response.status}`);
89
+ // Gitea's answer for a missing alternate manifest: 303 to the release's
90
+ // `Package.swift` under its configured root, with the owner lowercased
91
+ // and scope, name and version as the request named them.
92
+ const expected = new URL(`${baseUrlArg}/api/packages/${ownerArg.toLowerCase()}/swift/${partsArg.scope}/${partsArg.name}/${versionArg}/Package.swift`);
93
+ let location: URL | null = null;
94
+ try {
95
+ location = response.location === null ? null : new URL(response.location, url);
96
+ } catch {
97
+ location = null;
98
+ }
99
+ if (response.status !== 303 || location === null || location.href !== expected.href) {
100
+ throw new validation.ForgeInventoryError('swift-manifest-redirect-unexpected');
101
+ }
102
+ return null;
103
+ }
104
+ if (response.mediaType !== 'text/x-swift') throw new validation.ForgeInventoryError('invalid-content-type');
105
+ return { bytes: response.bytes, mediaType: 'text/x-swift' };
106
+ };
107
+
108
+ /** Distinct manifest keys read per Swift version, `''` included; Gitea keeps at most 64 manifests (`maxManifestFiles`). */
109
+ const maxSwiftManifestCandidates = 64;
110
+
111
+ /**
112
+ * The name of every central-directory entry of a zip archive, in directory
113
+ * order, each byte one code unit (Go strings are bytes, and only ASCII names
114
+ * can match `manifestPattern`). It reads the directory as Go 1.27.2's
115
+ * `archive/zip` `NewReader` does, the archive/zip Gitea 28.2.0 builds with:
116
+ * - the end record is the last signature in the final 65 KiB whose comment
117
+ * fits in the archive;
118
+ * - its zip64 locator and end record are used when a count, size or offset
119
+ * is saturated and a locator for disk 0 of 1 sits right before it;
120
+ * - the base offset of data prepended to the archive is the end record's
121
+ * position minus the directory's size and offset, unless a directory
122
+ * header sits at the unadjusted offset;
123
+ * - headers are read one after another until one is malformed or cut off
124
+ * (a zip64 field too short for the saturated sizes and offset it stands
125
+ * for is malformed), and their count must agree with the end record's in
126
+ * its low 16 bits.
127
+ * Anything Go refuses rejects with `swift-archive-invalid`.
128
+ */
129
+ export const zipEntryNames = (archiveArg: Buffer): string[] => {
130
+ const invalid = () => new validation.ForgeInventoryError('swift-archive-invalid');
131
+ const size = archiveArg.byteLength;
132
+ const sizeBig = BigInt(size);
133
+ let endOffset = -1;
134
+ for (let index = size - 22; index >= Math.max(0, size - 65 * 1024); index--) {
135
+ if (archiveArg.readUInt32LE(index) === 0x06054b50) {
136
+ endOffset = index;
137
+ break;
138
+ }
139
+ }
140
+ if (endOffset < 0 || endOffset + 22 + archiveArg.readUInt16LE(endOffset + 20) > size) throw invalid();
141
+ let directoryEnd = BigInt(endOffset);
142
+ let records = BigInt(archiveArg.readUInt16LE(endOffset + 10));
143
+ let directorySize = BigInt(archiveArg.readUInt32LE(endOffset + 12));
144
+ let directoryOffset = BigInt(archiveArg.readUInt32LE(endOffset + 16));
145
+ if (records === 0xffffn || directorySize === 0xffffffffn || directoryOffset === 0xffffffffn) {
146
+ const locator = endOffset - 20;
147
+ if (locator >= 0 && archiveArg.readUInt32LE(locator) === 0x07064b50 && archiveArg.readUInt32LE(locator + 4) === 0
148
+ && archiveArg.readUInt32LE(locator + 16) === 1) {
149
+ // Go reads the offset as an int64: one past its range is negative and leaves the archive as zip32.
150
+ const record = archiveArg.readBigInt64LE(locator + 8);
151
+ if (record >= 0n) {
152
+ if (record + 56n > sizeBig || archiveArg.readUInt32LE(Number(record)) !== 0x06064b50) throw invalid();
153
+ directoryEnd = record;
154
+ records = archiveArg.readBigUInt64LE(Number(record) + 32);
155
+ directorySize = archiveArg.readBigUInt64LE(Number(record) + 40);
156
+ directoryOffset = archiveArg.readBigUInt64LE(Number(record) + 48);
157
+ }
158
+ }
159
+ }
160
+ if (directorySize > 0x7fffffffffffffffn || directoryOffset > 0x7fffffffffffffffn) throw invalid();
161
+ let baseOffset = directoryEnd - directorySize - directoryOffset;
162
+ if (baseOffset + directoryOffset < 0n || baseOffset + directoryOffset >= sizeBig) throw invalid();
163
+ if (baseOffset > 0n && directoryHeader(archiveArg, Number(directoryOffset)).kind === 'entry') baseOffset = 0n;
164
+ const names: string[] = [];
165
+ let offset = Number(baseOffset + directoryOffset);
166
+ while (true) {
167
+ const header = directoryHeader(archiveArg, offset);
168
+ if (header.kind === 'end') break;
169
+ // Go's `io.ReadFull` reports a read that gets no byte at all as `io.EOF`, which fails the archive.
170
+ if (header.kind === 'eof') throw invalid();
171
+ names.push(header.name);
172
+ offset = header.next;
173
+ }
174
+ if ((BigInt(names.length) & 0xffffn) !== (records & 0xffffn)) throw invalid();
175
+ return names;
176
+ };
177
+
178
+ /**
179
+ * The central-directory header at `offsetArg`, as Go 1.27.2's
180
+ * `readDirectoryHeader` reads it: `entry` with its name and the offset of the
181
+ * next header; `end` where Go stops reading headers (a header cut off by the
182
+ * archive's end, another signature, or a zip64 field shorter than 8 bytes for
183
+ * each saturated size and offset); `eof` where a read gets no byte at all.
184
+ */
185
+ const directoryHeader = (archiveArg: Buffer, offsetArg: number):
186
+ { kind: 'entry'; name: string; next: number } | { kind: 'end' } | { kind: 'eof' } => {
187
+ const available = archiveArg.byteLength - offsetArg;
188
+ if (available <= 0) return { kind: 'eof' };
189
+ if (available < 46) return { kind: 'end' };
190
+ if (archiveArg.readUInt32LE(offsetArg) !== 0x02014b50) return { kind: 'end' };
191
+ const nameLength = archiveArg.readUInt16LE(offsetArg + 28);
192
+ const extraLength = archiveArg.readUInt16LE(offsetArg + 30);
193
+ const commentLength = archiveArg.readUInt16LE(offsetArg + 32);
194
+ const variable = nameLength + extraLength + commentLength;
195
+ if (variable > 0 && available === 46) return { kind: 'eof' };
196
+ if (available - 46 < variable) return { kind: 'end' };
197
+ const saturated = [20, 24, 42].filter((fieldArg) => archiveArg.readUInt32LE(offsetArg + fieldArg) === 0xffffffff).length;
198
+ let extra = offsetArg + 46 + nameLength;
199
+ const extraEnd = extra + extraLength;
200
+ while (extraEnd - extra >= 4) {
201
+ const tag = archiveArg.readUInt16LE(extra);
202
+ const fieldSize = archiveArg.readUInt16LE(extra + 2);
203
+ extra += 4;
204
+ if (extraEnd - extra < fieldSize) break;
205
+ // The zip64 field holds 8 bytes for each saturated field, in order.
206
+ if (tag === 0x0001 && fieldSize < 8 * saturated) return { kind: 'end' };
207
+ extra += fieldSize;
208
+ }
209
+ return { kind: 'entry', name: archiveArg.toString('latin1', offsetArg + 46, offsetArg + 46 + nameLength), next: offsetArg + 46 + variable };
210
+ };
211
+
212
+ /**
213
+ * The keys of the manifests Gitea 28.2.0 may hold for a Swift source
214
+ * archive, sorted in code-unit order: `''`, and the key of every
215
+ * central-directory entry, in any directory, whose name does not end in `/`
216
+ * and whose base name matches Gitea's `manifestPattern` and parses
217
+ * (`swiftManifestKey`). Gitea keeps only the manifests of the shallowest
218
+ * directory, so the keys are a superset of what it holds, and its answer for
219
+ * each key decides. More than `maxSwiftManifestCandidates` distinct keys
220
+ * reject with `swift-manifest-candidates-over-bound`; an archive Go's
221
+ * `archive/zip` refuses with `swift-archive-invalid`.
222
+ */
223
+ export const swiftManifestCandidates = (archiveArg: Buffer): string[] => {
224
+ const keys = new Set<string>(['']);
225
+ for (const name of zipEntryNames(archiveArg)) {
226
+ if (name.endsWith('/')) continue;
227
+ const key = swiftManifestKey(name.slice(name.lastIndexOf('/') + 1));
228
+ if (key === null) continue;
229
+ keys.add(key);
230
+ if (keys.size > maxSwiftManifestCandidates) throw new validation.ForgeInventoryError('swift-manifest-candidates-over-bound');
231
+ }
232
+ return [...keys].sort(compareText);
233
+ };
234
+
67
235
  /**
68
236
  * The digest of the manifest a container version holds: `sha256:` and the
69
237
  * SHA-256 of the blob holding the version's `manifest.json`; null for a
@@ -0,0 +1,140 @@
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
+ * The largest segment Gitea's version parser reads: hashicorp/go-version
94
+ * (v1.9.0 in Gitea 28.2.0) parses every segment as a signed 64-bit integer
95
+ * and refuses a larger one.
96
+ */
97
+ const maxSwiftVersionSegment = 9223372036854775807n;
98
+
99
+ /** Major and minor, and an optional patch, in decimal without leading zeros. */
100
+ const trimmedSwiftVersionShape = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.([1-9]\d*))?$/;
101
+
102
+ /**
103
+ * Whether `valueArg` is a Swift version Gitea 28.2.0 can key an alternate
104
+ * manifest (`Package@swift-<v>.swift`) by. Gitea parses the file name's
105
+ * version with go-version's `NewSemver`, refuses an archive whose version
106
+ * does not parse (`ErrInvalidManifestVersion`), and keys the manifest by
107
+ * `TrimmedVersionString`: major and minor, and the patch only when it is not
108
+ * zero, each in decimal without leading zeros (`modules/packages/swift/metadata.go`).
109
+ * So a key has two or three segments, no leading zeros, no zero patch, and
110
+ * every segment at most 9223372036854775807, compared by value.
111
+ */
112
+ export const isSwiftManifestVersion = (valueArg: unknown): valueArg is string => {
113
+ if (typeof valueArg !== 'string') return false;
114
+ const match = trimmedSwiftVersionShape.exec(valueArg);
115
+ if (!match) return false;
116
+ return match.slice(1).every((segmentArg) => segmentArg === undefined || BigInt(segmentArg) <= maxSwiftVersionSegment);
117
+ };
118
+
119
+ /** Gitea 28.2.0's `manifestPattern` (`modules/packages/swift/metadata.go:27`), matched against a file's base name. */
120
+ const swiftManifestNamePattern = /^Package(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift$/;
121
+
122
+ /**
123
+ * The key Gitea 28.2.0 would hold a manifest with base name `baseNameArg`
124
+ * by: `''` for `Package.swift`; for `Package@swift-<v>.swift`, `<v>` parsed
125
+ * as go-version's `NewSemver` parses it (leading zeros allowed, every segment
126
+ * a signed 64-bit integer, missing segments zero) and written as
127
+ * `TrimmedVersionString` writes it, a key `isSwiftManifestVersion` accepts.
128
+ * Null when the name is not a manifest name, or when a segment is over int64,
129
+ * which `NewSemver` refuses, so Gitea can never hold the key.
130
+ */
131
+ export const swiftManifestKey = (baseNameArg: string): string | null => {
132
+ const match = swiftManifestNamePattern.exec(baseNameArg);
133
+ if (!match) return null;
134
+ if (match[1] === undefined) return '';
135
+ const segments = match[1].split('.').map((segmentArg) => BigInt(segmentArg));
136
+ if (segments.some((segmentArg) => segmentArg > maxSwiftVersionSegment)) return null;
137
+ const [major, minor = 0n, patch = 0n] = segments;
138
+ const key = patch === 0n ? `${major}.${minor}` : `${major}.${minor}.${patch}`;
139
+ return isSwiftManifestVersion(key) ? key : null;
140
+ };
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';