@foss.global/codefeed 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,763 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as transport from './forgeinventory.transport.js';
3
+ import * as validation from './forgeinventory.validation.js';
4
+ import { ForgeRequestClient } from './forgeinventory.client.js';
5
+ import { commitinfo } from './00_commitinfo_data.js';
6
+
7
+ type IQuiescenceReport = plugins.fossInterfaces.data.IForgeSourceQuiescenceReport;
8
+ type IQuiescenceRunner = plugins.fossInterfaces.data.IForgeSourceQuiescenceRunner;
9
+ type ICronTask = plugins.fossInterfaces.data.IForgeSourceCronTask;
10
+ type IQueuedRun = plugins.fossInterfaces.data.IForgeSourceQueuedRun;
11
+ type TQueuedRuns = plugins.fossInterfaces.data.TForgeSourceQueuedRuns;
12
+ type IJobsInProgress = plugins.fossInterfaces.data.IForgeSourceJobsInProgress;
13
+ type IFreezeOrigin = plugins.fossInterfaces.data.IForgeSourceFreezeOrigin;
14
+ type TFreezeCanary = plugins.fossInterfaces.data.TForgeSourceFreezeHttpCanary;
15
+ type ICanaryOutcome = plugins.fossInterfaces.data.IForgeSourceFreezeCanaryOutcome;
16
+ type TResponseHeaders = transport.IForgeTransportResponse['headers'];
17
+
18
+ /** Gitea's largest page (`MAX_RESPONSE_ITEMS`, 50 unless configured lower), which every quiescence listing asks for. */
19
+ const quiescencePageSize = 50;
20
+ /** Pages read per quiescence listing before it counts as unfinished. */
21
+ const quiescenceMaxPages = 1000;
22
+ /** The statuses Gitea 28.2.0 shows for the runs its `queued` and `pending` filters select (`services/convert/convert.go` `ToActionsStatus`). */
23
+ const queuedRunStatuses: ReadonlySet<string> = new Set(['queued', 'pending', 'requested', 'waiting']);
24
+ /** The header a frozen source's edge filter answers a refused request with; its value is the window's nonce. */
25
+ const freezeMarkerHeader = 'x-gitea-freeze';
26
+ /** The bound of every JSON body the probe reads: the preflight reads, the OCI token and LFS batch answers and a created record. */
27
+ const probeMaxResponseBytes = 4 * 1024 * 1024;
28
+ const lfsMediaType = 'application/vnd.git-lfs+json';
29
+ /** Every HTTP canary the probe knows, in the order of the interfaces union. */
30
+ const freezeCanaries: ReadonlySet<TFreezeCanary> = new Set<TFreezeCanary>([
31
+ 'receive-pack-discovery', 'archive-get', 'issue-post', 'release-post', 'generic-package-put', 'oci-blob-upload', 'lfs-put', 'lfs-off',
32
+ ]);
33
+ /** Action headers that set the connection or the body framing, which the probe sets itself. */
34
+ const reservedHeaderNames: ReadonlySet<string> = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'te', 'trailer', 'keep-alive', 'expect']);
35
+ const headerNamePattern = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
36
+ const maxActionHeaders = 32;
37
+ const maxHeaderValueLength = 8 * 1024;
38
+ const maxHrefLength = 8 * 1024;
39
+ /** Gitea's container upload id pattern (`routers/api/packages/api.go`, `<uuid:[-.=\w]+>`). */
40
+ const uploadIdPattern = /^[-.=\w]{1,256}$/;
41
+
42
+ const reason = (errorArg: unknown): string => errorArg instanceof validation.ForgeInventoryError ? errorArg.code : 'unexpected-error';
43
+
44
+ const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
45
+
46
+ /** Orders canonical nonnegative decimal strings numerically. */
47
+ const compareDecimal = (aArg: string, bArg: string): number => aArg.length - bArg.length || compareText(aArg, bArg);
48
+
49
+ /** Any string the forge reports, `''` included. */
50
+ const text = (valueArg: unknown): string => {
51
+ if (typeof valueArg !== 'string') throw new validation.ForgeInventoryError('invalid-string');
52
+ return valueArg;
53
+ };
54
+
55
+ const requiredBoolean = (valueArg: unknown): boolean => {
56
+ if (typeof valueArg !== 'boolean') throw new validation.ForgeInventoryError('invalid-boolean');
57
+ return valueArg;
58
+ };
59
+
60
+ /** One provider path segment, as an owner or repository name. */
61
+ const segment = (valueArg: unknown): string => {
62
+ const segments = validation.segments(valueArg);
63
+ if (segments.length !== 1) throw new validation.ForgeInventoryError('invalid-path');
64
+ return segments[0];
65
+ };
66
+
67
+ /** A token for an `authorization` header: no whitespace or control characters. */
68
+ const validToken = (valueArg: unknown): valueArg is string =>
69
+ typeof valueArg === 'string' && valueArg !== '' && !/[\s\u0000-\u001f\u007f]/.test(valueArg);
70
+
71
+ /** The deadline of one request; defaults to 60 s, at most 300 s. */
72
+ const requestTimeout = (valueArg: number | undefined): number => {
73
+ const value = valueArg ?? 60_000;
74
+ if (!Number.isSafeInteger(value) || value < 1 || value > 300_000) throw new validation.ForgeInventoryError('invalid-limits');
75
+ return value;
76
+ };
77
+
78
+ /** An HTTPS instance root without trailing slash, as in `IForgeSourceIdentity.baseUrl`; anything else is `invalid-options`. */
79
+ const httpsBaseUrl = (valueArg: unknown): string => {
80
+ let url: string | null;
81
+ try {
82
+ url = validation.httpUrl(valueArg);
83
+ } catch {
84
+ throw new validation.ForgeInventoryError('invalid-options');
85
+ }
86
+ if (url === null || !url.startsWith('https:')) throw new validation.ForgeInventoryError('invalid-options');
87
+ return url.replace(/\/+$/, '');
88
+ };
89
+
90
+ /** A listing read whole: its rows, or the forge's total alone once it is past the caller's bound. */
91
+ type TPagedRows = { status: 'complete'; rows: unknown[] } | { status: 'overBound'; total: string };
92
+
93
+ /** One page of a listing: its rows and the total the forge reported with them. */
94
+ type TPageReader = (responseArg: { body: unknown; headers: TResponseHeaders }) => { rows: unknown[]; total: string };
95
+
96
+ /** A Gitea Actions envelope page: `{ <rows>: [...], total_count }`. */
97
+ const envelope = (rowsKeyArg: string): TPageReader => (responseArg) => {
98
+ const body = validation.record(responseArg.body);
99
+ const rows = body[rowsKeyArg];
100
+ if (!Array.isArray(rows)) throw new validation.ForgeInventoryError('invalid-page');
101
+ return { rows, total: validation.decimal(body.total_count) };
102
+ };
103
+
104
+ /** A plain array page whose total is the `X-Total-Count` header. */
105
+ const headerTotal: TPageReader = (responseArg) => {
106
+ if (!Array.isArray(responseArg.body)) throw new validation.ForgeInventoryError('invalid-page');
107
+ const total = responseArg.headers.get('x-total-count');
108
+ if (total === null) throw new validation.ForgeInventoryError('total-missing');
109
+ return { rows: responseArg.body, total: validation.decimal(total) };
110
+ };
111
+
112
+ /** The list unchanged; a key listed twice means the listing shifted under its pages. */
113
+ const unique = <T>(itemsArg: T[], keyArg: (itemArg: T) => string, codeArg: string): T[] => {
114
+ if (new Set(itemsArg.map(keyArg)).size !== itemsArg.length) throw new validation.ForgeInventoryError(codeArg);
115
+ return itemsArg;
116
+ };
117
+
118
+ export interface IGiteaQuiescenceReaderOptions {
119
+ /** Operator-assigned identity of the source instance. */
120
+ instanceId: string;
121
+ /** Instance root, including an optional deployment subpath. HTTPS is required. */
122
+ baseUrl: string;
123
+ /**
124
+ * A site administrator's token, the connection's read-only one, retained
125
+ * only in this reader's memory. It is sent only to `baseUrl`, only in GET
126
+ * requests, and never appears in reports, reasons or errors.
127
+ */
128
+ token: string;
129
+ /** PEM certificates that replace the default trust store for this reader's connections. */
130
+ caCertificates?: string[];
131
+ /** Deadline of one request; defaults to 60 s, at most 300 s. */
132
+ requestTimeoutMs?: number;
133
+ }
134
+
135
+ /**
136
+ * Reads the writers of a Gitea source for a freeze proof, with GET requests
137
+ * and a site administrator's token only: every enabled Actions runner, the
138
+ * jobs in progress, the cron tasks and the runs waiting to start. It reports
139
+ * facts only; whether the source is quiescent is the consumer's decision.
140
+ */
141
+ export class GiteaQuiescenceReader {
142
+ readonly #options: { instanceId: string; baseUrl: string };
143
+ readonly #client: ForgeRequestClient;
144
+
145
+ constructor(optionsArg: IGiteaQuiescenceReaderOptions) {
146
+ if (typeof optionsArg?.instanceId !== 'string' || !optionsArg.instanceId.trim() || !validToken(optionsArg.token)) {
147
+ throw new validation.ForgeInventoryError('invalid-options');
148
+ }
149
+ const baseUrl = httpsBaseUrl(optionsArg.baseUrl);
150
+ const requestTimeoutMs = requestTimeout(optionsArg.requestTimeoutMs);
151
+ const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
152
+ this.#options = { instanceId: optionsArg.instanceId, baseUrl };
153
+ this.#client = new ForgeRequestClient({
154
+ transport: caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates),
155
+ headers: { authorization: `token ${optionsArg.token}` },
156
+ requestTimeoutMs,
157
+ maxResponseBytes: 16 * 1024 * 1024,
158
+ });
159
+ }
160
+
161
+ /** Validated, normalized instance root; contains no credentials or API suffix. */
162
+ public get baseUrl(): string { return this.#options.baseUrl; }
163
+
164
+ /**
165
+ * Reads the report. Rejects unless the token belongs to a site
166
+ * administrator (`reader-not-site-admin`) and the forge reports a version;
167
+ * a part whose read does not finish is null, with `<part>: <code>` in
168
+ * `incompleteReasons`. Queued runs past
169
+ * `forgeSourceQuiescenceQueuedRunMaxRecords` are `overBound` with the
170
+ * forge's count and are not read further. An abort rejects with
171
+ * `request-aborted`.
172
+ */
173
+ public async read(signalArg?: AbortSignal): Promise<IQuiescenceReport> {
174
+ const reader = validation.record((await this.#get('/user', [], signalArg)).body);
175
+ if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
176
+ const version = validation.record((await this.#get('/version', [], signalArg)).body).version;
177
+ if (typeof version !== 'string' || version.length > 128 || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9][A-Za-z0-9.+-]*)?$/.test(version)) {
178
+ throw new validation.ForgeInventoryError('invalid-server-version');
179
+ }
180
+ const incompleteReasons: string[] = [];
181
+ const part = async <T>(nameArg: string, readArg: () => Promise<T>): Promise<T | null> => {
182
+ try {
183
+ return await readArg();
184
+ } catch (error) {
185
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
186
+ incompleteReasons.push(`${nameArg}: ${reason(error)}`);
187
+ return null;
188
+ }
189
+ };
190
+ const enabledRunners = await part('enabledRunners', () => this.#enabledRunners(signalArg));
191
+ const jobsInProgress = await part('jobsInProgress', () => this.#jobsInProgress(signalArg));
192
+ const cron = await part('cron', () => this.#cron(signalArg));
193
+ const queuedRuns = await part('queuedRuns', () => this.#queuedRuns(signalArg));
194
+ return {
195
+ generator: { package: commitinfo.name, version: commitinfo.version },
196
+ source: { instanceId: this.#options.instanceId, kind: 'gitea', baseUrl: this.#options.baseUrl, version },
197
+ enabledRunners,
198
+ jobsInProgress,
199
+ cron,
200
+ queuedRuns,
201
+ incompleteReasons: incompleteReasons.sort(compareText),
202
+ };
203
+ }
204
+
205
+ /** Runners of every scope with `disabled=false`, every page until the forge's `total_count` is covered. */
206
+ async #enabledRunners(signalArg?: AbortSignal): Promise<IQuiescenceRunner[]> {
207
+ const listing = await this.#pages('/admin/actions/runners', [['disabled', 'false']], envelope('runners'), null, signalArg);
208
+ if (listing.status !== 'complete') throw new validation.ForgeInventoryError('invalid-page');
209
+ const runners = listing.rows.map((rowArg) => {
210
+ const row = validation.record(rowArg);
211
+ // A disabled runner in this listing means the forge ignored the filter, so the list is not the one asked for.
212
+ if (row.disabled !== false) throw new validation.ForgeInventoryError('runner-filter-ignored');
213
+ return { id: validation.decimal(row.id), name: text(row.name), status: text(row.status), busy: requiredBoolean(row.busy) };
214
+ });
215
+ return unique(runners, (runnerArg) => runnerArg.id, 'duplicate-id').sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
216
+ }
217
+
218
+ /** The forge's count of jobs its `in_progress` filter selects; Gitea's cancelling jobs are in no filter. */
219
+ async #jobsInProgress(signalArg?: AbortSignal): Promise<IJobsInProgress> {
220
+ const body = validation.record((await this.#get('/admin/actions/jobs', [['status', 'in_progress'], ['limit', '1'], ['page', '1']], signalArg)).body);
221
+ if (!Array.isArray(body.jobs) || body.jobs.length > 1) throw new validation.ForgeInventoryError('invalid-page');
222
+ // A listed job in another state means the forge ignored the filter, so its total counts something else.
223
+ if (body.jobs.some((jobArg) => validation.record(jobArg).status !== 'in_progress')) throw new validation.ForgeInventoryError('job-filter-ignored');
224
+ return { total: validation.decimal(body.total_count), blindSpot: 'cancelling' };
225
+ }
226
+
227
+ async #cron(signalArg?: AbortSignal): Promise<ICronTask[]> {
228
+ const listing = await this.#pages('/admin/cron', [], headerTotal, null, signalArg);
229
+ if (listing.status !== 'complete') throw new validation.ForgeInventoryError('invalid-page');
230
+ const tasks = listing.rows.map((rowArg) => {
231
+ const row = validation.record(rowArg);
232
+ return { name: text(row.name), schedule: text(row.schedule) };
233
+ });
234
+ return unique(tasks, (taskArg) => taskArg.name, 'duplicate-name').sort((aArg, bArg) => compareText(aArg.name, bArg.name));
235
+ }
236
+
237
+ /** Runs the forge lists as queued or pending, in one listing of both status filters. */
238
+ async #queuedRuns(signalArg?: AbortSignal): Promise<TQueuedRuns> {
239
+ const listing = await this.#pages('/admin/actions/runs', [['status', 'queued'], ['status', 'pending']], envelope('workflow_runs'),
240
+ plugins.fossInterfaces.data.forgeSourceQuiescenceQueuedRunMaxRecords, signalArg);
241
+ if (listing.status === 'overBound') return listing;
242
+ const runs = listing.rows.map((rowArg): IQueuedRun => {
243
+ const row = validation.record(rowArg);
244
+ const status = text(row.status);
245
+ // A run in another state means the forge ignored the filter, so the list is not the one asked for.
246
+ if (!queuedRunStatuses.has(status)) throw new validation.ForgeInventoryError('run-filter-ignored');
247
+ // Gitea 28.2.0 reports the run's own `repository_id` as 0; the embedded repository carries the id and the name.
248
+ const repository = validation.record(row.repository);
249
+ const fullName = validation.segments(repository.full_name);
250
+ if (fullName.length !== 2) throw new validation.ForgeInventoryError('invalid-path');
251
+ return {
252
+ id: validation.decimal(row.id),
253
+ runNumber: validation.decimal(row.run_number),
254
+ repositoryId: validation.decimal(repository.id),
255
+ repository: fullName.join('/'),
256
+ event: text(row.event),
257
+ // Omitted while empty, as for a run on a tag.
258
+ ref: row.head_branch === undefined ? '' : text(row.head_branch),
259
+ sha: text(row.head_sha),
260
+ status,
261
+ };
262
+ });
263
+ return { status: 'complete', runs: unique(runs, (runArg) => runArg.id, 'duplicate-id').sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)) };
264
+ }
265
+
266
+ /**
267
+ * Reads every page of a listing at Gitea's largest page size until its
268
+ * rows cover the total the forge reports with every page. Rejects when a
269
+ * page fails or is malformed (`invalid-page`), the total changes between
270
+ * pages (`total-changed`), a page ends the listing early or the rows
271
+ * overrun the total (`count-mismatch`), or the page bound is reached
272
+ * (`page-limit`). With `maxRowsArg`, a first page reporting a larger total
273
+ * ends the listing as `overBound`.
274
+ */
275
+ async #pages(pathArg: string, queryArg: Array<[string, string]>, pageArg: TPageReader, maxRowsArg: number | null, signalArg?: AbortSignal): Promise<TPagedRows> {
276
+ const rows: unknown[] = [];
277
+ let total: bigint | null = null;
278
+ for (let page = 1; ; page++) {
279
+ if (page > quiescenceMaxPages) throw new validation.ForgeInventoryError('page-limit');
280
+ const answer = pageArg(await this.#get(pathArg, [...queryArg, ['limit', String(quiescencePageSize)], ['page', String(page)]], signalArg));
281
+ if (answer.rows.length > quiescencePageSize) throw new validation.ForgeInventoryError('invalid-page');
282
+ const pageTotal = BigInt(answer.total);
283
+ if (total !== null && pageTotal !== total) throw new validation.ForgeInventoryError('total-changed');
284
+ total = pageTotal;
285
+ if (maxRowsArg !== null && total > BigInt(maxRowsArg)) return { status: 'overBound', total: answer.total };
286
+ if (answer.rows.length === 0) break;
287
+ rows.push(...answer.rows);
288
+ if (BigInt(rows.length) >= total) break;
289
+ }
290
+ if (BigInt(rows.length) !== total) throw new validation.ForgeInventoryError('count-mismatch');
291
+ return { status: 'complete', rows };
292
+ }
293
+
294
+ #get(pathArg: string, queryArg: Array<[string, string]>, signalArg?: AbortSignal) {
295
+ const url = new URL(`${this.#options.baseUrl}/api/v1${pathArg}`);
296
+ for (const [key, value] of queryArg) url.searchParams.append(key, value);
297
+ return this.#client.request(url, { signal: signalArg });
298
+ }
299
+ }
300
+
301
+ export interface IGiteaFreezeProbeOptions {
302
+ /**
303
+ * The canary credential's token, never the connection's administrator
304
+ * token, retained only in this probe's memory: a restricted,
305
+ * non-administrator account whose only organization is the canary
306
+ * repository's owner and whose only repository is the canary repository.
307
+ * It is sent only to the origins of a run and never appears in outcomes or
308
+ * errors.
309
+ */
310
+ token: string;
311
+ /** The canary repository; its owner is the canary organization, and its generic and container packages carry its name. */
312
+ repository: { owner: string; name: string };
313
+ /** PEM certificates that replace the default trust store for the probe's connections. */
314
+ caCertificates?: string[];
315
+ /** Deadline of one request, its response body included; defaults to 60 s, at most 300 s. */
316
+ requestTimeoutMs?: number;
317
+ }
318
+
319
+ export interface IGiteaFreezeProbeRunOptions {
320
+ /** The network paths to send the canaries through, each at most once; all must reach the same instance. */
321
+ origins: IFreezeOrigin[];
322
+ /** The canaries each origin gets, in order, each at most once: the consumer's canary configuration. */
323
+ canaries: TFreezeCanary[];
324
+ signal?: AbortSignal;
325
+ }
326
+
327
+ /** What the preflight read through one origin. */
328
+ interface IPreflight {
329
+ /** The canary account's id and login. */
330
+ userId: string;
331
+ login: string;
332
+ /** The canary repository's id and default branch. */
333
+ repositoryId: string;
334
+ defaultBranch: string;
335
+ }
336
+
337
+ /** One origin of a run: its normalized form, its root, the agents that reach it and its preflight. */
338
+ interface IOriginContext extends IPreflight {
339
+ origin: IFreezeOrigin;
340
+ /** `baseUrl` without trailing slash. */
341
+ base: string;
342
+ agents: transport.INodeRequestAgents;
343
+ }
344
+
345
+ interface IProbeRequest {
346
+ method: 'GET' | 'POST' | 'PUT';
347
+ url: URL;
348
+ headers: Record<string, string>;
349
+ body?: Uint8Array<ArrayBuffer>;
350
+ }
351
+
352
+ /** What a canary's own request answered, before it becomes an outcome. */
353
+ type TCanaryResult = Omit<ICanaryOutcome, 'canary' | 'origin'>;
354
+
355
+ /** A canary object: fresh bytes naming the nonce, their SHA-256 and size. */
356
+ const canaryObject = (nonceArg: string) => {
357
+ const bytes = new TextEncoder().encode(`freeze canary ${nonceArg}\n`);
358
+ return { bytes, oid: plugins.crypto.createHash('sha256').update(bytes).digest('hex'), size: bytes.byteLength };
359
+ };
360
+
361
+ const utf8Json = (bytesArg: Buffer): unknown => JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytesArg));
362
+
363
+ /** A failed step: no status, no marker, nothing written. */
364
+ const failed = (errorCodeArg: string): TCanaryResult => ({ httpStatus: null, freezeMarker: null, wrote: null, errorCode: errorCodeArg });
365
+
366
+ /**
367
+ * Sends a Gitea source's HTTP freeze canaries for a freeze proof, with the
368
+ * canary credential only. Before any write it reads `/user`, `/user/orgs` and
369
+ * `/user/repos` through every origin of the run and refuses a credential that
370
+ * reaches more than the canary repository (`canary-credential-overbroad`).
371
+ * Each canary targets the canary repository or its packages, and every write
372
+ * carries a fresh nonce. It reports what each canary's own request answered:
373
+ * the status, the `X-Gitea-Freeze` header verbatim and what a 2xx created. It
374
+ * never returns a response body or a credential, never follows a redirect and
375
+ * never judges the marker.
376
+ */
377
+ export class GiteaFreezeProbe {
378
+ readonly #options: { token: string; owner: string; name: string; caCertificates?: string[]; requestTimeoutMs: number };
379
+
380
+ constructor(optionsArg: IGiteaFreezeProbeOptions) {
381
+ if (!validToken(optionsArg?.token)) throw new validation.ForgeInventoryError('invalid-options');
382
+ let owner: string;
383
+ let name: string;
384
+ try {
385
+ owner = segment(optionsArg.repository?.owner);
386
+ name = segment(optionsArg.repository?.name);
387
+ } catch {
388
+ throw new validation.ForgeInventoryError('invalid-options');
389
+ }
390
+ const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
391
+ this.#options = { token: optionsArg.token, owner, name, caCertificates, requestTimeoutMs: requestTimeout(optionsArg.requestTimeoutMs) };
392
+ }
393
+
394
+ /**
395
+ * Runs the preflight through every origin, then sends each canary through
396
+ * each origin, origins and canaries in the order given, and returns one
397
+ * outcome per pair. A preflight that does not pass rejects before any
398
+ * canary is sent: `canary-credential-overbroad` when the account is an
399
+ * administrator, is not restricted, or reaches another organization or
400
+ * repository; `canary-credential-mismatch` when it reaches no organization
401
+ * or repository at all; `canary-origin-mismatch` when two origins name a
402
+ * different account or canary repository id; a failed read with its code.
403
+ * An abort rejects with `request-aborted`; every other failure is an
404
+ * outcome's `errorCode`.
405
+ */
406
+ public async run(optionsArg: IGiteaFreezeProbeRunOptions): Promise<ICanaryOutcome[]> {
407
+ const origins = this.#origins(optionsArg?.origins);
408
+ const canaries: unknown = optionsArg.canaries;
409
+ if (!Array.isArray(canaries) || canaries.length === 0 || new Set(canaries).size !== canaries.length ||
410
+ canaries.some((canaryArg) => !freezeCanaries.has(canaryArg))) {
411
+ throw new validation.ForgeInventoryError('invalid-options');
412
+ }
413
+ const signal = optionsArg.signal;
414
+ const aborted = () => {
415
+ if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
416
+ };
417
+ aborted();
418
+ const agents = origins.map((originArg) => transport.createNodeAgents(this.#options.caCertificates, false, originArg.connectAddress));
419
+ try {
420
+ const contexts: IOriginContext[] = [];
421
+ for (const [index, origin] of origins.entries()) {
422
+ const preflight = await this.#preflight(origin.baseUrl, agents[index], signal).catch((error: unknown) => {
423
+ aborted();
424
+ throw error;
425
+ });
426
+ contexts.push({ origin, base: origin.baseUrl, agents: agents[index], ...preflight });
427
+ }
428
+ // Every origin is a path to one instance, so each must show the same account and the same canary repository.
429
+ if (contexts.some((contextArg) => contextArg.userId !== contexts[0].userId || contextArg.repositoryId !== contexts[0].repositoryId)) {
430
+ throw new validation.ForgeInventoryError('canary-origin-mismatch');
431
+ }
432
+ const outcomes: ICanaryOutcome[] = [];
433
+ for (const context of contexts) {
434
+ for (const canary of canaries as TFreezeCanary[]) {
435
+ const result = await this.#canary(context, canary, signal);
436
+ aborted();
437
+ outcomes.push({ canary, origin: { ...context.origin }, ...result });
438
+ }
439
+ }
440
+ return outcomes;
441
+ } finally {
442
+ for (const agent of agents) agent.https?.destroy();
443
+ }
444
+ }
445
+
446
+ /** Validated origins, each normalized and at most once. */
447
+ #origins(originsArg: unknown): IFreezeOrigin[] {
448
+ if (!Array.isArray(originsArg) || originsArg.length === 0) throw new validation.ForgeInventoryError('invalid-options');
449
+ const origins = originsArg.map((originArg: unknown): IFreezeOrigin => {
450
+ let origin: Record<string, unknown>;
451
+ try {
452
+ origin = validation.record(originArg);
453
+ } catch {
454
+ throw new validation.ForgeInventoryError('invalid-options');
455
+ }
456
+ const baseUrl = httpsBaseUrl(origin.baseUrl);
457
+ if (origin.connectAddress === undefined) return { baseUrl };
458
+ const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, '');
459
+ // A connect address replaces the DNS answer for a name; an IP literal host has none to replace.
460
+ if (typeof origin.connectAddress !== 'string' || plugins.net.isIP(origin.connectAddress) === 0 || plugins.net.isIP(host) !== 0) {
461
+ throw new validation.ForgeInventoryError('invalid-options');
462
+ }
463
+ return { baseUrl, connectAddress: origin.connectAddress };
464
+ });
465
+ const keys = new Set(origins.map((originArg) => `${originArg.baseUrl} ${originArg.connectAddress ?? ''}`));
466
+ if (keys.size !== origins.length) throw new validation.ForgeInventoryError('invalid-options');
467
+ return origins;
468
+ }
469
+
470
+ /**
471
+ * Reads, with the token as a REST credential, the account (`is_admin`
472
+ * false, `restricted` true), its organizations (exactly the canary
473
+ * organization) and its repositories (exactly the canary repository).
474
+ * Both listings must carry Gitea's `X-Total-Count`.
475
+ */
476
+ async #preflight(baseArg: string, agentsArg: transport.INodeRequestAgents, signalArg?: AbortSignal): Promise<IPreflight> {
477
+ const client = new ForgeRequestClient({
478
+ transport: transport.createAgentTransport(agentsArg),
479
+ headers: { authorization: `token ${this.#options.token}` },
480
+ requestTimeoutMs: this.#options.requestTimeoutMs,
481
+ maxResponseBytes: probeMaxResponseBytes,
482
+ });
483
+ const api = (pathArg: string, pagedArg: boolean) => {
484
+ const url = new URL(`${baseArg}/api/v1${pathArg}`);
485
+ // Two rows show whether there is more than one.
486
+ if (pagedArg) url.search = new URLSearchParams({ limit: '2', page: '1' }).toString();
487
+ return url;
488
+ };
489
+ const user = validation.record((await client.request(api('/user', false), { signal: signalArg })).body);
490
+ if (user.is_admin !== false || user.restricted !== true) throw new validation.ForgeInventoryError('canary-credential-overbroad');
491
+ const userId = validation.decimal(user.id);
492
+ const login = segment(user.login);
493
+ const only = async (pathArg: string, matchesArg: (rowArg: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> => {
494
+ const response = await client.request(api(pathArg, true), { signal: signalArg });
495
+ if (!Array.isArray(response.body)) throw new validation.ForgeInventoryError('invalid-page');
496
+ const totalHeader = response.headers.get('x-total-count');
497
+ if (totalHeader === null) throw new validation.ForgeInventoryError('total-missing');
498
+ const total = BigInt(validation.decimal(totalHeader));
499
+ if (total > 1n) throw new validation.ForgeInventoryError('canary-credential-overbroad');
500
+ if (BigInt(response.body.length) !== total) throw new validation.ForgeInventoryError('count-mismatch');
501
+ if (total === 0n) throw new validation.ForgeInventoryError('canary-credential-mismatch');
502
+ const row = validation.record(response.body[0]);
503
+ if (!matchesArg(row)) throw new validation.ForgeInventoryError('canary-credential-overbroad');
504
+ return row;
505
+ };
506
+ await only('/user/orgs', (rowArg) => rowArg.name === this.#options.owner);
507
+ const repository = await only('/user/repos', (rowArg) =>
508
+ typeof rowArg.owner === 'object' && rowArg.owner !== null && (rowArg.owner as Record<string, unknown>).login === this.#options.owner &&
509
+ rowArg.name === this.#options.name);
510
+ const defaultBranch = validation.optionalString(repository.default_branch);
511
+ if (defaultBranch === null || defaultBranch.split('/').some((partArg) => !partArg || partArg === '.' || partArg === '..')) {
512
+ throw new validation.ForgeInventoryError('invalid-default-branch');
513
+ }
514
+ return { userId, login, repositoryId: validation.decimal(repository.id), defaultBranch };
515
+ }
516
+
517
+ async #canary(contextArg: IOriginContext, canaryArg: TFreezeCanary, signalArg?: AbortSignal): Promise<TCanaryResult> {
518
+ const { owner, name } = this.#options;
519
+ const repositoryPath = `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
520
+ const api = `${contextArg.base}/api/v1/repos/${repositoryPath}`;
521
+ const rest = { authorization: `token ${this.#options.token}` };
522
+ const basic = { authorization: `Basic ${Buffer.from(`${contextArg.login}:${this.#options.token}`).toString('base64')}` };
523
+ const json = (valueArg: unknown) => ({ body: new TextEncoder().encode(JSON.stringify(valueArg)), headers: { accept: 'application/json', 'content-type': 'application/json' } });
524
+ const nonce = plugins.crypto.randomBytes(16).toString('hex');
525
+ switch (canaryArg) {
526
+ case 'receive-pack-discovery':
527
+ return this.#send(contextArg, {
528
+ method: 'GET', url: new URL(`${contextArg.base}/${repositoryPath}.git/info/refs?service=git-receive-pack`), headers: { ...basic },
529
+ }, null, signalArg);
530
+ case 'archive-get': {
531
+ const archive = `${contextArg.defaultBranch}.zip`;
532
+ return this.#send(contextArg, {
533
+ method: 'GET', url: new URL(`${api}/archive/${archive.split('/').map(encodeURIComponent).join('/')}`), headers: { ...rest },
534
+ }, async () => archive, signalArg);
535
+ }
536
+ case 'issue-post': {
537
+ const request = json({ title: `Freeze canary ${nonce}`, body: 'A freeze proof canary.' });
538
+ return this.#send(contextArg, { method: 'POST', url: new URL(`${api}/issues`), headers: { ...request.headers, ...rest }, body: request.body },
539
+ async (readArg) => validation.decimal(validation.record(utf8Json(await readArg())).number), signalArg);
540
+ }
541
+ case 'release-post': {
542
+ const request = json({
543
+ tag_name: `freeze-canary-${nonce}`, target_commitish: contextArg.defaultBranch, name: `Freeze canary ${nonce}`, body: '', draft: false, prerelease: false,
544
+ });
545
+ return this.#send(contextArg, { method: 'POST', url: new URL(`${api}/releases`), headers: { ...request.headers, ...rest }, body: request.body },
546
+ async (readArg) => validation.decimal(validation.record(utf8Json(await readArg())).id), signalArg);
547
+ }
548
+ case 'generic-package-put': {
549
+ const object = canaryObject(nonce);
550
+ return this.#send(contextArg, {
551
+ method: 'PUT', url: new URL(`${contextArg.base}/api/packages/${encodeURIComponent(owner)}/generic/${encodeURIComponent(name)}/${nonce}/canary.txt`),
552
+ headers: { ...basic, 'content-type': 'application/octet-stream', 'content-length': String(object.size) }, body: object.bytes,
553
+ }, async () => nonce, signalArg);
554
+ }
555
+ case 'oci-blob-upload':
556
+ return this.#ociBlobUpload(contextArg, basic, signalArg);
557
+ case 'lfs-put':
558
+ return this.#lfsPut(contextArg, basic, canaryObject(nonce), signalArg);
559
+ case 'lfs-off':
560
+ return this.#send(contextArg, this.#lfsBatch(contextArg, basic, canaryObject(nonce)), null, signalArg);
561
+ }
562
+ }
563
+
564
+ /** The registry token `GET`, then the upload `POST` with it; a failed token read is the outcome's `errorCode` alone. */
565
+ async #ociBlobUpload(contextArg: IOriginContext, basicArg: Record<string, string>, signalArg?: AbortSignal): Promise<TCanaryResult> {
566
+ const { owner, name } = this.#options;
567
+ // `/v2` sits at the origin's root, whatever the instance's subpath.
568
+ const root = new URL(contextArg.base).origin;
569
+ let registryToken: string;
570
+ try {
571
+ registryToken = await this.#exchange(contextArg, {
572
+ method: 'GET', url: new URL(`${root}/v2/token`), headers: { ...basicArg, accept: 'application/json' },
573
+ }, signalArg, async (responseArg, readArg) => {
574
+ if (responseArg.status !== 200) throw new validation.ForgeInventoryError(`http-${responseArg.status}`);
575
+ let value: unknown;
576
+ try {
577
+ value = validation.record(utf8Json(await readArg())).token;
578
+ } catch {
579
+ throw new validation.ForgeInventoryError('invalid-token-response');
580
+ }
581
+ if (!validToken(value)) throw new validation.ForgeInventoryError('invalid-token-response');
582
+ return value;
583
+ });
584
+ } catch (error) {
585
+ if (signalArg?.aborted) return failed('request-aborted');
586
+ return failed(`oci-token: ${reason(error)}`);
587
+ }
588
+ return this.#send(contextArg, {
589
+ method: 'POST', url: new URL(`${root}/v2/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/blobs/uploads/`),
590
+ headers: { authorization: `Bearer ${registryToken}`, 'content-length': '0' }, body: new Uint8Array(0),
591
+ }, async (_readArg, headersArg) => {
592
+ const uploadId = headersArg.get('docker-upload-uuid');
593
+ if (uploadId === null || !uploadIdPattern.test(uploadId)) throw new validation.ForgeInventoryError('invalid-upload-id');
594
+ return uploadId;
595
+ }, signalArg);
596
+ }
597
+
598
+ /**
599
+ * The batch `upload` request for a fresh object, then the object `PUT` to
600
+ * the upload action with the action's headers only. The action must point
601
+ * at the origin the batch went to; a failed batch is the outcome's
602
+ * `errorCode` alone.
603
+ */
604
+ async #lfsPut(contextArg: IOriginContext, basicArg: Record<string, string>, objectArg: ReturnType<typeof canaryObject>, signalArg?: AbortSignal): Promise<TCanaryResult> {
605
+ let action: { href: URL; headers: Record<string, string> };
606
+ try {
607
+ action = await this.#exchange(contextArg, this.#lfsBatch(contextArg, basicArg, objectArg), signalArg, async (responseArg, readArg) => {
608
+ if (responseArg.status !== 200) throw new validation.ForgeInventoryError(`http-${responseArg.status}`);
609
+ let upload: unknown;
610
+ try {
611
+ const objects = validation.record(utf8Json(await readArg())).objects;
612
+ if (!Array.isArray(objects) || objects.length !== 1) throw new Error('objects');
613
+ const object = validation.record(objects[0]);
614
+ if (object.oid !== objectArg.oid || object.size !== objectArg.size || (object.error !== undefined && object.error !== null)) throw new Error('object');
615
+ upload = object.actions === undefined || object.actions === null ? undefined : validation.record(object.actions).upload;
616
+ } catch {
617
+ throw new validation.ForgeInventoryError('invalid-batch-response');
618
+ }
619
+ // A fresh object the forge already holds would mean the nonce was not fresh, so the canary wrote nothing.
620
+ if (upload === undefined || upload === null) throw new validation.ForgeInventoryError('upload-action-missing');
621
+ return uploadAction(upload, contextArg.base);
622
+ });
623
+ } catch (error) {
624
+ if (signalArg?.aborted) return failed('request-aborted');
625
+ return failed(`lfs-batch: ${reason(error)}`);
626
+ }
627
+ return this.#send(contextArg, {
628
+ method: 'PUT', url: action.href,
629
+ headers: { ...action.headers, 'content-type': 'application/octet-stream', 'content-length': String(objectArg.size) }, body: objectArg.bytes,
630
+ }, async () => objectArg.oid, signalArg);
631
+ }
632
+
633
+ #lfsBatch(contextArg: IOriginContext, basicArg: Record<string, string>, objectArg: ReturnType<typeof canaryObject>): IProbeRequest {
634
+ const { owner, name } = this.#options;
635
+ return {
636
+ method: 'POST', url: new URL(`${contextArg.base}/${encodeURIComponent(owner)}/${encodeURIComponent(name)}.git/info/lfs/objects/batch`),
637
+ headers: { ...basicArg, accept: lfsMediaType, 'content-type': lfsMediaType },
638
+ body: new TextEncoder().encode(JSON.stringify({ operation: 'upload', transfers: ['basic'], objects: [{ oid: objectArg.oid, size: objectArg.size }] })),
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Sends a canary's own request and reports its status and marker. On a 2xx,
644
+ * `createdArg` reads what it created; null means the canary creates
645
+ * nothing. A 2xx whose identity cannot be read keeps its status and marker
646
+ * with `created-identity-unreadable`.
647
+ */
648
+ async #send(
649
+ contextArg: IOriginContext, requestArg: IProbeRequest,
650
+ createdArg: ((readArg: () => Promise<Buffer>, headersArg: TResponseHeaders) => Promise<string>) | null, signalArg?: AbortSignal,
651
+ ): Promise<TCanaryResult> {
652
+ try {
653
+ return await this.#exchange(contextArg, requestArg, signalArg, async (responseArg, readArg): Promise<TCanaryResult> => {
654
+ const httpStatus = responseArg.status;
655
+ const freezeMarker = responseArg.headers.get(freezeMarkerHeader);
656
+ if (httpStatus < 200 || httpStatus > 299 || createdArg === null) return { httpStatus, freezeMarker, wrote: null, errorCode: null };
657
+ try {
658
+ return { httpStatus, freezeMarker, wrote: await createdArg(readArg, responseArg.headers), errorCode: null };
659
+ } catch {
660
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
661
+ return { httpStatus, freezeMarker, wrote: null, errorCode: 'created-identity-unreadable' };
662
+ }
663
+ });
664
+ } catch (error) {
665
+ return failed(reason(error));
666
+ }
667
+ }
668
+
669
+ /**
670
+ * One request through the origin's agents, never following a redirect,
671
+ * under the probe's deadline, which covers reading the body. `consumeArg`
672
+ * gets the response and a bounded body read; a body it does not read is
673
+ * discarded with the connection. Rejects with `request-aborted`,
674
+ * `request-timed-out`, `request-failed` or the consumer's own code.
675
+ */
676
+ async #exchange<T>(
677
+ contextArg: IOriginContext, requestArg: IProbeRequest, signalArg: AbortSignal | undefined,
678
+ consumeArg: (responseArg: transport.INodeResponse, readArg: () => Promise<Buffer>) => Promise<T>,
679
+ ): Promise<T> {
680
+ const controller = new AbortController();
681
+ let timedOut = false;
682
+ const timer = setTimeout(() => {
683
+ timedOut = true;
684
+ controller.abort();
685
+ }, this.#options.requestTimeoutMs);
686
+ const abort = () => controller.abort();
687
+ signalArg?.addEventListener('abort', abort, { once: true });
688
+ if (signalArg?.aborted) controller.abort();
689
+ let chunks: AsyncIterator<Uint8Array> | undefined;
690
+ try {
691
+ const response = await transport.sendNodeRequest(requestArg.url, {
692
+ method: requestArg.method, headers: requestArg.headers, body: requestArg.body, signal: controller.signal, agents: contextArg.agents, redirect: 'return',
693
+ });
694
+ const read = async (): Promise<Buffer> => {
695
+ chunks = response.body?.[Symbol.asyncIterator]();
696
+ if (!chunks) throw new validation.ForgeInventoryError('response-body-missing');
697
+ const parts: Uint8Array[] = [];
698
+ let bytes = 0;
699
+ while (true) {
700
+ const result = await chunks.next();
701
+ if (result.done) return Buffer.concat(parts);
702
+ bytes += result.value.byteLength;
703
+ if (bytes > probeMaxResponseBytes) throw new validation.ForgeInventoryError('response-size-limit');
704
+ parts.push(result.value);
705
+ }
706
+ };
707
+ return await consumeArg(response, read);
708
+ } catch (error) {
709
+ if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
710
+ if (timedOut) throw new validation.ForgeInventoryError('request-timed-out');
711
+ if (error instanceof validation.ForgeInventoryError) throw error;
712
+ throw new validation.ForgeInventoryError('request-failed');
713
+ } finally {
714
+ clearTimeout(timer);
715
+ signalArg?.removeEventListener('abort', abort);
716
+ // Discards an unread or partly read body with its connection; a completely read one has already released it.
717
+ controller.abort();
718
+ try { await chunks?.return?.(); } catch { /* The request has already been aborted. */ }
719
+ }
720
+ }
721
+ }
722
+
723
+ /**
724
+ * An LFS upload action's href and headers. The href must stay on the origin
725
+ * the batch request went to, so the canary write and the credential the
726
+ * action carries never leave it; its headers may not set connection-level
727
+ * fields. Gitea asks for a chunked upload (`Transfer-Encoding: chunked`); the
728
+ * probe sends the object with its length instead.
729
+ */
730
+ const uploadAction = (actionArg: unknown, baseArg: string): { href: URL; headers: Record<string, string> } => {
731
+ let action: Record<string, unknown>;
732
+ let href: URL;
733
+ try {
734
+ action = validation.record(actionArg);
735
+ if (typeof action.href !== 'string' || action.href.length > maxHrefLength) throw new Error('href');
736
+ href = new URL(action.href);
737
+ } catch {
738
+ throw new validation.ForgeInventoryError('invalid-batch-response');
739
+ }
740
+ if (href.origin !== new URL(baseArg).origin || href.username || href.password || href.hash) {
741
+ throw new validation.ForgeInventoryError('upload-href-other-origin');
742
+ }
743
+ const headers: Record<string, string> = {};
744
+ if (action.header !== undefined && action.header !== null) {
745
+ let entries: Array<[string, unknown]>;
746
+ try {
747
+ entries = Object.entries(validation.record(action.header));
748
+ } catch {
749
+ throw new validation.ForgeInventoryError('invalid-batch-response');
750
+ }
751
+ if (entries.length > maxActionHeaders) throw new validation.ForgeInventoryError('invalid-batch-response');
752
+ for (const [headerName, value] of entries) {
753
+ const key = headerName.toLowerCase();
754
+ if (key === 'transfer-encoding' && typeof value === 'string' && value.trim().toLowerCase() === 'chunked') continue;
755
+ if (!headerNamePattern.test(headerName) || reservedHeaderNames.has(key) || key in headers ||
756
+ typeof value !== 'string' || value.length > maxHeaderValueLength || /[\u0000\r\n]/.test(value)) {
757
+ throw new validation.ForgeInventoryError('invalid-batch-response');
758
+ }
759
+ headers[key] = value;
760
+ }
761
+ }
762
+ return { href, headers };
763
+ };