@foss.global/codefeed 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,9 @@ import * as validation from './forgeinventory.validation.js';
4
4
  import { ForgeRequestClient } from './forgeinventory.client.js';
5
5
  import type { IForgeRequestMetrics, IForgeRequestOptions } from './forgeinventory.client.js';
6
6
  import { measureGiteaMirror } from './giteasourceinventory.git.js';
7
+ import {
8
+ cargoIndexPath, comparePackages, composeGiteaPackageContent, containerIndexMediaTypes, npmPackagePath, packageKey, packageTypePattern,
9
+ } from './giteasourceinventory.packages.js';
7
10
 
8
11
  type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
9
12
  type TUnit = plugins.fossInterfaces.data.TForgeSourceInventoryUnit;
@@ -17,6 +20,18 @@ type TRelease = plugins.fossInterfaces.data.TForgeSourceRelease;
17
20
  type IBranchProtection = plugins.fossInterfaces.data.IForgeSourceBranchProtection;
18
21
  type ITagProtection = plugins.fossInterfaces.data.IForgeSourceTagProtection;
19
22
  type IInventory = plugins.fossInterfaces.data.IForgeSourceInventory;
23
+ type TAccessMode = plugins.fossInterfaces.data.TForgeSourceAccessMode;
24
+ type TTeamVisibility = plugins.fossInterfaces.data.TForgeSourceTeamVisibility;
25
+ type ITeamRecord = plugins.fossInterfaces.data.IForgeSourceTeamRecord;
26
+ type IUserSshKey = plugins.fossInterfaces.data.IForgeSourceUserSshKey;
27
+ type ICollaborator = plugins.fossInterfaces.data.IForgeSourceCollaborator;
28
+ type IDeployKey = plugins.fossInterfaces.data.IForgeSourceDeployKey;
29
+ type IDeployToken = plugins.fossInterfaces.data.IForgeSourceDeployToken;
30
+ type IAccessCounts = plugins.fossInterfaces.data.IForgeSourceAccessCounts;
31
+ type IPackageRef = plugins.fossInterfaces.data.IForgeSourcePackageRef;
32
+ type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
33
+ type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
34
+ type IPackageBlob = plugins.fossInterfaces.data.IForgeSourcePackageBlob;
20
35
 
21
36
  export interface IGiteaSourceInventoryOptions {
22
37
  /** Operator-assigned identity of the source instance. */
@@ -47,6 +62,19 @@ export interface IGiteaSourceInventoryOptions {
47
62
  maxResponseBytes?: number;
48
63
  }
49
64
 
65
+ /** A class of source data a run measures only when its discovery selects it. */
66
+ export type TGiteaSourceInventoryClass = 'packages';
67
+
68
+ export interface IGiteaSourceDiscoveryOptions {
69
+ /**
70
+ * Classes beside the instance, owners and repositories to list for
71
+ * measurement. `packages` lists every package, so `units` measures one unit
72
+ * per package; without it, discovery carries no `packages` and the package
73
+ * class is unread.
74
+ */
75
+ classes?: readonly TGiteaSourceInventoryClass[];
76
+ }
77
+
50
78
  export interface IGiteaSourceMeasureOptions {
51
79
  signal?: AbortSignal;
52
80
  /** Units measured at the same time. Requests within one unit are sequential. */
@@ -212,6 +240,71 @@ const releaseRecord = (itemArg: unknown): TRelease => {
212
240
  : { ...release, draft: false, publishedAt: requiredTimestamp(item.published_at) };
213
241
  };
214
242
 
243
+ const accessModes: ReadonlySet<string> = new Set<TAccessMode>(['none', 'read', 'write', 'admin', 'owner']);
244
+ const isAccessMode = (valueArg: string): valueArg is TAccessMode => accessModes.has(valueArg);
245
+ /** An access mode as Gitea reports it (`AccessMode.ToString`). */
246
+ const accessMode = (valueArg: unknown): TAccessMode => {
247
+ if (typeof valueArg !== 'string' || !isAccessMode(valueArg)) throw new validation.ForgeInventoryError('invalid-access-mode');
248
+ return valueArg;
249
+ };
250
+
251
+ const teamVisibilities: ReadonlySet<string> = new Set<TTeamVisibility>(['public', 'limited', 'private']);
252
+ const isTeamVisibility = (valueArg: string): valueArg is TTeamVisibility => teamVisibilities.has(valueArg);
253
+
254
+ /** An account as a listing names it: its id and login. */
255
+ const account = (itemArg: unknown): { id: string; login: string } => {
256
+ const item = validation.record(itemArg);
257
+ return { id: validation.decimal(item.id), login: segment(item.login) };
258
+ };
259
+
260
+ /** A user's public SSH key; its key type is the first field of the public key Gitea stores. */
261
+ const userSshKey = (itemArg: unknown): IUserSshKey => {
262
+ const item = validation.record(itemArg);
263
+ const publicKey = requiredString(item.key);
264
+ const keyType = publicKey.split(' ')[0];
265
+ if (!/^[A-Za-z0-9][A-Za-z0-9@._+-]{0,127}$/.test(keyType)) throw new validation.ForgeInventoryError('invalid-ssh-key');
266
+ return { id: validation.decimal(item.id), title: text(item.title), fingerprint: requiredString(item.fingerprint), keyType, publicKey };
267
+ };
268
+
269
+ /**
270
+ * A team as Gitea 28 reads it, with the members and repositories its listings
271
+ * returned. A whole-repository team has a null units map; units sort by name.
272
+ */
273
+ const teamRecord = (teamArg: Record<string, unknown>, membersArg: unknown[], repositoriesArg: unknown[]): ITeamRecord => {
274
+ if (typeof teamArg.visibility !== 'string' || !isTeamVisibility(teamArg.visibility)) throw new validation.ForgeInventoryError('invalid-visibility');
275
+ return {
276
+ id: validation.decimal(teamArg.id),
277
+ name: requiredString(teamArg.name),
278
+ permission: accessMode(teamArg.permission),
279
+ unitsMap: teamArg.units_map === null ? null : Object.fromEntries(Object.entries(validation.record(teamArg.units_map))
280
+ .map(([unitArg, modeArg]) => [unitArg, accessMode(modeArg)] as const)
281
+ .sort(([aArg], [bArg]) => compareText(aArg, bArg))),
282
+ includesAllRepositories: requiredBoolean(teamArg.includes_all_repositories),
283
+ canCreateOrgRepo: requiredBoolean(teamArg.can_create_org_repo),
284
+ visibility: teamArg.visibility,
285
+ members: membersArg.map(account).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
286
+ repositoryIds: repositoriesArg.map((repositoryArg) => validation.decimal(validation.record(repositoryArg).id)).sort(compareDecimal),
287
+ };
288
+ };
289
+
290
+ /**
291
+ * One entry of a repository's deploy key listing, which on Gitea 28 holds SSH
292
+ * deploy keys and deploy tokens (`key_type`). A token's key and fingerprint
293
+ * (its mask) are never read.
294
+ */
295
+ const deployKeyEntry = (itemArg: unknown): { kind: 'ssh'; key: IDeployKey } | { kind: 'token'; token: IDeployToken } => {
296
+ const item = validation.record(itemArg);
297
+ const id = validation.decimal(item.id);
298
+ const title = text(item.title);
299
+ const readOnly = requiredBoolean(item.read_only);
300
+ if (item.key_type === 'token') return { kind: 'token', token: { id, title, readOnly } };
301
+ if (item.key_type !== 'ssh') throw new validation.ForgeInventoryError('invalid-deploy-key-type');
302
+ return {
303
+ kind: 'ssh',
304
+ key: { id, keyId: validation.decimal(item.key_id), title, fingerprint: requiredString(item.fingerprint), publicKey: requiredString(item.key), readOnly },
305
+ };
306
+ };
307
+
215
308
  const allowlist = (usernamesArg: unknown, teamsArg: unknown) => ({ usernames: texts(usernamesArg), teams: texts(teamsArg) });
216
309
 
217
310
  /** A branch protection rule with every restriction Gitea reads back. */
@@ -266,16 +359,56 @@ const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[
266
359
  return { rows, failure: validation.decimal(body.total_count) === String(rows.length) ? null : 'count-mismatch' };
267
360
  };
268
361
 
269
- const unitKey = (unitArg: TUnit): string => unitArg.kind === 'instance' ? 'instance'
270
- : unitArg.kind === 'owner' ? `owner:${unitArg.ownerKind}:${unitArg.name}` : `repository:${unitArg.owner}/${unitArg.name}`;
362
+ /** The unit a result answers. */
363
+ const resultUnit = (resultArg: TUnitResult): TUnit => {
364
+ switch (resultArg.kind) {
365
+ case 'failed': return resultArg.unit;
366
+ case 'instance': return { kind: 'instance' };
367
+ case 'owner': return { kind: 'owner', ownerKind: resultArg.owner.kind, name: resultArg.owner.name };
368
+ case 'repository': return { kind: 'repository', owner: resultArg.repository.owner, name: resultArg.repository.name };
369
+ case 'package': return { kind: 'package', owner: resultArg.package.owner, type: resultArg.package.type, name: resultArg.package.name };
370
+ default: return unknownUnit(resultArg);
371
+ }
372
+ };
373
+
374
+ /** Refuses a unit kind this generator does not know, which a newer contract may add. */
375
+ const unknownUnit = (unitArg: never): never => {
376
+ void unitArg;
377
+ throw new validation.ForgeInventoryError('unit-kind-unsupported');
378
+ };
379
+
380
+ const unitKey = (unitArg: TUnit): string => {
381
+ switch (unitArg.kind) {
382
+ case 'instance': return 'instance';
383
+ case 'owner': return `owner:${unitArg.ownerKind}:${unitArg.name}`;
384
+ case 'repository': return `repository:${unitArg.owner}/${unitArg.name}`;
385
+ case 'package': return `package:${packageKey(unitArg)}`;
386
+ default: return unknownUnit(unitArg);
387
+ }
388
+ };
389
+
390
+ /** A package type from a listing. */
391
+ const packageType = (valueArg: unknown): string => {
392
+ const type = requiredString(valueArg);
393
+ if (!packageTypePattern.test(type)) throw new validation.ForgeInventoryError('invalid-package-type');
394
+ return type;
395
+ };
396
+
397
+ /** A package name as Gitea stores it; container and npm names may hold `/`, so it is no single path segment. */
398
+ const packageName = (valueArg: unknown): string => {
399
+ const name = requiredString(valueArg);
400
+ if (name.length > 512 || /[\u0000-\u001f\u007f]/.test(name)) throw new validation.ForgeInventoryError('invalid-package-name');
401
+ return name;
402
+ };
271
403
 
272
404
  const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
273
405
 
274
406
  /**
275
407
  * Authenticated, read-only inventory of a Gitea instance, read with a site
276
- * administrator's token: GET requests to its REST API, a mirror clone of every
277
- * repository for refs, objects and LFS pointers, and LFS batch `download`
278
- * requests that check which referenced objects Gitea holds. It counts secrets
408
+ * administrator's token: GET requests to its REST API and package registries,
409
+ * a mirror clone of every repository for refs, objects and LFS pointers, and
410
+ * LFS batch `download` requests that check which referenced objects Gitea
411
+ * holds. It counts secrets
279
412
  * and variables without retaining their values, and reports every read that
280
413
  * did not finish as a partial or unmeasured value instead of a count.
281
414
  */
@@ -339,11 +472,15 @@ export class GiteaSourceInventory {
339
472
 
340
473
  /**
341
474
  * Identifies the source and its reader and lists every owner and repository
342
- * with its id. Rejects unless the token belongs to a site administrator; a listing that
343
- * does not finish, or that lists a repository id twice, is reported in
344
- * `incompleteReasons`.
475
+ * with its id, and with `classes: ['packages']` every package from the
476
+ * administrator package listing (`packages`, absent otherwise). Rejects
477
+ * unless the token belongs to a site administrator, and rejects an unknown
478
+ * class with `invalid-options`; a listing that does not finish, or that
479
+ * lists a repository id twice, is reported in `incompleteReasons`.
345
480
  */
346
- public async discover(signalArg?: AbortSignal): Promise<IDiscovery> {
481
+ public async discover(signalArg?: AbortSignal, optionsArg: IGiteaSourceDiscoveryOptions = {}): Promise<IDiscovery> {
482
+ const classes = optionsArg.classes ?? [];
483
+ if (!Array.isArray(classes) || classes.some((classArg) => classArg !== 'packages')) throw new validation.ForgeInventoryError('invalid-options');
347
484
  const reader = validation.record((await this.#get('/user', { signal: signalArg })).body);
348
485
  if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
349
486
  const version = validation.record((await this.#get('/version', { signal: signalArg })).body).version;
@@ -380,16 +517,34 @@ export class GiteaSourceInventory {
380
517
  }
381
518
  if (ids.size !== repositories.items.length) incompleteReasons.push('discovery: repositories: duplicate-id');
382
519
  discovered.sort((aArg, bArg) => compareText(aArg.owner, bArg.owner) || compareText(aArg.name, bArg.name));
520
+ let packages: IPackageRef[] | undefined;
521
+ if (classes.includes('packages')) {
522
+ // One row per package version, of every owner, user-owned packages included.
523
+ const versions = await this.#list('/admin/packages', { signal: signalArg });
524
+ if (versions.failure !== null) incompleteReasons.push(`discovery: packages: ${versions.failure}`);
525
+ const refs = new Map<string, IPackageRef>();
526
+ for (const itemArg of versions.items) {
527
+ const item = validation.record(itemArg);
528
+ const ref = { owner: segment(validation.record(item.owner).login), type: packageType(item.type), name: packageName(item.name) };
529
+ refs.set(packageKey(ref), ref);
530
+ }
531
+ packages = [...refs.values()].sort(comparePackages);
532
+ }
383
533
  return {
384
534
  source: { instanceId: this.#options.instanceId, kind: 'gitea', baseUrl: this.#options.baseUrl, version },
385
535
  reader: { login: segment(reader.login), siteAdmin: true },
386
536
  owners,
387
537
  repositories: discovered,
538
+ ...(packages === undefined ? {} : { packages }),
388
539
  incompleteReasons: incompleteReasons.sort(compareText),
389
540
  };
390
541
  }
391
542
 
392
- /** Every unit a run over this discovery measures: the instance, then owners, then repositories. */
543
+ /**
544
+ * Every unit a run over this discovery measures: the instance, then owners,
545
+ * then repositories, then the packages discovery lists when it selected
546
+ * them.
547
+ */
393
548
  public units(discoveryArg: IDiscovery): TUnit[] {
394
549
  return [
395
550
  { kind: 'instance' },
@@ -397,6 +552,7 @@ export class GiteaSourceInventory {
397
552
  ...discoveryArg.repositories.map((repositoryArg): TUnit => ({
398
553
  kind: 'repository', owner: repositoryArg.owner, name: repositoryArg.name, ...repositoryArg.id === undefined ? {} : { id: repositoryArg.id },
399
554
  })),
555
+ ...(discoveryArg.packages ?? []).map((packageArg): TUnit => ({ kind: 'package', owner: packageArg.owner, type: packageArg.type, name: packageArg.name })),
400
556
  ];
401
557
  }
402
558
 
@@ -424,15 +580,31 @@ export class GiteaSourceInventory {
424
580
  /**
425
581
  * Measures one unit. Never rejects: a unit whose primary record cannot be
426
582
  * read is `failed`, and so is a repository unit carrying an id that its name
427
- * no longer answers with (`repository-identity-changed`).
583
+ * no longer answers with (`repository-identity-changed`). A package unit is
584
+ * read whole or fails, never partly: a listing that does not finish, an
585
+ * index that is not what its file list promises (`unexpected-manifest-shape`)
586
+ * and a result over `forgeSourceInventoryUnitMaxBytes` of UTF-8 JSON
587
+ * (`unit-over-bound`) fail it, and its record is never truncated.
428
588
  */
429
589
  public async measureUnit(unitArg: TUnit, signalArg?: AbortSignal): Promise<TUnitResult> {
430
590
  try {
431
- if (unitArg.kind === 'instance') return { kind: 'instance', instance: await this.#instance(signalArg) };
432
- if (unitArg.kind === 'owner') {
433
- return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
591
+ switch (unitArg.kind) {
592
+ case 'instance':
593
+ return { kind: 'instance', instance: await this.#instance(signalArg) };
594
+ case 'owner':
595
+ return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
596
+ case 'repository':
597
+ return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
598
+ case 'package': {
599
+ const result: TUnitResult = { kind: 'package', package: await this.#package(unitArg, signalArg) };
600
+ if (Buffer.byteLength(JSON.stringify(result), 'utf8') > plugins.fossInterfaces.data.forgeSourceInventoryUnitMaxBytes) {
601
+ return { kind: 'failed', unit: unitArg, reason: 'unit-over-bound' };
602
+ }
603
+ return result;
604
+ }
605
+ default:
606
+ return unknownUnit(unitArg);
434
607
  }
435
- return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
436
608
  } catch (error) {
437
609
  return { kind: 'failed', unit: unitArg, reason: reason(error) };
438
610
  }
@@ -518,13 +690,21 @@ export class GiteaSourceInventory {
518
690
  const teams = await this.#list(`${path}/teams`, { signal: signalArg });
519
691
  const memberships: TMeasure[] = [];
520
692
  const grants: TMeasure[] = [];
521
- for (const team of teams.items) {
522
- const teamPath = `/teams/${validation.decimal(validation.record(team).id)}`;
523
- memberships.push(await this.#count(`${teamPath}/members`, { signal: signalArg }));
524
- grants.push(await this.#count(`${teamPath}/repos`, { signal: signalArg }));
693
+ const teamRecords: ITeamRecord[] = [];
694
+ for (const teamArg of teams.items) {
695
+ const team = validation.record(teamArg);
696
+ const teamPath = `/teams/${validation.decimal(team.id)}`;
697
+ // An includes-all team lists every repository it holds like any other team.
698
+ const members = await this.#list(`${teamPath}/members`, { signal: signalArg });
699
+ const repositories = await this.#list(`${teamPath}/repos`, { signal: signalArg });
700
+ memberships.push(listingMeasure(members));
701
+ grants.push(listingMeasure(repositories));
702
+ teamRecords.push(teamRecord(team, members.items, repositories.items));
525
703
  }
526
704
  const teamsMeasure = listingMeasure(teams);
527
705
  const fromTeams = (measuresArg: TMeasure[]) => sumMeasures([...measuresArg, ...(teamsMeasure.status === 'complete' ? [] : [partial(0, teams.failure!)])]);
706
+ const teamMemberships = fromTeams(memberships);
707
+ const teamRepositoryGrants = fromTeams(grants);
528
708
  return {
529
709
  kind: 'organization',
530
710
  ...await this.#ownerBase(nameArg, organization, `${path}/repos`, signalArg),
@@ -533,10 +713,14 @@ export class GiteaSourceInventory {
533
713
  runners: await this.#runners(`${path}/actions/runners`, { signal: signalArg }),
534
714
  members: await this.#count(`${path}/members`, { signal: signalArg }),
535
715
  teams: teamsMeasure,
536
- teamMemberships: fromTeams(memberships),
537
- teamRepositoryGrants: fromTeams(grants),
716
+ teamMemberships,
717
+ teamRepositoryGrants,
538
718
  labels: await this.#count(`${path}/labels`, { signal: signalArg }),
539
719
  actionsSecrets: await this.#count(`${path}/actions/secrets`, { signal: signalArg }),
720
+ // Never partial: one unfinished listing makes the records null.
721
+ teamRecords: [teamsMeasure, teamMemberships, teamRepositoryGrants].every((measureArg) => measureArg.status === 'complete')
722
+ ? teamRecords.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id))
723
+ : null,
540
724
  };
541
725
  }
542
726
 
@@ -546,21 +730,28 @@ export class GiteaSourceInventory {
546
730
  if (segment(user.login) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
547
731
  // User-scoped settings are only readable as that user, through the administrator's Sudo header.
548
732
  const sudo = { signal: signalArg, sudo: nameArg };
733
+ const base = await this.#ownerBase(nameArg, user, `${path}/repos`, signalArg);
734
+ const flags = {
735
+ admin: requiredBoolean(user.is_admin),
736
+ active: requiredBoolean(user.active),
737
+ restricted: requiredBoolean(user.restricted),
738
+ prohibitLogin: requiredBoolean(user.prohibit_login),
739
+ };
740
+ // As an administrator this lists every key of the user.
741
+ const sshKeys = await this.#list(`${path}/keys`, { signal: signalArg });
742
+ const keyRecords = sshKeys.items.map(userSshKey).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
549
743
  return {
550
744
  kind: 'user',
551
- ...await this.#ownerBase(nameArg, user, `${path}/repos`, signalArg),
552
- account: {
553
- admin: requiredBoolean(user.is_admin),
554
- active: requiredBoolean(user.active),
555
- restricted: requiredBoolean(user.restricted),
556
- prohibitLogin: requiredBoolean(user.prohibit_login),
557
- externalAuth: validation.decimal(user.source_id) !== '0',
558
- },
745
+ ...base,
746
+ account: { ...flags, externalAuth: validation.decimal(user.source_id) !== '0' },
559
747
  webhooks: await this.#count('/user/hooks', sudo),
560
748
  actionsVariables: await this.#count('/user/actions/variables', sudo),
561
749
  runners: await this.#runners('/user/actions/runners', sudo),
562
- sshKeys: await this.#count(`${path}/keys`, { signal: signalArg }),
750
+ sshKeys: listingMeasure(sshKeys),
563
751
  gpgKeys: await this.#count(`${path}/gpg_keys`, { signal: signalArg }),
752
+ userRecord: sshKeys.failure === null
753
+ ? { id: base.id, login: nameArg, active: flags.active, admin: flags.admin, restricted: flags.restricted, prohibitLogin: flags.prohibitLogin, visibility: base.visibility, sshKeys: keyRecords }
754
+ : null,
564
755
  };
565
756
  }
566
757
 
@@ -608,6 +799,164 @@ export class GiteaSourceInventory {
608
799
  return counts;
609
800
  }
610
801
 
802
+ /**
803
+ * One package as Gitea's v1 package API lists it: every version with its
804
+ * files, each distinct content once. Container index candidates, versions
805
+ * whose only file is `manifest.json`, are read by digest for their
806
+ * references; no other manifest is read. npm packages add the dist-tags Gitea
807
+ * serves (a derived `latest` included) and each version's deprecation,
808
+ * Cargo crates each version's yank from the sparse index. Every read is a
809
+ * GET; reading an index counts a download of its version, an accepted read
810
+ * stamp (S10 ruling, Addendum 1).
811
+ */
812
+ async #package(refArg: IPackageRef, signalArg?: AbortSignal): Promise<IPackageRecord> {
813
+ const packagePath = `/packages/${encodeURIComponent(refArg.owner)}/${encodeURIComponent(refArg.type)}/${encodeURIComponent(refArg.name)}`;
814
+ const listing = await this.#list(packagePath, { signal: signalArg });
815
+ if (listing.failure !== null) throw new validation.ForgeInventoryError(listing.failure);
816
+ if (listing.items.length === 0) throw new validation.ForgeInventoryError('package-without-versions');
817
+ let ownerId: string | null = null;
818
+ let repositoryId: string | null | undefined;
819
+ const versions: IPackageVersion[] = [];
820
+ const ids = new Set<string>();
821
+ for (const itemArg of listing.items) {
822
+ const item = validation.record(itemArg);
823
+ const owner = validation.record(item.owner);
824
+ const repository = item.repository === null || item.repository === undefined ? null : validation.decimal(validation.record(item.repository).id);
825
+ if (segment(owner.login) !== refArg.owner || packageType(item.type) !== refArg.type || packageName(item.name) !== refArg.name) {
826
+ throw new validation.ForgeInventoryError('package-identity-changed');
827
+ }
828
+ // Every version names the package's owner and its one repository link.
829
+ if ((ownerId ?? validation.decimal(owner.id)) !== validation.decimal(owner.id) || (repositoryId !== undefined && repositoryId !== repository)) {
830
+ throw new validation.ForgeInventoryError('package-identity-changed');
831
+ }
832
+ ownerId = validation.decimal(owner.id);
833
+ repositoryId = repository;
834
+ const id = validation.decimal(item.id);
835
+ if (ids.has(id)) throw new validation.ForgeInventoryError('duplicate-id');
836
+ ids.add(id);
837
+ versions.push({ id, version: requiredString(item.version), createdAt: requiredTimestamp(item.created_at) });
838
+ }
839
+ versions.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
840
+ const blobs = new Map<string, IPackageBlob>();
841
+ const filesByVersion = new Map<string, Array<{ name: string; sha256: string }>>();
842
+ for (const version of versions) {
843
+ const files = (await this.#get(`${packagePath}/${encodeURIComponent(version.version)}/files`, { signal: signalArg })).body;
844
+ if (!Array.isArray(files)) throw new validation.ForgeInventoryError('invalid-page');
845
+ const read: Array<{ name: string; sha256: string }> = [];
846
+ for (const fileArg of files) {
847
+ const file = validation.record(fileArg);
848
+ const sha256 = requiredString(file.sha256);
849
+ if (!/^[0-9a-f]{64}$/.test(sha256)) throw new validation.ForgeInventoryError('invalid-sha256');
850
+ const size = validation.decimal(file.size);
851
+ const blob = blobs.get(sha256) ?? { sha256, size, files: [] };
852
+ if (blob.size !== size) throw new validation.ForgeInventoryError('package-blob-size-conflict');
853
+ const name = requiredString(file.name);
854
+ blob.files.push({ versionId: version.id, id: validation.decimal(file.id), name });
855
+ blobs.set(sha256, blob);
856
+ read.push({ name, sha256 });
857
+ }
858
+ filesByVersion.set(version.id, read);
859
+ }
860
+ const record: IPackageRecord = {
861
+ owner: refArg.owner, ownerId: ownerId!, type: refArg.type, name: refArg.name, repositoryId: repositoryId ?? null, versions,
862
+ blobs: [...blobs.values()].sort((aArg, bArg) => compareText(aArg.sha256, bArg.sha256)).map((blobArg) => ({
863
+ ...blobArg, files: blobArg.files.sort((aArg, bArg) => compareDecimal(aArg.versionId, bArg.versionId) || compareDecimal(aArg.id, bArg.id)),
864
+ })),
865
+ };
866
+ if (record.type === 'container') await this.#containerReferences(record, filesByVersion, signalArg);
867
+ if (record.type === 'npm') await this.#npmState(record, signalArg);
868
+ if (record.type === 'cargo') await this.#cargoYanks(record, signalArg);
869
+ return record;
870
+ }
871
+
872
+ /**
873
+ * Reads the references of every index candidate, a version whose file list
874
+ * is exactly `manifest.json` (Gitea stores an index's manifest and nothing
875
+ * else), by digest from the registry with the reader's Basic credentials. The
876
+ * bytes must hash to the listed SHA-256 and the response must be an index or
877
+ * a manifest list, else the unit fails with `unexpected-manifest-shape`.
878
+ */
879
+ async #containerReferences(
880
+ recordArg: IPackageRecord, filesArg: ReadonlyMap<string, Array<{ name: string; sha256: string }>>, signalArg?: AbortSignal,
881
+ ): Promise<void> {
882
+ if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(recordArg.name)) {
883
+ throw new validation.ForgeInventoryError('invalid-image-name');
884
+ }
885
+ for (const version of recordArg.versions) {
886
+ const files = filesArg.get(version.id)!;
887
+ if (files.length !== 1 || files[0].name !== 'manifest.json') continue;
888
+ const digest = `sha256:${files[0].sha256}`;
889
+ const client = this.#basic(await this.#login(signalArg));
890
+ const response = await client.requestBytes(
891
+ new URL(`/v2/${recordArg.owner.toLowerCase()}/${recordArg.name}/manifests/${digest}`, this.#options.baseUrl),
892
+ { accept: containerIndexMediaTypes.join(', '), signal: signalArg },
893
+ );
894
+ if (plugins.crypto.createHash('sha256').update(response.bytes).digest('hex') !== files[0].sha256
895
+ || response.mediaType === null || !containerIndexMediaTypes.includes(response.mediaType)) {
896
+ throw new validation.ForgeInventoryError('unexpected-manifest-shape');
897
+ }
898
+ let entries: unknown;
899
+ try {
900
+ entries = validation.record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.bytes)) as unknown).manifests;
901
+ } catch {
902
+ throw new validation.ForgeInventoryError('unexpected-manifest-shape');
903
+ }
904
+ if (!Array.isArray(entries)) throw new validation.ForgeInventoryError('unexpected-manifest-shape');
905
+ const references = entries.map((entryArg: unknown) => {
906
+ const entry = entryArg !== null && typeof entryArg === 'object' && !Array.isArray(entryArg) ? (entryArg as Record<string, unknown>).digest : undefined;
907
+ if (typeof entry !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(entry)) throw new validation.ForgeInventoryError('unexpected-manifest-shape');
908
+ return entry;
909
+ });
910
+ version.references = [...new Set(references)].sort(compareText);
911
+ }
912
+ }
913
+
914
+ /** Every dist-tag Gitea serves, a `latest` it derives included, and each version's deprecation from the package document. */
915
+ async #npmState(recordArg: IPackageRecord, signalArg?: AbortSignal): Promise<void> {
916
+ const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/npm`;
917
+ const name = npmPackagePath(recordArg.name);
918
+ const tags = validation.record((await this.#client.request(new URL(`${registry}/-/package/${name}/dist-tags`), { signal: signalArg })).body);
919
+ recordArg.distTags = Object.entries(tags).map(([tag, version]) => ({ tag, version: requiredString(version) }))
920
+ .sort((aArg, bArg) => compareText(aArg.tag, bArg.tag));
921
+ const versions = validation.record(validation.record((await this.#client.request(new URL(`${registry}/${name}`), { signal: signalArg })).body).versions);
922
+ for (const version of recordArg.versions) {
923
+ const entry = versions[version.version];
924
+ if (entry === undefined) throw new validation.ForgeInventoryError('npm-version-missing');
925
+ const deprecated = validation.record(entry).deprecated;
926
+ version.deprecated = deprecated === undefined ? '' : text(deprecated);
927
+ }
928
+ }
929
+
930
+ /** Each version's yank from the crate's line in the Cargo sparse index. */
931
+ async #cargoYanks(recordArg: IPackageRecord, signalArg?: AbortSignal): Promise<void> {
932
+ const response = await this.#client.requestBytes(
933
+ new URL(`${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/cargo/${cargoIndexPath(recordArg.name)}`),
934
+ { accept: 'text/plain', signal: signalArg },
935
+ );
936
+ if (response.mediaType !== 'text/plain') throw new validation.ForgeInventoryError('invalid-content-type');
937
+ const yanked = new Map<string, boolean>();
938
+ let lines: string[];
939
+ try {
940
+ lines = new TextDecoder('utf-8', { fatal: true }).decode(response.bytes).split('\n').filter((lineArg) => lineArg !== '');
941
+ } catch {
942
+ throw new validation.ForgeInventoryError('invalid-cargo-index');
943
+ }
944
+ for (const line of lines) {
945
+ let entry: Record<string, unknown>;
946
+ try {
947
+ entry = validation.record(JSON.parse(line) as unknown);
948
+ } catch {
949
+ throw new validation.ForgeInventoryError('invalid-cargo-index');
950
+ }
951
+ yanked.set(requiredString(entry.vers), requiredBoolean(entry.yanked));
952
+ }
953
+ for (const version of recordArg.versions) {
954
+ const value = yanked.get(version.version);
955
+ if (value === undefined) throw new validation.ForgeInventoryError('cargo-index-incomplete');
956
+ version.yanked = value;
957
+ }
958
+ }
959
+
611
960
  async #repository(ownerArg: string, nameArg: string, idArg: string | undefined, signalArg?: AbortSignal): Promise<IRepository> {
612
961
  const path = `/repos/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}`;
613
962
  const item = validation.record((await this.#get(path, { signal: signalArg })).body);
@@ -635,6 +984,8 @@ export class GiteaSourceInventory {
635
984
  const options = { signal: signalArg };
636
985
  const branchRules = await this.#unpaged(`${path}/branch_protections`, options);
637
986
  const tagRules = await this.#unpaged(`${path}/tag_protections`, options);
987
+ const collaborators = await this.#collaborators(path, signalArg);
988
+ const deployKeys = await this.#deployKeys(path, signalArg);
638
989
  return {
639
990
  owner: ownerArg,
640
991
  name: nameArg,
@@ -657,14 +1008,21 @@ export class GiteaSourceInventory {
657
1008
  ...await this.#releases(path, signalArg),
658
1009
  wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
659
1010
  access: {
660
- collaborators: await this.#count(`${path}/collaborators`, options),
1011
+ collaborators: collaborators.measure,
661
1012
  teams: await this.#repositoryTeams(path, options),
662
- deployKeys: await this.#count(`${path}/keys`, options),
1013
+ deployKeys: deployKeys.keys,
1014
+ deployTokens: deployKeys.tokens,
663
1015
  webhooks: await this.#count(`${path}/hooks`, options),
664
1016
  branchProtections: branchRules.measure,
665
1017
  tagProtections: tagRules.measure,
666
1018
  pushMirrors: await this.#count(`${path}/push_mirrors`, options),
667
1019
  },
1020
+ // Never partial: an unfinished listing or permission read makes the records null.
1021
+ accessRecords: collaborators.records === null || deployKeys.records === null ? null : {
1022
+ collaborators: collaborators.records,
1023
+ deployKeys: deployKeys.records.keys,
1024
+ deployTokens: deployKeys.records.tokens,
1025
+ },
668
1026
  protections: branchRules.rows === null || tagRules.rows === null ? null : {
669
1027
  branches: branchRules.rows.map(branchProtection).sort((aArg, bArg) => compareText(aArg.ruleName, bArg.ruleName)),
670
1028
  tags: tagRules.rows.map(tagProtection).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
@@ -673,6 +1031,49 @@ export class GiteaSourceInventory {
673
1031
  };
674
1032
  }
675
1033
 
1034
+ /**
1035
+ * The repository's collaborators, each with one read of the effective
1036
+ * permission Gitea reports for them, never the collaboration's own mode,
1037
+ * which no REST endpoint reports. The count is complete only when every
1038
+ * permission was read; the first failed read ends the reads.
1039
+ */
1040
+ async #collaborators(pathArg: string, signalArg?: AbortSignal): Promise<{ measure: TMeasure; records: ICollaborator[] | null }> {
1041
+ const listing = await this.#list(`${pathArg}/collaborators`, { signal: signalArg });
1042
+ const listed = listing.items.map(account);
1043
+ if (listing.failure !== null) return { measure: partial(listed.length, listing.failure), records: null };
1044
+ const records: ICollaborator[] = [];
1045
+ for (const collaborator of listed) {
1046
+ try {
1047
+ const read = validation.record((await this.#get(`${pathArg}/collaborators/${encodeURIComponent(collaborator.login)}/permission`, { signal: signalArg })).body);
1048
+ if (validation.decimal(validation.record(read.user).id) !== collaborator.id) throw new validation.ForgeInventoryError('collaborator-identity-changed');
1049
+ records.push({ userId: collaborator.id, login: collaborator.login, effectivePermission: accessMode(read.permission) });
1050
+ } catch (error) {
1051
+ return { measure: partial(listed.length, `collaborator-permission: ${reason(error)}`), records: null };
1052
+ }
1053
+ }
1054
+ return { measure: complete(listed.length), records: records.sort((aArg, bArg) => compareDecimal(aArg.userId, bArg.userId)) };
1055
+ }
1056
+
1057
+ /** SSH deploy keys and deploy tokens, which Gitea 28 lists together, counted and recorded apart. */
1058
+ async #deployKeys(pathArg: string, signalArg?: AbortSignal): Promise<{
1059
+ keys: TMeasure; tokens: TMeasure; records: { keys: IDeployKey[]; tokens: IDeployToken[] } | null;
1060
+ }> {
1061
+ const listing = await this.#list(`${pathArg}/keys`, { signal: signalArg });
1062
+ const keys: IDeployKey[] = [];
1063
+ const tokens: IDeployToken[] = [];
1064
+ for (const entry of listing.items.map(deployKeyEntry)) {
1065
+ if (entry.kind === 'ssh') keys.push(entry.key); else tokens.push(entry.token);
1066
+ }
1067
+ return {
1068
+ keys: listingMeasure(listing, BigInt(keys.length)),
1069
+ tokens: listingMeasure(listing, BigInt(tokens.length)),
1070
+ records: listing.failure === null ? {
1071
+ keys: keys.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
1072
+ tokens: tokens.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
1073
+ } : null,
1074
+ };
1075
+ }
1076
+
676
1077
  /** Gitea answers 405 for a repository that no organization owns, which therefore has no teams. */
677
1078
  async #repositoryTeams(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
678
1079
  try {
@@ -693,7 +1094,7 @@ export class GiteaSourceInventory {
693
1094
  baseUrl: this.#options.baseUrl, owner: ownerArg, name: nameArg, username, token: this.#options.token,
694
1095
  caCertificates: this.#options.caCertificates, workDir: this.#options.gitWorkDir, reportedSizeKiB: sizeKiB,
695
1096
  timeoutMs: this.#options.gitTimeoutMs,
696
- lfsClient: this.#lfs(username), signal: signalArg,
1097
+ lfsClient: this.#basic(username), signal: signalArg,
697
1098
  });
698
1099
  return { git: { reportedSizeKiB: complete(sizeKiB), ...measured.git }, lfs: measured.lfs, head: measured.head, refDigests: measured.refDigests };
699
1100
  }
@@ -709,8 +1110,8 @@ export class GiteaSourceInventory {
709
1110
  return this.#readerLogin;
710
1111
  }
711
1112
 
712
- /** LFS routes accept the token as a Basic password, not as a `token` authorization. */
713
- #lfs(usernameArg: string): ForgeRequestClient {
1113
+ /** LFS and registry (`/v2`) routes accept the token as a Basic password, not as a `token` authorization. */
1114
+ #basic(usernameArg: string): ForgeRequestClient {
714
1115
  this.#lfsClient ??= new ForgeRequestClient({
715
1116
  transport: this.#transport,
716
1117
  headers: { authorization: `Basic ${Buffer.from(`${usernameArg}:${this.#options.token}`).toString('base64')}` },
@@ -949,6 +1350,9 @@ const indicator = (measureArg: TMeasure, predicateArg: (valueArg: bigint) => boo
949
1350
  measureArg.status === 'complete' ? complete(predicateArg(BigInt(measureArg.value)) ? 1 : 0)
950
1351
  : measureArg.status === 'partial' ? partial(predicateArg(BigInt(measureArg.observedValue)) ? 1 : 0, measureArg.reason) : measureArg;
951
1352
 
1353
+ /** A repository result from a generator that predates the deploy token count, whose `deployKeys` included the tokens. */
1354
+ const deployTokensPredated = notMeasured('Measured by a generator that predates the deploy token count.');
1355
+
952
1356
  /** Every measure of a document with its dotted path. */
953
1357
  const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: string; measure: TMeasure }>) => {
954
1358
  if (!valueArg || typeof valueArg !== 'object') return;
@@ -972,6 +1376,10 @@ const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: st
972
1376
  * repositories measured with one id, owner repository counts that disagree
973
1377
  * with discovery, and every partial or unmeasured value make the inventory
974
1378
  * incomplete, with reasons aggregated per subject kind, path and reason.
1379
+ * `packages` and `totals.packageContent` (`composeGiteaPackageContent`) are
1380
+ * present exactly when discovery lists packages, an empty list included; a
1381
+ * package without a record is left out, never truncated, and makes its
1382
+ * content measures partial.
975
1383
  */
976
1384
  export const composeGiteaSourceInventory = (
977
1385
  discoveryArg: IDiscovery,
@@ -979,12 +1387,7 @@ export const composeGiteaSourceInventory = (
979
1387
  generatorsArg: plugins.fossInterfaces.data.IForgeSourceInventoryGenerator[],
980
1388
  ): IInventory => {
981
1389
  const results = new Map<string, TUnitResult>();
982
- for (const result of resultsArg) {
983
- const unit: TUnit = result.kind === 'failed' ? result.unit : result.kind === 'instance' ? { kind: 'instance' }
984
- : result.kind === 'owner' ? { kind: 'owner', ownerKind: result.owner.kind, name: result.owner.name }
985
- : { kind: 'repository', owner: result.repository.owner, name: result.repository.name };
986
- results.set(unitKey(unit), result);
987
- }
1390
+ for (const result of resultsArg) results.set(unitKey(resultUnit(result)), result);
988
1391
  const reasons = new Map<string, number>();
989
1392
  const addReason = (textArg: string) => reasons.set(textArg, (reasons.get(textArg) ?? 0) + 1);
990
1393
  for (const text of discoveryArg.incompleteReasons) addReason(text);
@@ -1022,6 +1425,17 @@ export const composeGiteaSourceInventory = (
1022
1425
  addReason(`repository: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : result?.kind === 'repository' ? 'unit failed: repository-identity-changed' : 'unit not measured'}`);
1023
1426
  }
1024
1427
  }
1428
+ // Packages only when discovery listed them; a package without a record leaves the class incomplete, never truncated.
1429
+ let packages: IPackageRecord[] | undefined;
1430
+ if (discoveryArg.packages !== undefined) {
1431
+ packages = [];
1432
+ for (const ref of discoveryArg.packages) {
1433
+ const result = results.get(unitKey({ kind: 'package', ...ref }));
1434
+ if (result?.kind === 'package') packages.push(result.package);
1435
+ else addReason(`package: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
1436
+ }
1437
+ packages.sort(comparePackages);
1438
+ }
1025
1439
  // Without discovered ids, two names measured as one id show a rename between their units.
1026
1440
  if (new Set(repositories.map((repositoryArg) => repositoryArg.id)).size !== repositories.length) addReason('repository: duplicate-id');
1027
1441
  owners.sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
@@ -1058,6 +1472,7 @@ export const composeGiteaSourceInventory = (
1058
1472
  instance,
1059
1473
  owners,
1060
1474
  repositories,
1475
+ ...(packages === undefined ? {} : { packages }),
1061
1476
  totals: {
1062
1477
  organizations: instance.organizations,
1063
1478
  users: instance.users.total,
@@ -1079,8 +1494,9 @@ export const composeGiteaSourceInventory = (
1079
1494
  ['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
1080
1495
  releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
1081
1496
  wikiPages: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.wikiPages), ...missingRepositories]),
1082
- access: sumGroup(repositories.map((repositoryArg) => repositoryArg.access),
1083
- ['collaborators', 'teams', 'deployKeys', 'webhooks', 'branchProtections', 'tagProtections', 'pushMirrors'], missingRepositories),
1497
+ access: sumGroup(repositories.map((repositoryArg): Required<IAccessCounts> => ({
1498
+ ...repositoryArg.access, deployTokens: repositoryArg.access.deployTokens ?? deployTokensPredated,
1499
+ })), ['collaborators', 'teams', 'deployKeys', 'deployTokens', 'webhooks', 'branchProtections', 'tagProtections', 'pushMirrors'], missingRepositories),
1084
1500
  actions: sumGroup(repositories.map((repositoryArg) => repositoryArg.actions),
1085
1501
  ['workflows', 'secrets', 'variables', 'runners', 'runs', 'artifacts', 'artifactBytes'], missingRepositories),
1086
1502
  owners: {
@@ -1100,6 +1516,7 @@ export const composeGiteaSourceInventory = (
1100
1516
  type,
1101
1517
  ...sumGroup(countsArg.map(({ type: _type, ...measures }) => measures), ['packages', 'versions', 'files', 'bytes'], missingOwners),
1102
1518
  })),
1519
+ ...(packages === undefined ? {} : { packageContent: composeGiteaPackageContent(discoveryArg.packages!, packages) }),
1103
1520
  },
1104
1521
  };
1105
1522
 
@@ -1110,7 +1527,11 @@ export const composeGiteaSourceInventory = (
1110
1527
  const { name: _name, id: _id, ...measures } = owner;
1111
1528
  measuresOf(measures, owner.kind, collected);
1112
1529
  }
1113
- for (const repository of repositories) measuresOf(repository, 'repository', collected);
1530
+ for (const repository of repositories) {
1531
+ measuresOf(repository, 'repository', collected);
1532
+ // A stored result from a generator before deploy tokens counted them among its deploy keys.
1533
+ if (repository.access.deployTokens === undefined) collected.push({ path: 'repository.access.deployTokens', measure: deployTokensPredated });
1534
+ }
1114
1535
  for (const { path, measure } of collected) {
1115
1536
  if (measure.status !== 'complete') addReason(`${path}: ${measure.reason}`);
1116
1537
  }