@xemahq/xema 0.47.1 → 0.49.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.
Files changed (99) hide show
  1. package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
  2. package/dist/assets/appliance-base/images.lock.json +878 -1161
  3. package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
  4. package/dist/assets/appliance-lean/images.lock.json +825 -924
  5. package/dist/assets/boot-graph.json +398 -93
  6. package/dist/assets/distribution-assets.manifest.json +10 -10
  7. package/dist/commands/edition.d.ts.map +1 -1
  8. package/dist/commands/edition.js +5 -3
  9. package/dist/commands/edition.js.map +1 -1
  10. package/dist/dev/env-defaults.d.ts.map +1 -1
  11. package/dist/dev/env-defaults.js +0 -1
  12. package/dist/dev/env-defaults.js.map +1 -1
  13. package/dist/edition/verify-image-evidence.bundle.mjs +4649 -2742
  14. package/dist/edition/verify-image-evidence.bundle.provenance.json +597 -495
  15. package/dist/runtime/auth/cli-application.d.ts +18 -0
  16. package/dist/runtime/auth/cli-application.d.ts.map +1 -0
  17. package/dist/runtime/auth/cli-application.js +39 -0
  18. package/dist/runtime/auth/cli-application.js.map +1 -0
  19. package/dist/runtime/edition/verb-map.d.ts +3 -1
  20. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  21. package/dist/runtime/edition/verb-map.js +5 -1
  22. package/dist/runtime/edition/verb-map.js.map +1 -1
  23. package/dist/runtime/env-seeder.d.ts.map +1 -1
  24. package/dist/runtime/env-seeder.js +21 -0
  25. package/dist/runtime/env-seeder.js.map +1 -1
  26. package/dist/runtime/launch.d.ts.map +1 -1
  27. package/dist/runtime/launch.js +0 -18
  28. package/dist/runtime/launch.js.map +1 -1
  29. package/edition/README.md +20 -3
  30. package/edition/biome-content.mjs +5 -2
  31. package/edition/build-runtime-image-source-index.mjs +79 -5
  32. package/edition/canonical-evidence-json.mjs +82 -0
  33. package/edition/catalogue-evidence.mjs +8 -5
  34. package/edition/customer-edition-verbs.mjs +9 -3
  35. package/edition/develop-coverage-run.mjs +2 -1
  36. package/edition/develop-coverage.mjs +12 -1
  37. package/edition/develop-edition-resolve.mjs +3 -2
  38. package/edition/develop-edition.mjs +15 -3
  39. package/edition/develop-record-verify.mjs +216 -0
  40. package/edition/develop-select-records.mjs +24 -225
  41. package/edition/develop-verify-image-record.mjs +90 -0
  42. package/edition/dist-check.mjs +22 -2
  43. package/edition/distribution-evidence-set.mjs +30 -2
  44. package/edition/edition-artifact.mjs +110 -22
  45. package/edition/edition-ceiling.mjs +180 -0
  46. package/edition/edition-config.mjs +91 -0
  47. package/edition/edition-constants.mjs +21 -3
  48. package/edition/edition-contract-parse.mjs +88 -3
  49. package/edition/edition-contract.json +47 -4
  50. package/edition/edition-image-seal.mjs +9 -2
  51. package/edition/edition-kind.mjs +10 -0
  52. package/edition/edition-protocol.mjs +8 -1
  53. package/edition/edition-publisher.mjs +21 -0
  54. package/edition/edition-seal.mjs +257 -0
  55. package/edition/evidence-grammars.mjs +2 -0
  56. package/edition/evidence-trust-pin.mjs +66 -10
  57. package/edition/evidence-trust-policy.mjs +39 -29
  58. package/edition/external-source-walk.mjs +16 -3
  59. package/edition/fleet-coordinate-finality.mjs +18 -5
  60. package/edition/fulcio-certificate-extensions.mjs +231 -0
  61. package/edition/image-evidence.mjs +34 -79
  62. package/edition/locked-bundle-signer.mjs +224 -0
  63. package/edition/oci-evidence-verbs.mjs +7 -1
  64. package/edition/oci-image-source-evidence.mjs +8 -7
  65. package/edition/pinned-cosign.mjs +70 -0
  66. package/edition/plan-image-evidence-refresh.mjs +3 -1
  67. package/edition/producer-evidence-bundle.mjs +3 -1
  68. package/edition/producer-evidence-index-file.mjs +4 -0
  69. package/edition/release-edition-artifact.mjs +3 -3
  70. package/edition/repin-evidence-trust.mjs +6 -1
  71. package/edition/resolve-distribution.mjs +47 -6
  72. package/edition/resolve-edition.mjs +1 -1
  73. package/edition/resolve-images.mjs +53 -7
  74. package/edition/seal-image-digests.mjs +19 -8
  75. package/edition/sealed-distribution-lock.mjs +2 -2
  76. package/edition/third-party-admission.mjs +91 -0
  77. package/edition/third-party-bundle-registry.mjs +147 -0
  78. package/edition/third-party-certificate-extensions.mjs +21 -0
  79. package/edition/third-party-image-pins.mjs +41 -0
  80. package/edition/third-party-pins-trusted.mjs +24 -0
  81. package/edition/third-party-producer-evidence.mjs +76 -0
  82. package/edition/third-party-source-check.mjs +28 -0
  83. package/edition/third-party-source-context.mjs +41 -0
  84. package/edition/third-party-source-evidence.mjs +255 -0
  85. package/edition/third-party-source-flags.mjs +134 -0
  86. package/edition/third-party-source-pins.mjs +259 -0
  87. package/edition/third-party-source-uniqueness.mjs +54 -0
  88. package/edition/third-party-source-walk.mjs +115 -0
  89. package/edition/third-party-source-writer.mjs +229 -0
  90. package/edition/third-party-subjects.mjs +127 -0
  91. package/edition/trust-policy-image-ownership.mjs +85 -0
  92. package/edition/verify-catalogue-evidence.mjs +2 -0
  93. package/edition/verify-image-evidence.mjs +2 -0
  94. package/edition/verify-image-platforms.mjs +33 -9
  95. package/package.json +5 -5
  96. package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
  97. package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
  98. package/dist/runtime/auth/client-bootstrap.js +0 -60
  99. package/dist/runtime/auth/client-bootstrap.js.map +0 -1
@@ -78,7 +78,7 @@ import { resolveDispatchedRuntimeComponents } from './dispatched-runtime-compone
78
78
  import { fleetChartSetLockPath, writeEditionChartSet } from './edition-chart-set.mjs';
79
79
  import { workspaceSection } from './edition-config.mjs';
80
80
  import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
81
- import { WorkspaceRoot, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, kernelPackageDir } from './edition-workspace.mjs';
81
+ import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir } from './edition-workspace.mjs';
82
82
  import { walkDeclaredSources } from './external-source-walk.mjs';
83
83
  import {
84
84
  biomeTierOrder,
@@ -95,6 +95,7 @@ import {
95
95
  validateImmutableDistributionLock,
96
96
  validateServiceDependencyOwnerParity,
97
97
  } from './lib.mjs';
98
+ import { BundleSignerPolicyAbsentError, loadBundleSignerSelection } from './locked-bundle-signer.mjs';
98
99
  import {
99
100
  assertInstalledOperationCeilingAdmitted,
100
101
  assertOperationGrantsAdmitted,
@@ -112,6 +113,7 @@ import { commandFor } from './remedy-command.mjs';
112
113
  import { soleResourceProvider } from './required-resource-provider.mjs';
113
114
  import { readStorePublisherTrust } from './store-publisher-trust.mjs';
114
115
  import { readStrictJsonFile } from './strict-json.mjs';
116
+ import { isThirdPartyOciEntry } from './third-party-subjects.mjs';
115
117
  import { RuntimeSubjectKind } from './workspace-sections.mjs';
116
118
 
117
119
  /**
@@ -254,7 +256,14 @@ export function loadEditionManifest(parseDistribution, id) {
254
256
  if (!existsSync(path)) {
255
257
  throw new Error(`distribution "${id}" not found: ${path} does not exist`);
256
258
  }
257
- const raw = readStrictJsonFile(path);
259
+ return parseEditionManifest(parseDistribution, id, readStrictJsonFile(path), path);
260
+ }
261
+
262
+ /**
263
+ * The edition model's checks on one raw definition read from `source` (a file path, or a Git object): the refused
264
+ * fields, the kernel's validation and the id match. The one parse of a definition, whoever read the bytes.
265
+ */
266
+ export function parseEditionManifest(parseDistribution, id, raw, source) {
258
267
  if (raw !== null && typeof raw === 'object' && Object.hasOwn(raw, REFUSED_EXTENDS_FIELD)) {
259
268
  throw new EditionModelError(
260
269
  `edition "${id}": "${REFUSED_EXTENDS_FIELD}" is not part of the edition model; ` +
@@ -271,7 +280,7 @@ export function loadEditionManifest(parseDistribution, id) {
271
280
  // `sourcePins` reaches the kernel unchanged: its Distribution schema validates it.
272
281
  const manifest = parseDistribution(raw);
273
282
  if (manifest.id !== id) {
274
- throw new Error(`distribution at ${path} declares id "${manifest.id}", but it was addressed as "${id}"`);
283
+ throw new Error(`distribution at ${source} declares id "${manifest.id}", but it was addressed as "${id}"`);
275
284
  }
276
285
  return manifest;
277
286
  }
@@ -618,6 +627,11 @@ export function mergeExternalSourceEntries(
618
627
  * emitting `source: undefined` would make it read as first-party — so this
619
628
  * fails rather than choosing either.
620
629
  */
630
+ /** A stamped entry of a declared OCI source: third-party, an OCI bundle source and no git record. */
631
+ function isThirdPartyOciSourceEntry(entry, sourceRecords) {
632
+ return !sourceRecords.has(entry.source) && isThirdPartyOciEntry(entry);
633
+ }
634
+
621
635
  function requireSourceRecord(entry, sourceRecords) {
622
636
  const record = sourceRecords.get(entry.source);
623
637
  if (record === undefined) {
@@ -1780,6 +1794,23 @@ export function resolveDistributionFromInputs(
1780
1794
  // shipped provider. See `nexusBinder`.
1781
1795
  const nexusBindings = nexusBinder(id, [...selected.values()], schemas);
1782
1796
 
1797
+ // (5c') Bundle signers — every OCI bundle row (a biome's, a host module's) states the signer the data root's
1798
+ // trust policy selects for it. See `locked-bundle-signer.mjs`.
1799
+ const bundleSignerOf = (e, bundleSource, componentKey) => {
1800
+ const subject = { biomeId: e.id, ...(componentKey === undefined ? {} : { componentKey }), origin: e.origin, repo: e.repo, bundleSource };
1801
+ if (composed?.bundleSigners === undefined) {
1802
+ throw new BundleSignerPolicyAbsentError(
1803
+ `${componentKey === undefined ? `biome "${e.id}"` : `host module "${componentKey}" of biome "${e.id}"`} is an OCI bundle, but this resolve was given no bundle signer selection`,
1804
+ );
1805
+ }
1806
+ return composed.bundleSigners.signerFor(subject);
1807
+ };
1808
+ const bundleSignerFor = (e) => (e.bundleSource?.kind === 'oci' ? { bundleSigner: bundleSignerOf(e, e.bundleSource) } : {});
1809
+ const hostModulesOf = (e) =>
1810
+ (Array.isArray(e.hostModules) ? e.hostModules : []).map((hostModule) =>
1811
+ hostModule.bundleSource?.kind === 'oci' ? { ...hostModule, bundleSigner: bundleSignerOf(e, hostModule.bundleSource, hostModule.componentKey) } : hostModule,
1812
+ );
1813
+
1783
1814
  // (5d) Dispatched runtime components — every `runtime-image` edge of a
1784
1815
  // shipped biome must resolve to a runtime component of a biome in the index.
1785
1816
  // The target biome is not rostered (it enters no `biomes[]`, `requires` or
@@ -1950,7 +1981,7 @@ export function resolveDistributionFromInputs(
1950
1981
  // (the connector adapters loaded by `biome-adapter-host`). Projected
1951
1982
  // verbatim from the index, which resolved each component's bundle source
1952
1983
  // against the biome manifest.
1953
- hostModules: Array.isArray(e.hostModules) ? e.hostModules : [],
1984
+ hostModules: hostModulesOf(e),
1954
1985
  // The BIOME-LEVEL bundle source, for a biome whose bundle no host module
1955
1986
  // above names — the field a `installPolicy: required` service-only
1956
1987
  // biome needs for the runtime's required-biome reconciler to fetch,
@@ -1964,6 +1995,7 @@ export function resolveDistributionFromInputs(
1964
1995
  // fact none of their entries carries. A lock in which SOME biome carries
1965
1996
  // it still is the one v5 grammar once sealed.
1966
1997
  ...(e.bundleSource === undefined ? {} : { bundleSource: e.bundleSource }),
1998
+ ...bundleSignerFor(e),
1967
1999
  serviceDependencies: Array.isArray(e.serviceDependencies)
1968
2000
  ? e.serviceDependencies
1969
2001
  : [],
@@ -2125,7 +2157,11 @@ export function resolveDistributionFromInputs(
2125
2157
  // scopes on — and the lock records the full pinned `LockedBiomeSource`,
2126
2158
  // so a reader of the artifact never has to resolve a handle against a
2127
2159
  // manifest it does not have.
2128
- ...(e.source === undefined
2160
+ //
2161
+ // A third-party OCI biome is stamped too (the edition fence keys on the stamp) but is NOT in `sourceRecords`:
2162
+ // the lock's `LockedBiomeSource` is git-only, so such a row carries no `source` and is held to its pin instead
2163
+ // (`third-party-subjects.mjs`).
2164
+ ...(e.source === undefined || isThirdPartyOciSourceEntry(e, sourceRecords)
2129
2165
  ? {}
2130
2166
  : { source: requireSourceRecord(e, sourceRecords) }),
2131
2167
  }))
@@ -2353,7 +2389,12 @@ export async function resolveDistributionWithIndex(id, { materialize, inputs, re
2353
2389
  id,
2354
2390
  index,
2355
2391
  schemas,
2356
- { ...composed, sourceRecords, storePublisherTrust },
2392
+ {
2393
+ ...composed,
2394
+ sourceRecords,
2395
+ storePublisherTrust,
2396
+ bundleSigners: inputs?.bundleSigners ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas }),
2397
+ },
2357
2398
  new Date().toISOString(),
2358
2399
  platformServiceIndex,
2359
2400
  );
@@ -7,7 +7,7 @@
7
7
  // node resolve-edition.mjs <root flags> <edition-id> --trusted-write-v3
8
8
  // write the whole lock set into the editions root. The flag is the
9
9
  // operator's explicit acknowledgement that the images lock is rewritten
10
- // UNSEALED (a resolve never produces an `imageDigest`; `xema edition seal`
10
+ // UNSEALED (a resolve never produces an `imageDigest`; `xema edition resolve-digests`
11
11
  // writes them afterwards). Without it nothing is written.
12
12
  // node resolve-edition.mjs <root flags> <edition-id> --check
13
13
  // resolve the whole lock set into a scratch copy of the editions root and
@@ -72,6 +72,14 @@ import {
72
72
  } from './lib.mjs';
73
73
  import { assertReleaseContent } from './release-channel-guard.mjs';
74
74
  import { composeEdition } from './resolve-distribution.mjs';
75
+ import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
76
+ import {
77
+ assertOciBundleSourceOrigin,
78
+ attestedContentHash,
79
+ isThirdPartyOciRow,
80
+ thirdPartyPinForRow,
81
+ thirdPartySubjectCandidates,
82
+ } from './third-party-subjects.mjs';
75
83
 
76
84
  if (isEntryModule(import.meta.url)) {
77
85
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
@@ -418,7 +426,7 @@ export function resolveImageStructure(
418
426
  enums,
419
427
  requiredPlatforms,
420
428
  declaredSources,
421
- { biomeIndex, supplementalSources } = {},
429
+ { biomeIndex, supplementalSources, thirdPartyPins } = {},
422
430
  ) {
423
431
  if (!Array.isArray(requiredPlatforms) || requiredPlatforms.length === 0) {
424
432
  throw new Error(
@@ -440,6 +448,9 @@ export function resolveImageStructure(
440
448
  );
441
449
  }
442
450
  const subjects = new Map();
451
+ // Third-party OCI subjects come from their pin and are never built, hashed or read from a checkout, so they are kept
452
+ // apart from every git-backed subject (see `third-party-subjects.mjs`).
453
+ const thirdPartySubjects = new Map();
443
454
  const webBiomes = [];
444
455
  const platformServiceBindings = [];
445
456
  const repositoryPins = new Map(
@@ -479,6 +490,15 @@ export function resolveImageStructure(
479
490
  };
480
491
 
481
492
  for (const biome of lock.biomes) {
493
+ const biomeLabel = `distribution "${lock.distributionId}" biome "${biome.id}"`;
494
+ assertOciBundleSourceOrigin(biome, biomeLabel);
495
+ if (isThirdPartyOciRow(biome)) {
496
+ const pin = thirdPartyPinForRow({ row: biome, pins: thirdPartyPins, declaredSources, label: biomeLabel });
497
+ for (const candidate of thirdPartySubjectCandidates({ row: biome, pin, enums, requiredPlatforms, label: biomeLabel })) {
498
+ addSubject(thirdPartySubjects, candidate);
499
+ }
500
+ continue;
501
+ }
482
502
  // WHERE THIS BIOME'S IMAGES LIVE, and WHICH COMMIT THEY ARE BUILT FROM.
483
503
  // Both answers move together when a biome is drawn from an external source,
484
504
  // because both are facts about that source rather than about this fleet.
@@ -662,7 +682,7 @@ export function resolveImageStructure(
662
682
  // distribution resolve if it dispatches, and the first-party index carries
663
683
  // none, so it is not looked up here.
664
684
  const dispatchers = lock.biomes
665
- .filter((biome) => biome.source === undefined && biome.target === 'server')
685
+ .filter((biome) => biome.source === undefined && !isThirdPartyOciRow(biome) && biome.target === 'server')
666
686
  .map((biome) => {
667
687
  const entries = biomeIndex.filter(
668
688
  (entry) =>
@@ -892,13 +912,29 @@ export function resolveImageStructure(
892
912
  platformServiceBindings.sort((left, right) =>
893
913
  compareStrings(left.platformService, right.platformService),
894
914
  );
915
+ const orderedThirdPartySubjects = [...thirdPartySubjects.values()]
916
+ .map((subject) => ({ ...subject, owners: subject.owners.sort(compareOwner) }))
917
+ .sort(compareSubject);
918
+ for (const subject of orderedThirdPartySubjects) {
919
+ if (subjects.has(subjectKey(subject))) {
920
+ throw new Error(
921
+ `third-party runtime image subject "${subject.kind}:${subject.id}" has the identity of another subject of distribution "${lock.distributionId}"`,
922
+ );
923
+ }
924
+ }
895
925
  return {
896
926
  subjects: orderedSubjects,
927
+ thirdPartySubjects: orderedThirdPartySubjects,
897
928
  webBiomes,
898
929
  platformServiceBindings,
899
930
  };
900
931
  }
901
932
 
933
+ /** Every image subject of a structure, first-party and third-party, in the lock's order. */
934
+ export function structureSubjects(structure) {
935
+ return [...structure.subjects, ...structure.thirdPartySubjects].sort(compareSubject);
936
+ }
937
+
902
938
  /**
903
939
  * THE COMPOSED-PROCESS IMAGE OF ONE BUILD — where a producing repository
904
940
  * declares it, and how an edition addresses it.
@@ -1775,7 +1811,11 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
1775
1811
  schemas,
1776
1812
  requiredPlatforms,
1777
1813
  declaredSources,
1778
- { biomeIndex, supplementalSources: supplementalSourcesFor(runtimeSourceIndex) },
1814
+ {
1815
+ biomeIndex,
1816
+ supplementalSources: supplementalSourcesFor(runtimeSourceIndex),
1817
+ thirdPartyPins: loadThirdPartySourcePins(dataRoot()),
1818
+ },
1779
1819
  ),
1780
1820
  schemas,
1781
1821
  requiredPlatforms,
@@ -1909,8 +1949,11 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
1909
1949
  'refusing image-lock write: a source repository commit changed while hashing',
1910
1950
  );
1911
1951
  }
1952
+ // After the ledger check and the hashing: a third-party subject has no ledger row and no checkout, and its
1953
+ // `contentHash` is the publisher's statement, so none of the above read it.
1912
1954
  return {
1913
- subjects,
1955
+ subjects: structure.thirdPartySubjects.length === 0 ? subjects : [...subjects, ...structure.thirdPartySubjects].sort(compareSubject),
1956
+ thirdPartySubjects: structure.thirdPartySubjects,
1914
1957
  platformServiceBindings: structure.platformServiceBindings,
1915
1958
  webBiomes,
1916
1959
  contractAuthority: runtimeSourceIndex.contractAuthority,
@@ -1920,7 +1963,7 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {
1920
1963
 
1921
1964
  /** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
1922
1965
  export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {}) {
1923
- const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed });
1966
+ const { subjects, thirdPartySubjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed });
1924
1967
  const schemas = await loadKernelDistributionSchemas();
1925
1968
  const imageLock = validateImmutableImageLock(
1926
1969
  schemas.parseImageLock({
@@ -1934,7 +1977,7 @@ export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {})
1934
1977
  }),
1935
1978
  schemas,
1936
1979
  );
1937
- return { imageLock, commits };
1980
+ return { imageLock, commits, thirdPartySubjects };
1938
1981
  }
1939
1982
 
1940
1983
  /**
@@ -1954,7 +1997,7 @@ export async function writeResolvedImages(
1954
1997
  }
1955
1998
  // The RELEASE lock is published-only: a develop edition resolves through
1956
1999
  // `develop-edition.mjs` and never reaches this writer.
1957
- const { imageLock, commits } = await resolveImages(id);
2000
+ const { imageLock, commits, thirdPartySubjects } = await resolveImages(id);
1958
2001
  const outPath = distributionOutputPath(
1959
2002
  editionsRoot(),
1960
2003
  id,
@@ -1969,6 +2012,9 @@ export async function writeResolvedImages(
1969
2012
  for (const [repo, commit] of commits) {
1970
2013
  console.log(` source ${repo}@${commit}`);
1971
2014
  }
2015
+ for (const subject of thirdPartySubjects) {
2016
+ console.log(` third-party ${subject.kind}:${subject.id} ${subject.imageRef} ${attestedContentHash(subject)}`);
2017
+ }
1972
2018
  }
1973
2019
  return { imageLock, commits };
1974
2020
  }
@@ -121,7 +121,7 @@ import { parseCustomerDefinition } from './customer-definition.mjs';
121
121
  import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, DEFINITION_FLAG } from './customer-flags.mjs';
122
122
  import { EditionChannel } from './edition-constants.mjs';
123
123
  import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
124
- import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
124
+ import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
125
125
  import {
126
126
  assertCoordinatesFinal,
127
127
  collectFleetPlatformRequirements,
@@ -137,6 +137,8 @@ import { externalSourceTrees, sourceTreeLocator } from './resolve-images.mjs';
137
137
  import { readSealedDistributionLock } from './sealed-distribution-lock.mjs';
138
138
  import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
139
139
  import { readStrictJsonFile } from './strict-json.mjs';
140
+ import { pinnedManifestDigests, thirdPartyImagePins } from './third-party-image-pins.mjs';
141
+ import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
140
142
  import {
141
143
  loadImageLockForVerification,
142
144
  verifyDistributionPlatforms,
@@ -569,14 +571,14 @@ export async function sealEditionLocks({ distributionId, root, digests = new Map
569
571
  * The decision, with no files: an UNSEALED lock is sealed (refused under `check`); a SEALED lock is VERIFIED -
570
572
  * every hash recomputed, a mismatch refused naming the biome.
571
573
  */
572
- export async function sealOrVerifyLock({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, check = false, lockPath = 'distribution.lock.json' }) {
574
+ export async function sealOrVerifyLock({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners, check = false, lockPath = 'distribution.lock.json' }) {
573
575
  if ('unsealedSchema' in lock) {
574
576
  if (check) {
575
- throw new Error(`${lockPath} is unsealed (no biomeContentHash); run \`xema edition seal ${lock.distributionId}\``);
577
+ throw new Error(`${lockPath} is unsealed (no biomeContentHash); run \`xema edition resolve-digests ${lock.distributionId}\``);
576
578
  }
577
- return { sealed: true, lock: await sealLock({ schemas, unsealed: lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys }) };
579
+ return { sealed: true, lock: await sealLock({ schemas, unsealed: lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners }) };
578
580
  }
579
- await verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys });
581
+ await verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners });
580
582
  return { sealed: false, lock };
581
583
  }
582
584
 
@@ -586,7 +588,7 @@ async function main() {
586
588
  parseSealArgs(args);
587
589
  if (!distributionId || rest.length > 0) {
588
590
  throw new Error(
589
- 'usage: xema edition seal <distributionId> ' +
591
+ 'usage: xema edition resolve-digests <distributionId> ' +
590
592
  `[--check | --if-published | --publication-verdict] ` +
591
593
  `[--definition <file> [--catalogue-username <name> --catalogue-token-env <VAR>]]`,
592
594
  );
@@ -700,12 +702,16 @@ async function main() {
700
702
  ? undefined
701
703
  : parseCustomerDefinition(await adoptCatalogueKernel(catalogue), readStrictJsonFile(catalogueFlags.get(DEFINITION_FLAG))).definition.storePublisherAnchorKeys;
702
704
  const imageLock = await loadImageLockForVerification(distributionId, root);
705
+ // A pinned third-party subject (its repository is in the data root's third-party source pins) is verified and sealed
706
+ // BY the pin's manifest digest: its contentHash is publisher-attested and is not a tag.
707
+ const thirdPartyPins = loadTrustedThirdPartySourcePins(dataRoot());
708
+ const pinnedImages = thirdPartyImagePins({ pins: thirdPartyPins, subjects: imageLock.subjects });
703
709
 
704
710
  // Throws on ANY unresolvable subject or missing required platform, before a
705
711
  // single byte of the lock is touched.
706
712
  let report;
707
713
  try {
708
- report = await verifyDistributionPlatforms({ imageLock, distributionsRoot: root });
714
+ report = await verifyDistributionPlatforms({ imageLock, distributionsRoot: root, pinnedDigests: pinnedManifestDigests(pinnedImages) });
709
715
  } catch (error) {
710
716
  // RECONCILE: a coordinate this pass just re-pinned has not been built yet.
711
717
  // Skip the EDITION — never some of its subjects — and leave the lock
@@ -839,14 +845,19 @@ async function main() {
839
845
  // introduced and the repository holding every edition's lock is where the
840
846
  // question is answerable at all.
841
847
  const { editions, withoutLock } = readFleetImageLocks(root);
842
- const fleetRequirements = collectFleetPlatformRequirements({ editions });
848
+ const fleetRequirements = collectFleetPlatformRequirements({ editions, isThirdPartySubject: (subject) => thirdPartyPins.byRepository(subject.repo) !== undefined });
843
849
  try {
844
850
  const finality = assertCoordinatesFinal({
845
851
  distributionId,
846
852
  writes,
847
853
  verified: report.subjects,
848
854
  fleetRequirements,
855
+ thirdPartyKeys: new Set(pinnedImages.keys()),
849
856
  });
857
+ // Third-party subjects are NOT graded by the first-party fleet verdict, and are named here rather than skipped silently.
858
+ for (const key of finality.thirdParty) {
859
+ console.log(`[${label}] finality: ${key} third-party (pinned by digest), not a first-party fleet coordinate.`);
860
+ }
850
861
  // The CORPUS, not a confirmation: how many coordinates were graded against
851
862
  // how many editions, and which editions made no claim. Without the
852
863
  // denominators a pass cannot be told from a pass over nothing, and the
@@ -1,5 +1,5 @@
1
1
  // sealed-distribution-lock — the one predicate for "this committed distribution.lock.json is the SEALED grammar".
2
- // `xema edition artifact` and the `xema edition seal --publication-verdict` gate both ask it, so the verdict can
2
+ // `xema edition artifact` and the `xema edition resolve-digests --publication-verdict` gate both ask it, so the verdict can
3
3
  // never say PUBLISHABLE for a lock the artifact verb then refuses.
4
4
 
5
5
  import { readFileSync } from 'node:fs';
@@ -15,7 +15,7 @@ export function readSealedDistributionLock({ schemas, lockPath, distributionId }
15
15
  } catch (error) {
16
16
  throw new Error(
17
17
  `${lockPath} is not a sealed distribution lock, so nothing is published from it. ` +
18
- `Run \`xema edition seal ${distributionId}\` first. (${error.message})`,
18
+ `Run \`xema edition resolve-digests ${distributionId}\` first. (${error.message})`,
19
19
  { cause: error },
20
20
  );
21
21
  }
@@ -0,0 +1,91 @@
1
+ // Admission of third-party image subjects (XEMA-1611 design section 4), beside `externalSourceRepositoryPins` and
2
+ // `verifyDistributionImageEvidenceSet` of `image-evidence.mjs`. Pure over data: the pins come from the data root's
3
+ // `third-party-source-pins.json` (held to the evidence trust pin by the entry), the statement and bundle from a
4
+ // descriptor `verifySigstoreEvidenceDescriptor` has just verified.
5
+
6
+ import { assertCertificateExtensions } from './third-party-certificate-extensions.mjs';
7
+
8
+ /** The declared source kind that names a third-party pin. */
9
+ const OCI_SOURCE_KIND = 'oci';
10
+ const FIRST_PARTY_LAYOUT_PREFIX = /^(?:repos|submodules)\//u;
11
+
12
+ const repositoryPathOf = (repositoryUrl) => new URL(repositoryUrl).pathname.replace(/^\//u, '').replace(/\.git$/u, '');
13
+
14
+ /**
15
+ * The repository pins of the edition's declared OCI sources, derived from their third-party source pins:
16
+ * `repo` (the pinned `owner/name`), `repositoryUrl` and `sourceCommit`. Every declared OCI source must be pinned (a
17
+ * missing or empty pin file refuses, with the loader's reason), and the pin must not collide with an index pin or a git
18
+ * source, nor sit under the first-party layout (`repos/`, `submodules/`). A declared source that ships no image yields no
19
+ * pin; every subject of a source must agree on the pinned commit.
20
+ *
21
+ * @returns {Map<string, {repo: string, repositoryUrl: string, sourceCommit: string}>}
22
+ */
23
+ export function thirdPartySourceRepositoryPins({ distribution, imageLock, repositoryPins, thirdPartyPins }) {
24
+ const sources = distribution.sources ?? [];
25
+ const derived = new Map();
26
+ for (const source of sources) {
27
+ if (source.kind !== OCI_SOURCE_KIND) {continue;}
28
+ const pin = thirdPartyPins.byName(source.name);
29
+ if (pin === undefined) {
30
+ throw new Error(
31
+ `edition source "${source.name}" is kind "${OCI_SOURCE_KIND}", but ${thirdPartyPins.reason ?? `the data root's third-party source pins hold no pin named "${source.name}"`}`,
32
+ );
33
+ }
34
+ if (FIRST_PARTY_LAYOUT_PREFIX.test(pin.repository)) {
35
+ throw new Error(`third-party source "${pin.name}" pins repository "${pin.repository}", which is under the first-party layout (repos/ or submodules/)`);
36
+ }
37
+ if (repositoryPins.has(pin.repository)) {
38
+ throw new Error(`third-party source "${pin.name}" repository "${pin.repository}" shadows a repository pin the runtime image source index already commits`);
39
+ }
40
+ const shadowed = sources.find((candidate) => candidate.kind === 'git' && (candidate.name === pin.repository || candidate.name === pin.name));
41
+ if (shadowed !== undefined) {
42
+ throw new Error(`third-party source "${pin.name}" (${pin.repository}) collides with the git source "${shadowed.name}" of the same edition`);
43
+ }
44
+ const subjects = imageLock.subjects.filter((subject) => subject.repo === pin.repository);
45
+ if (subjects.length === 0) {continue;}
46
+ const wrong = subjects.filter((subject) => subject.sourceCommit !== pin.sourceCommit);
47
+ if (wrong.length > 0) {
48
+ throw new Error(
49
+ `third-party source "${pin.name}" is pinned at ${pin.sourceCommit}, but images.lock.json builds ${wrong.map((subject) => `${subject.kind}:${subject.id}`).join(', ')} at ${[...new Set(wrong.map((subject) => subject.sourceCommit))].join(', ')}`,
50
+ );
51
+ }
52
+ derived.set(pin.repository, { repo: pin.repository, repositoryUrl: pin.repositoryUrl, sourceCommit: pin.sourceCommit });
53
+ }
54
+ return derived;
55
+ }
56
+
57
+ /** The pins behind `thirdPartySourceRepositoryPins`, by repository. */
58
+ export const thirdPartySourcesByRepository = (derived, thirdPartyPins) =>
59
+ new Map([...derived.keys()].map((repo) => [repo, thirdPartyPins.byRepository(repo)]));
60
+
61
+ /**
62
+ * What a third-party subject must satisfy beyond a first-party one, after its evidence has verified:
63
+ * * [d9-1] the signing row's workflow repository, the subject's `repo` and the path of the predicate's
64
+ * `source.repositoryUrl` are one repository;
65
+ * * the subject is the pinned image (reference, content hash, sealed digest when sealed);
66
+ * * (d) the leaf certificate states the pinned repository id, owner id and a GitHub-hosted runner.
67
+ */
68
+ export function assertThirdPartySubjectAdmission({ subject, source, statement, identityPolicy, bundle }) {
69
+ const label = `third-party subject "${subject.kind}:${subject.id}" (source "${source.name}")`;
70
+ const claimedRepository = repositoryPathOf(statement.predicate.source.repositoryUrl);
71
+ const signerRepository = identityPolicy.githubWorkflow.repository;
72
+ if (signerRepository !== subject.repo || claimedRepository !== subject.repo) {
73
+ throw new Error(
74
+ `${label}: the signing row's workflow repository (${signerRepository}), the subject repo (${subject.repo}) and the predicate source.repositoryUrl repository (${claimedRepository}) must be one repository`,
75
+ );
76
+ }
77
+ const image = source.images.find((candidate) => candidate.subjectKind === subject.kind && candidate.subjectId === subject.id);
78
+ if (image === undefined) {
79
+ throw new Error(`${label}: the pin pins no image for it`);
80
+ }
81
+ if (subject.imageRef !== image.imageRef) {
82
+ throw new Error(`${label}: images.lock.json names ${subject.imageRef}, but the pin pins ${image.imageRef}`);
83
+ }
84
+ if (subject.contentHash !== image.sourceTreeHash) {
85
+ throw new Error(`${label}: images.lock.json has contentHash ${subject.contentHash}, but the pin records sourceTreeHash ${image.sourceTreeHash}`);
86
+ }
87
+ if (subject.imageDigest !== undefined && subject.imageDigest !== image.manifestDigest) {
88
+ throw new Error(`${label}: images.lock.json seals ${subject.imageDigest}, but the pin records manifestDigest ${image.manifestDigest}`);
89
+ }
90
+ assertCertificateExtensions({ bundle, source, label });
91
+ }
@@ -0,0 +1,147 @@
1
+ // The registry side of the third-party writer (XEMA-1611 design section 3 (a)): the biome bundle, by digest, and the
2
+ // sigstore bundle of its SLSA provenance, read as an OCI referrer. Nothing here is trusted: every byte it returns is
3
+ // verified by cosign (`third-party-source-evidence.mjs`) before it is committed.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
8
+ import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
9
+ import { splitImageRef } from './registry-credentials.mjs';
10
+ import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
11
+ import { SLSA_PROVENANCE_V1, decodeJsonStatement, digestOfOciRef } from './third-party-source-evidence.mjs';
12
+ import { assertPreparedExecutable } from './trusted-executable.mjs';
13
+
14
+ const BIOME_MANIFEST_FILE = 'xema-biome.json';
15
+ const BIOME_GENERATED_INDEX_FILE = 'xema-biome.index.json';
16
+ const OCI_TITLE_ANNOTATION = 'org.opencontainers.image.title';
17
+ const MAX_BIOME_MANIFEST_BYTES = 4 * 1024 * 1024;
18
+ const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
19
+ const REGISTRY_COSIGN_TIMEOUT_MS = 120_000;
20
+ /** What `cosign verify-attestation --type` calls the SLSA v1 provenance predicate. */
21
+ const COSIGN_SLSA_PROVENANCE_ALIAS = 'slsaprovenance1';
22
+
23
+ const repositoryOf = (ociRef) => splitImageRef(ociRef.slice(0, ociRef.indexOf('@')));
24
+
25
+ /**
26
+ * The biome bundle manifest JSON (`xema-biome.json`) of an OCI artifact, fetched by digest.
27
+ *
28
+ * @returns {Promise<{manifest: object, bytes: Buffer}>}
29
+ */
30
+ export async function fetchBundleManifest({ clientFor, ociRef }) {
31
+ const bytes = await fetchTitledLayer({ clientFor, ociRef, title: BIOME_MANIFEST_FILE });
32
+ return { manifest: decodeJsonStatement(bytes, `${ociRef} ${BIOME_MANIFEST_FILE}`), bytes };
33
+ }
34
+
35
+ /**
36
+ * The committed generated index (`xema-biome.index.json`) of an OCI biome bundle, fetched by digest: the authority for
37
+ * the biomes the bundle requires, read from the same bundle bytes as its manifest, so it cannot describe another revision.
38
+ */
39
+ export async function fetchBundleGeneratedIndex({ clientFor, ociRef }) {
40
+ const bytes = await fetchTitledLayer({ clientFor, ociRef, title: BIOME_GENERATED_INDEX_FILE });
41
+ return decodeJsonStatement(bytes, `${ociRef} ${BIOME_GENERATED_INDEX_FILE}`);
42
+ }
43
+
44
+ /** The one layer of the bundle manifest at `ociRef` (by digest) annotated with `title`. */
45
+ async function fetchTitledLayer({ clientFor, ociRef, title }) {
46
+ const { authority, repository } = repositoryOf(ociRef);
47
+ const client = clientFor(authority);
48
+ const { document } = await client.getManifestByDigest(repository, digestOfOciRef(ociRef));
49
+ const layers = (document.layers ?? []).filter((layer) => layer.annotations?.[OCI_TITLE_ANNOTATION] === title);
50
+ if (layers.length !== 1) {
51
+ throw new Error(`${ociRef} has ${layers.length} layers titled ${title}; a biome bundle has exactly one`);
52
+ }
53
+ const bytes = await client.getBlob(repository, layers[0].digest, MAX_BIOME_MANIFEST_BYTES);
54
+ if (sha256Digest(bytes) !== layers[0].digest) {
55
+ throw new Error(`${ociRef} layer titled ${title} does not match its descriptor`);
56
+ }
57
+ return bytes;
58
+ }
59
+
60
+ /**
61
+ * The image names a biome manifest ships: its service and worker components with an `oci-image` artifact, named the way
62
+ * the index builder names them (a service by `protocol.serviceName`, a worker by its component `key`).
63
+ */
64
+ export function bundleImageNames(manifest) {
65
+ const components = Array.isArray(manifest?.xema?.components) ? manifest.xema.components : [];
66
+ return new Set(
67
+ components
68
+ .filter((component) => (component?.kind === 'service' || component?.kind === 'worker') && component.artifact?.kind === 'oci-image')
69
+ .map((component) => (component.kind === 'service' ? component.protocol?.serviceName : component.key)),
70
+ );
71
+ }
72
+
73
+ function provenanceOf(bundleBytes, bundleDigest) {
74
+ const bundle = JSON.parse(decodeExactUtf8(bundleBytes, 'sigstore bundle'));
75
+ if (bundle?.dsseEnvelope === undefined) {
76
+ return null; // a message-signature bundle (the artifact signature): not an attestation
77
+ }
78
+ const payload = Buffer.from(bundle.dsseEnvelope.payload, 'base64');
79
+ const statement = decodeJsonStatement(payload, 'DSSE payload');
80
+ if (statement.predicateType !== SLSA_PROVENANCE_V1) {
81
+ return null;
82
+ }
83
+ if (`sha256:${statement.subject?.[0]?.digest?.sha256}` !== bundleDigest) {
84
+ throw new Error(`a SLSA provenance referrer attests ${statement.subject?.[0]?.digest?.sha256}, not ${bundleDigest}`);
85
+ }
86
+ return { statementBytes: payload, bundleBytes };
87
+ }
88
+
89
+ /**
90
+ * The SLSA provenance attestation of the bundle: the one sigstore-bundle referrer of `ociRef` whose DSSE statement is a
91
+ * `https://slsa.dev/provenance/v1` attestation over exactly this digest. None, or more than one, is refused.
92
+ *
93
+ * @returns {Promise<{statementBytes: Buffer, bundleBytes: Buffer}>}
94
+ */
95
+ export async function fetchBundleProvenance({ clientFor, ociRef }) {
96
+ const { authority, repository } = repositoryOf(ociRef);
97
+ const client = clientFor(authority);
98
+ const subjectDigest = digestOfOciRef(ociRef);
99
+ const referrers = await client.listReferrers(repository, subjectDigest, BUNDLE_MEDIA_TYPE);
100
+ const found = [];
101
+ for (const referrer of referrers) {
102
+ const { document } = await client.getManifestByDigest(repository, referrer.digest);
103
+ for (const layer of document.layers ?? []) {
104
+ if (layer.mediaType !== BUNDLE_MEDIA_TYPE) {continue;}
105
+ const bytes = await client.getBlob(repository, layer.digest, MAX_BUNDLE_BYTES);
106
+ if (sha256Digest(bytes) !== layer.digest) {
107
+ throw new Error(`${ociRef} referrer ${referrer.digest} layer does not match its descriptor`);
108
+ }
109
+ const provenance = provenanceOf(bytes, subjectDigest);
110
+ if (provenance !== null) {found.push(provenance);}
111
+ }
112
+ }
113
+ if (found.length === 0) {
114
+ throw new Error(`${ociRef} has no SLSA provenance attestation (${SLSA_PROVENANCE_V1}) among its ${referrers.length} sigstore-bundle referrers; the bundle must be published with provenance`);
115
+ }
116
+ if (found.length > 1) {
117
+ throw new Error(`${ociRef} has ${found.length} SLSA provenance attestations; exactly one is required, so the evidence is unambiguous`);
118
+ }
119
+ return found[0];
120
+ }
121
+
122
+ const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
123
+
124
+ /**
125
+ * (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
126
+ * artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
127
+ * commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
128
+ * with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
129
+ */
130
+ export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
131
+ const root = validatePinnedTrustedRoot(policyRoot, policy);
132
+ const common = ['--offline', '--trusted-root', root.path, ...certificateConstraintArgs(row, workflowSha)];
133
+ const environment = {
134
+ ...COSIGN_ENV,
135
+ ...(env.HOME === undefined ? {} : { HOME: env.HOME }),
136
+ ...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
137
+ };
138
+ const run = (command, args) => {
139
+ try {
140
+ execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], { env: environment, stdio: ['ignore', 'ignore', 'pipe'], timeout: REGISTRY_COSIGN_TIMEOUT_MS });
141
+ } catch (error) {
142
+ throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
143
+ }
144
+ };
145
+ run('verify', [...common, ociRef]);
146
+ run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
147
+ }
@@ -0,0 +1,21 @@
1
+ // (d) of the third-party checks: the Fulcio extensions of a bundle's leaf certificate, held to a source pin. Its own module
2
+ // (it imports no evidence module) because admission in `image-evidence.mjs` and the pin verifier both use it.
3
+
4
+ import { FulcioExtensionOid, GITHUB_HOSTED_RUNNER_ENVIRONMENT, readFulcioExtensionsFromBundle } from './fulcio-certificate-extensions.mjs';
5
+
6
+ /** The leaf certificate of a parsed Sigstore `bundle` states the pinned repository id, owner id and a GitHub-hosted runner. */
7
+ export function assertCertificateExtensions({ bundle, source, label }) {
8
+ const read = readFulcioExtensionsFromBundle(bundle);
9
+ const expectations = [
10
+ [FulcioExtensionOid.SourceRepositoryIdentifier, 'Source Repository Identifier', read.sourceRepositoryIdentifier, source.repositoryId, 'repositoryId'],
11
+ [FulcioExtensionOid.SourceRepositoryOwnerIdentifier, 'Source Repository Owner Identifier', read.sourceRepositoryOwnerIdentifier, source.repositoryOwnerId, 'repositoryOwnerId'],
12
+ [FulcioExtensionOid.RunnerEnvironment, 'Runner Environment', read.runnerEnvironment, GITHUB_HOSTED_RUNNER_ENVIRONMENT, GITHUB_HOSTED_RUNNER_ENVIRONMENT],
13
+ ];
14
+ for (const [oid, name, actual, expected, pinned] of expectations) {
15
+ if (actual !== expected) {
16
+ throw new Error(
17
+ `${label} certificate ${name} (${oid}) is ${JSON.stringify(actual)}, but the pin requires ${JSON.stringify(expected)} (${pinned})`,
18
+ );
19
+ }
20
+ }
21
+ }