@xemahq/xema 0.40.3 → 0.41.1

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 (46) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +1 -0
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +289 -19
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +71 -47
  6. package/dist/runtime/edition/verb-map.d.ts +3 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +4 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/edition/README.md +5 -0
  11. package/edition/catalogue-evidence.mjs +225 -0
  12. package/edition/catalogue-fetch.mjs +40 -7
  13. package/edition/customer-edition-verbs.mjs +477 -0
  14. package/edition/customer-flags.mjs +2 -0
  15. package/edition/develop-coverage-readers.mjs +212 -0
  16. package/edition/develop-coverage-run.mjs +48 -0
  17. package/edition/develop-coverage-verdict.mjs +153 -0
  18. package/edition/develop-coverage.mjs +120 -0
  19. package/edition/develop-edition-resolve.mjs +18 -7
  20. package/edition/develop-edition.mjs +47 -220
  21. package/edition/develop-freeze.mjs +50 -13
  22. package/edition/develop-image-probe.mjs +7 -7
  23. package/edition/dispatched-runtime-components.mjs +90 -1
  24. package/edition/edition-artifact.mjs +31 -14
  25. package/edition/edition-constants.mjs +9 -0
  26. package/edition/edition-contract.json +2 -1
  27. package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
  28. package/edition/edition-workspace.mjs +4 -3
  29. package/edition/evidence-trust-pin.mjs +28 -8
  30. package/edition/evidence-trust-policy.mjs +51 -7
  31. package/edition/image-evidence.mjs +2 -2
  32. package/edition/lib.mjs +20 -11
  33. package/edition/oci-image-source-evidence.mjs +3 -0
  34. package/edition/oras-process.mjs +3 -3
  35. package/edition/oras.mjs +47 -5
  36. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  37. package/edition/produce-image-source-evidence.mjs +11 -2
  38. package/edition/release-edition.mjs +54 -0
  39. package/edition/repin-evidence-trust.mjs +5 -3
  40. package/edition/resolve-images.mjs +40 -5
  41. package/edition/sigstore-bundle-media-type.mjs +4 -0
  42. package/edition/trust-policy-arming.mjs +14 -1
  43. package/edition/verify-catalogue-evidence.mjs +245 -0
  44. package/edition/verify-image-evidence.mjs +15 -1
  45. package/package.json +2 -2
  46. package/edition/develop-customer-edition.mjs +0 -149
@@ -5,7 +5,7 @@
5
5
  // from edition-contract.json), addressed by digest, whose titled layers are
6
6
  // `xema-edition.json` (the descriptor) plus the lock files. It is written to a
7
7
  // scratch directory; the workflow pushes it with oras and signs the digest.
8
- // Tags are for humans only (develop: the hash of every input, developEditionTag; release: the
8
+ // Tags are for humans only (an edition resolved from a snapshot: the hash of every input, snapshotEditionTag; vendor release: the
9
9
  // coordinate). Admission rests on the signature of the manifest digest, never on
10
10
  // a channel label: no reader branches on `provenance.channel`.
11
11
  //
@@ -19,6 +19,8 @@ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
19
19
  import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
20
20
  import {
21
21
  CATALOGUE_PIN_KEY,
22
+ DEFINITION_PIN_KEY,
23
+ DIGEST_PIN_KEYS,
22
24
  EDITION_DESCRIPTOR_FILE,
23
25
  EDITION_DESCRIPTOR_SCHEMA,
24
26
  EDITION_LOCKS,
@@ -81,7 +83,7 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
81
83
  locks: [...EDITION_LOCKS],
82
84
  provenance: {
83
85
  channel,
84
- pins: channel === EditionChannel.Develop ? sortedSnapshotPins(pins) : { ...pins },
86
+ pins: sortedSnapshotPins(pins),
85
87
  workflow: { repository: workflow?.repository, runId: workflow?.runId },
86
88
  },
87
89
  evidenceTrustPolicy,
@@ -90,7 +92,7 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
90
92
  }
91
93
 
92
94
  const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
93
- const CATALOGUE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
95
+ const DIGEST_PIN_PATTERN = /^sha256:[0-9a-f]{64}$/;
94
96
 
95
97
  const exactKeys = (value, keys, where) => {
96
98
  if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) {
@@ -114,13 +116,14 @@ export function parseEditionDescriptor(value) {
114
116
  }
115
117
  const { pins, workflow } = value.provenance;
116
118
  if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
117
- throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (the "${CATALOGUE_PIN_KEY}" pin to a sha256 digest)`);
119
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (the ${DIGEST_PIN_KEYS.map((key) => `"${key}"`).join(' and ')} pins to sha256 digests)`);
118
120
  }
119
121
  for (const [key, pin] of Object.entries(pins)) {
120
- const pattern = key === CATALOGUE_PIN_KEY ? CATALOGUE_DIGEST_PATTERN : COMMIT_PATTERN;
122
+ const digestPin = DIGEST_PIN_KEYS.includes(key);
123
+ const pattern = digestPin ? DIGEST_PIN_PATTERN : COMMIT_PATTERN;
121
124
  if (typeof pin !== 'string' || !pattern.test(pin)) {
122
125
  throw new Error(
123
- key === CATALOGUE_PIN_KEY
126
+ digestPin
124
127
  ? `${EDITION_DESCRIPTOR_FILE}: provenance.pins["${key}"] must be a sha256:<64 hex> digest`
125
128
  : `${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (${JSON.stringify(key)} is not)`,
126
129
  );
@@ -238,15 +241,17 @@ export function canonicalSourceImages(sourceImages = new Map()) {
238
241
  }
239
242
 
240
243
  /**
241
- * A develop edition's tag: sha256 of the canonical `{ cli, engine, pins, sourceImages }`. Every input of
242
- * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins; the
244
+ * The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`. Every input of
245
+ * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins: a vendor edition's
246
+ * commits fix its definitions; a customer edition's pins carry its sources' commits, the catalogue digest and the
247
+ * digest of its canonical definition (`definition`: vendor selection, image registry mirror, Store publisher anchors); the
243
248
  * descriptor's `engine.image` is not a pin; a customer's `--source-image` map composes its own image rows
244
249
  * (`<registry>/<prefix><subject>`) and is not a pin either; and the CLI version is the writer of every
245
250
  * byte. A tag over the pins alone named two artifacts that differ in any of the others, and a re-run could
246
251
  * only refuse the existing one (`check-existing`) or reuse a stale one. `provenance.workflow` (the run that
247
252
  * wrote the bytes) is the one recorded field outside it: a fact about the artifact, not an input to it.
248
253
  */
249
- export function developEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
254
+ export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
250
255
  return sha256Hex(
251
256
  serializeJson({
252
257
  cli: version,
@@ -257,9 +262,14 @@ export function developEditionTag({ pins, engineImage, sourceImages = new Map(),
257
262
  );
258
263
  }
259
264
 
260
- /** A develop edition's target: the tag is `developEditionTag` of the pins, the engine and the source images. */
265
+ /** The target of an edition resolved from a snapshot of pins, on either channel: the tag is `snapshotEditionTag`. */
266
+ export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages }) {
267
+ return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages }));
268
+ }
269
+
270
+ /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine and the source images. */
261
271
  export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages }) {
262
- return editionTarget(distributionId, registry, EditionChannel.Develop, developEditionTag({ pins, engineImage, sourceImages }));
272
+ return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages });
263
273
  }
264
274
 
265
275
  function realpathOfPlanned(path) {
@@ -329,10 +339,11 @@ export function writeEditionArtifact({
329
339
  * An existing develop tag is reused only if it IS this snapshot: the tag
330
340
  * is a hash of its inputs, but a tag is mutable registry state, so the pulled
331
341
  * descriptor must say the same edition, the same channel, exactly the same pins,
332
- * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` given) must also record that digest
333
- * as its catalogue provenance pin. Throws naming the first difference.
342
+ * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` and
343
+ * `definitionDigest` given) must also record those digests as its catalogue and definition provenance pins. Throws naming
344
+ * the first difference.
334
345
  */
335
- export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest }) {
346
+ export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest, definitionDigest }) {
336
347
  const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
337
348
  if (!existsSync(descriptorPath)) {
338
349
  throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
@@ -354,6 +365,12 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
354
365
  `the pinned catalogue is ${catalogueDigest}; refusing to reuse it`,
355
366
  );
356
367
  }
368
+ if (definitionDigest !== undefined && existing.provenance.pins[DEFINITION_PIN_KEY] !== definitionDigest) {
369
+ throw new Error(
370
+ `the existing artifact was resolved from customer definition ${JSON.stringify(existing.provenance.pins[DEFINITION_PIN_KEY])}, ` +
371
+ `this definition is ${definitionDigest} (its selection, mirror or Store publisher anchors differ); refusing to reuse it`,
372
+ );
373
+ }
357
374
  if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
358
375
  throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
359
376
  }
@@ -48,6 +48,15 @@ if (!(CATALOGUE_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
48
48
  throw new Error('edition-contract.json provenancePins does not declare the catalogue pin');
49
49
  }
50
50
 
51
+ /** The digest of the canonical customer definition a customer edition was resolved from: every input the pins of its sources and catalogue do not fix. */
52
+ export const DEFINITION_PIN_KEY = 'definition';
53
+ if (!(DEFINITION_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
54
+ throw new Error('edition-contract.json provenancePins does not declare the definition pin');
55
+ }
56
+
57
+ /** The provenance pins that are sha256 digests, not commits. */
58
+ export const DIGEST_PIN_KEYS = Object.freeze([CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY]);
59
+
51
60
  /** The lock files an edition can carry. */
52
61
  export const EditionLockFile = Object.freeze({
53
62
  Distribution: 'distribution.lock.json',
@@ -23,7 +23,8 @@
23
23
  "catalogue": "catalogue.json"
24
24
  },
25
25
  "provenancePins": {
26
- "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against; every other pin is a 40-hex commit"
26
+ "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against",
27
+ "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit"
27
28
  },
28
29
  "imageNamespaceSegment": "dev",
29
30
  "evidenceTrust": {
@@ -1,10 +1,13 @@
1
- // develop-seal — seal a snapshot's images lock against the DEVELOP repositories.
1
+ // edition-image-seal — seal image subjects against the repositories of one channel.
2
2
  //
3
- // The release sealer verifies `<imageRef>:<contentHash>` and writes the digest
4
- // the registry served. This does the same for a develop edition, with the
3
+ // The vendor release sealer verifies `<imageRef>:<contentHash>` and writes the digest
4
+ // the registry served. This does the same for an edition of either channel, with the
5
5
  // SAME verifier (`verifyDistributionPlatforms`, required platforms enforced)
6
- // and the SAME digest decision (`planDigestSeal`), against the repository the
7
- // producer's develop lane pushes to (`developImageRef`, contract C1).
6
+ // and the SAME digest decision (`planDigestSeal`). Only the repository differs:
7
+ // develop the repository the producer's develop lane pushes to (`developImageRef`, contract C1)
8
+ // release the subject's own imageRef (the identity)
9
+ // It never builds or rebuilds an image: a coordinate that is not there is refused, and the digest
10
+ // sealed is the digest the registry serves for `<repository>:<contentHash>`.
8
11
  //
9
12
  // A coordinate that is not there is "not built yet": the producer's CI is not
10
13
  // green or still running. EVERY such subject is collected and named at once;
@@ -13,23 +16,39 @@
13
16
 
14
17
  import { developImageRef } from './develop-image-coordinate.mjs';
15
18
  import { notBuiltYetError } from './develop-image-probe.mjs';
19
+ import { EditionChannel } from './edition-channel.mjs';
16
20
  import { editionsRoot } from './edition-workspace.mjs';
17
21
  import { validateImmutableImageLock, loadKernelDistributionSchemas, } from './lib.mjs';
18
22
  import { isNotPublishedYet, planDigestSeal } from './seal-image-digests.mjs';
19
23
  import { verifyDistributionPlatforms } from './verify-image-platforms.mjs';
20
24
 
25
+ /** The repository a channel's own image subjects are verified and sealed in, from the subject's release imageRef. */
26
+ const CHANNEL_IMAGE_REF = Object.freeze({
27
+ [EditionChannel.Develop]: developImageRef,
28
+ [EditionChannel.Release]: (releaseImageRef) => releaseImageRef,
29
+ });
30
+
31
+ /** The repository `channel` seals a subject whose release imageRef is `releaseImageRef` in. */
32
+ export function channelImageRef(channel, releaseImageRef) {
33
+ const toChannelRef = CHANNEL_IMAGE_REF[channel];
34
+ if (toChannelRef === undefined) {throw new Error(`cannot seal image subjects for channel ${JSON.stringify(channel)}`);}
35
+ return toChannelRef(releaseImageRef);
36
+ }
37
+
21
38
  /**
22
- * The row-level seal: every subject re-homed to its develop repository, verified
39
+ * The row-level seal: every subject re-homed to its channel's repository, verified
23
40
  * there for its required platforms, and given the digest the registry served.
24
41
  * It takes SUBJECTS, not a lock, so a caller that owns only some rows of a lock
25
42
  * (a customer's own image rows) seals exactly those.
26
43
  *
27
44
  * @param {object} request
45
+ * @param {string} request.channel `develop` or `release`: which repositories the subjects are sealed in
28
46
  * @param {string} request.distributionId the edition the subjects belong to (named in refusals)
29
47
  * @param {object[]} request.subjects image subjects (release refs, unsealed)
30
- * @returns the subjects with develop refs and verified digests
48
+ * @returns the subjects with the channel's refs and verified digests
31
49
  */
32
- export async function sealDevelopImageSubjects({
50
+ export async function sealImageSubjects({
51
+ channel,
33
52
  distributionId,
34
53
  subjects,
35
54
  distributionsRoot = editionsRoot(),
@@ -38,12 +57,12 @@ export async function sealDevelopImageSubjects({
38
57
  run = null,
39
58
  progress = null,
40
59
  }) {
41
- const developSubjects = subjects.map((subject) => ({
60
+ const channelSubjects = subjects.map((subject) => ({
42
61
  ...subject,
43
- imageRef: developImageRef(subject.imageRef),
62
+ imageRef: channelImageRef(channel, subject.imageRef),
44
63
  }));
45
64
  const report = await verifyDistributionPlatforms({
46
- imageLock: { distributionId, subjects: developSubjects },
65
+ imageLock: { distributionId, subjects: channelSubjects },
47
66
  distributionsRoot,
48
67
  env,
49
68
  fetchImpl,
@@ -54,11 +73,11 @@ export async function sealDevelopImageSubjects({
54
73
  const aborting = report.failures.find(({ error }) => !isNotPublishedYet(error));
55
74
  if (aborting) {throw aborting.error;}
56
75
  if (report.failures.length > 0) {
57
- throw notBuiltYetError(distributionId, report.failures.map(({ subject }) => subject));
76
+ throw notBuiltYetError(distributionId, report.failures.map(({ subject }) => subject), channel);
58
77
  }
59
- const { writes, unchanged } = planDigestSeal({ subjects: developSubjects, verified: report.subjects });
78
+ const { writes, unchanged } = planDigestSeal({ subjects: channelSubjects, verified: report.subjects });
60
79
  const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
61
- return developSubjects.map((subject) => ({
80
+ return channelSubjects.map((subject) => ({
62
81
  ...subject,
63
82
  imageDigest: digests.get(`${subject.kind}:${subject.id}`),
64
83
  }));
@@ -70,10 +89,10 @@ export async function sealDevelopImageSubjects({
70
89
  *
71
90
  * @param {object} request
72
91
  * @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
73
- * @returns the images lock with develop refs and verified digests, kernel-validated
92
+ * @returns the images lock with the channel's refs and verified digests, kernel-validated
74
93
  */
75
- export async function sealDevelopImages({ imageLock, ...sealOptions }) {
76
- const subjects = await sealDevelopImageSubjects({
94
+ export async function sealImages({ imageLock, ...sealOptions }) {
95
+ const subjects = await sealImageSubjects({
77
96
  distributionId: imageLock.distributionId,
78
97
  subjects: imageLock.subjects,
79
98
  ...sealOptions,
@@ -182,9 +182,10 @@ export function isEntryModule(moduleUrl) {
182
182
  /**
183
183
  * The entry's one call: configure the roots from `process.argv` and remove the
184
184
  * root flags from it, so the entry's own parser sees only its own arguments.
185
- * `options` are those of `configureEditionWorkspaceFromArgv`.
185
+ * `options` are those of `configureEditionWorkspaceFromArgv`, plus `failureExitCode` (default 1): the exit code of
186
+ * a refused root, for a verb whose exit 1 already means something else.
186
187
  */
187
- export function configureEditionWorkspaceFromProcess(needs, options) {
188
+ export function configureEditionWorkspaceFromProcess(needs, { failureExitCode = 1, ...options } = {}) {
188
189
  let rest;
189
190
  try {
190
191
  rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs, options);
@@ -195,7 +196,7 @@ export function configureEditionWorkspaceFromProcess(needs, options) {
195
196
  process.stderr.write(
196
197
  `${error.message}\nusage: this command takes ${needs.map((name) => `${WORKSPACE_FLAGS[name]} <dir>`).join(' ')}\n`,
197
198
  );
198
- process.exit(1);
199
+ process.exit(failureExitCode);
199
200
  }
200
201
  process.argv.splice(2, process.argv.length - 2, ...rest);
201
202
  }
@@ -10,13 +10,15 @@
10
10
  // change and the registry reader cannot change without a reviewed data-root
11
11
  // commit.
12
12
  //
13
+ // A customer data root has no such index; its pin records `kernelContractAuthority: null` and pins only what exists.
14
+ //
13
15
  // The kernel contract authority is pinned HERE because nothing else can hold
14
16
  // it: `runtime-image-source-index.json` states the authority, and the image
15
17
  // locks and statements are compared with what that file states. Comparing the
16
18
  // file with itself decides nothing, so its authority is first held to this
17
19
  // pin, and only the pinned authority is what evidence is compared with.
18
20
 
19
- import { closeSync, constants as fsConstants, openSync, writeFileSync } from 'node:fs';
21
+ import { closeSync, constants as fsConstants, existsSync, openSync, writeFileSync } from 'node:fs';
20
22
  import { join } from 'node:path';
21
23
 
22
24
  import { COMMIT_PATTERN } from './evidence-grammars.mjs';
@@ -61,7 +63,7 @@ const VERIFIER_KEYS = Object.freeze(['bundleDigest', 'provenanceDigest']);
61
63
  const AUTHORITY_PIN_KEYS = Object.freeze(['digest', 'sourceCommit']);
62
64
  const PIN_SHAPE =
63
65
  '{schemaVersion, policyDigest, verifier: {bundleDigest, provenanceDigest}, ' +
64
- 'kernelContractAuthority: {sourceCommit, digest}, trustedOrasBinaryDigests: [sha256:…, …]}';
66
+ 'kernelContractAuthority: {sourceCommit, digest} | null, trustedOrasBinaryDigests: [sha256:…, …]}';
65
67
 
66
68
  const exactKeys = (value, keys) =>
67
69
  value !== null &&
@@ -117,7 +119,7 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
117
119
  if (
118
120
  !exactKeys(pin, PIN_KEYS) ||
119
121
  !exactKeys(pin.verifier, VERIFIER_KEYS) ||
120
- !exactKeys(pin.kernelContractAuthority, AUTHORITY_PIN_KEYS) ||
122
+ !(pin.kernelContractAuthority === null || exactKeys(pin.kernelContractAuthority, AUTHORITY_PIN_KEYS)) ||
121
123
  !Array.isArray(pin.trustedOrasBinaryDigests)
122
124
  ) {
123
125
  throw new Error(`${declaration.pin}: the pin is exactly ${PIN_SHAPE}`);
@@ -137,10 +139,13 @@ export function readEvidenceTrustPin({ dataRoot, declaration }) {
137
139
  'verifier.provenanceDigest',
138
140
  ),
139
141
  }),
140
- kernelContractAuthority: Object.freeze({
141
- sourceCommit: requireCommit(pin.kernelContractAuthority.sourceCommit),
142
- digest: requireDigest(pin.kernelContractAuthority.digest, 'kernelContractAuthority.digest'),
143
- }),
142
+ kernelContractAuthority:
143
+ pin.kernelContractAuthority === null
144
+ ? null
145
+ : Object.freeze({
146
+ sourceCommit: requireCommit(pin.kernelContractAuthority.sourceCommit),
147
+ digest: requireDigest(pin.kernelContractAuthority.digest, 'kernelContractAuthority.digest'),
148
+ }),
144
149
  trustedOrasBinaryDigests: requireOrasDigests(pin.trustedOrasBinaryDigests),
145
150
  });
146
151
  }
@@ -175,6 +180,14 @@ export function kernelContractAuthorityPin(authority) {
175
180
  });
176
181
  }
177
182
 
183
+ /**
184
+ * Whether the data root holds a runtime image source index. A vendor data root does; a customer data root has none
185
+ * (it consumes a catalogue and states no kernel contract authority of its own), and its pin records `null`.
186
+ */
187
+ export function hasRuntimeImageSourceIndex(dataRoot) {
188
+ return existsSync(join(dataRoot, RUNTIME_IMAGE_SOURCE_INDEX_FILE));
189
+ }
190
+
178
191
  /** The kernel contract authority the data root's runtime image source index STATES; nothing is trusted yet. */
179
192
  export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
180
193
  if (!schemas?.parseRuntimeImageSourceIndex) {
@@ -192,6 +205,13 @@ export function readStatedKernelContractAuthority({ dataRoot, schemas }) {
192
205
  /** Refuse an authority that is not the one the pin records. */
193
206
  export function assertPinnedKernelContractAuthority({ pin, authority, label }) {
194
207
  const actual = kernelContractAuthorityPin(authority);
208
+ if (pin.kernelContractAuthority === null) {
209
+ throw new KernelContractAuthorityPinError(
210
+ KERNEL_CONTRACT_AUTHORITY_NOT_PINNED_ONE,
211
+ `${label} states kernel contract authority ${actual.sourceCommit} (${actual.digest}) but the pin records none ` +
212
+ `(it was written for a data root without a runtime image source index); review the index, then re-pin with \`${REPIN_COMMAND}\``,
213
+ );
214
+ }
195
215
  if (
196
216
  actual.sourceCommit !== pin.kernelContractAuthority.sourceCommit ||
197
217
  actual.digest !== pin.kernelContractAuthority.digest
@@ -283,7 +303,7 @@ export function writeEvidenceTrustPin({
283
303
  bundleDigest: verifier.bundleDigest,
284
304
  provenanceDigest: verifier.provenanceDigest,
285
305
  },
286
- kernelContractAuthority: { ...kernelContractAuthorityPin(kernelContractAuthority) },
306
+ kernelContractAuthority: kernelContractAuthority === null ? null : { ...kernelContractAuthorityPin(kernelContractAuthority) },
287
307
  trustedOrasBinaryDigests: [...requireOrasDigests(trustedOrasBinaryDigests)].sort(),
288
308
  };
289
309
  const text = `${JSON.stringify(pin, null, 2)}\n`;
@@ -133,6 +133,28 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
133
133
  return { policy, policyDigest };
134
134
  }
135
135
 
136
+ /**
137
+ * The data root's declared policy, loaded and held to its pin exactly as the
138
+ * arming checks load it, for a READ-ONLY consumer (the develop coverage verb).
139
+ * A data root that declares no policy has nothing to report coverage against
140
+ * and is refused, naming the declaration it lacks. The POLICY is held to its pin; this CLI's built verifier
141
+ * is not. Coverage therefore runs this CLI's own, unpinned verifier code (case A), so a green coverage is a
142
+ * forecast of what resolve accepts, not a guarantee: resolve can still refuse under the pinned verifier.
143
+ *
144
+ * @returns {{ policy: object, dataRoot: string }}
145
+ */
146
+ export async function loadDeclaredPinnedPolicy() {
147
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
148
+ const root = configuredDataRoot();
149
+ if (declaration.policy === null) {
150
+ throw new Error(`${join(root, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy; there is no pinned policy to report coverage against`);
151
+ }
152
+ const schemas = await loadKernelDistributionSchemas();
153
+ const { policy, policyDigest } = loadDeclaredTrustPolicy({ dataRoot: root, declaration, schemas });
154
+ assertPinnedPolicy({ pin: readEvidenceTrustPin({ dataRoot: root, declaration }), policyDigest });
155
+ return { policy, dataRoot: root };
156
+ }
157
+
136
158
  /**
137
159
  * The arming of the data root's declared policy on `channel`, loaded and pinned
138
160
  * exactly as `descriptorEvidenceTrust` and the check load it: `{ declared, state,
@@ -140,9 +162,9 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
140
162
  * false` and one unmet entry; a declared policy that does not hold to its pin
141
163
  * (digest, verifier) throws. Nothing is written.
142
164
  *
143
- * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
165
+ * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
144
166
  */
145
- export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier }) {
167
+ export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
146
168
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
147
169
  const root = configuredDataRoot();
148
170
  const schemas = await loadKernelDistributionSchemas();
@@ -155,13 +177,26 @@ export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier
155
177
  };
156
178
  }
157
179
  const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
158
- const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
180
+ const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue });
159
181
  return { declared: true, state, unmet };
160
182
  }
161
183
 
162
184
  /** What a develop edition says, once, when it is written or checked without an armed develop channel. */
163
185
  export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
164
186
 
187
+ /** A customer develop edition under a policy ARMED on develop: its catalogue carries no E4 evidence to verify. */
188
+ export class CatalogueEvidenceUnverifiableError extends Error {
189
+ constructor(root) {
190
+ super(
191
+ `the data root ${root} declares an evidence trust policy ARMED on the develop channel, but a develop catalogue carries no ` +
192
+ 'signed edition-catalogue (E4) evidence, so the catalogue this customer develop edition consumes cannot be verified and ' +
193
+ 'the edition is never recorded as armed. Resolve it under a policy that is not armed on develop (no develop signer rows), ' +
194
+ 'or resolve a release edition, whose catalogue evidence is verified.',
195
+ );
196
+ this.name = 'CatalogueEvidenceUnverifiableError';
197
+ }
198
+ }
199
+
165
200
  /**
166
201
  * The evidence state an edition of `channel` records in its descriptor. Arming
167
202
  * is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
@@ -179,9 +214,15 @@ export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed
179
214
  * A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
180
215
  * ARMED on `channel` are refused on either channel.
181
216
  *
182
- * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
217
+ * `consumesCatalogue` (a customer edition consumes a catalogue): on RELEASE it adds the edition-catalogue (E4) row to the
218
+ * required ones, and the catalogue's E4 evidence is verified before it is parsed. A DEVELOP catalogue carries no E4
219
+ * evidence (the vendor's develop-edition workflow signs the artifact but attaches no E4 statement), so on develop no E4
220
+ * row is required and nothing can verify the catalogue: a customer develop edition is never written `armed`. A policy
221
+ * ARMED on develop throws CatalogueEvidenceUnverifiableError instead of recording a claim nothing checked.
222
+ *
223
+ * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string}, consumesCatalogue?: boolean }} options
183
224
  */
184
- export async function descriptorEvidenceTrust({ channel, locateVerifier = locateBuiltVerifier }) {
225
+ export async function descriptorEvidenceTrust({ channel, locateVerifier = locateBuiltVerifier, consumesCatalogue = false }) {
185
226
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
186
227
  const root = configuredDataRoot();
187
228
  const schemas = await loadKernelDistributionSchemas();
@@ -196,8 +237,11 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
196
237
  return developNotArmed();
197
238
  }
198
239
  const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
199
- const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
200
- if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
240
+ const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel, { consumesCatalogue: consumesCatalogue && release });
241
+ if (state === TrustPolicyArmingState.ARMED) {
242
+ if (consumesCatalogue && !release) {throw new CatalogueEvidenceUnverifiableError(root);}
243
+ return EvidenceTrustState.Armed;
244
+ }
201
245
  if (state === TrustPolicyArmingState.PARTIAL) {
202
246
  if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
203
247
  throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
@@ -42,7 +42,7 @@ const MAX_SIGSTORE_BUNDLE_BYTES = 16 * 1024 * 1024;
42
42
  const MAX_TRUSTED_ROOT_BYTES = 8 * 1024 * 1024;
43
43
  /** The kernel's `SignerKind.OidcCertificate`: the one signer kind this verifier runs (cosign keyless). */
44
44
  const OIDC_CERTIFICATE_SIGNER = 'oidc-certificate';
45
- const COSIGN_ENV = Object.freeze({
45
+ export const COSIGN_ENV = Object.freeze({
46
46
  LANG: 'C',
47
47
  LC_ALL: 'C',
48
48
  PATH: '/usr/bin:/bin',
@@ -632,7 +632,7 @@ function assertEvidenceChannel(channel) {
632
632
  * selects which policy rows are tried; what authenticates a channel is the
633
633
  * certificate matching a row of that channel.
634
634
  */
635
- function policyCandidates(policy, predicateType, channel, imageRepository) {
635
+ export function policyCandidates(policy, predicateType, channel, imageRepository) {
636
636
  const candidates = policy.policies.filter(
637
637
  (candidate) =>
638
638
  candidate.channel === channel &&
package/edition/lib.mjs CHANGED
@@ -20,6 +20,12 @@ import { readBiomeAuthorizationModuleAt } from './authorization-base.mjs';
20
20
  import {
21
21
  isAuthoritativeProductionBiomeSource,
22
22
  } from './connector-source-authority.mjs';
23
+ import {
24
+ compareDispatchedEdges,
25
+ dispatchedEdgeIdentity,
26
+ isSupplementalEdge,
27
+ projectDispatchedTarget,
28
+ } from './dispatched-runtime-components.mjs';
23
29
  import { assertKernelEditionProtocol } from './edition-protocol.mjs';
24
30
  import {
25
31
  kernelDistPath,
@@ -734,6 +740,8 @@ export async function loadKernelDistributionSchemas() {
734
740
  parseImageProducerStatement,
735
741
  parseDistributionImagesStatement,
736
742
  parseImageDeliveryEntitlementStatement,
743
+ parseEditionCatalogueStatement,
744
+ editionCatalogueRefusal,
737
745
  parseDistributionImageEvidenceSet,
738
746
  parseDistributionImageReplayState,
739
747
  parseSigstoreOfflineTrustPolicy,
@@ -751,6 +759,7 @@ export async function loadKernelDistributionSchemas() {
751
759
  IMAGE_PRODUCER_PREDICATE_TYPE,
752
760
  DISTRIBUTION_IMAGES_PREDICATE_TYPE,
753
761
  IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
762
+ EDITION_CATALOGUE_PREDICATE_TYPE,
754
763
  EVIDENCE_CHANNELS,
755
764
  EVIDENCE_CHANNEL_REFS,
756
765
  resolveFirstPartyCapabilityDomains,
@@ -927,6 +936,8 @@ export async function loadKernelDistributionSchemas() {
927
936
  parseImageProducerStatement,
928
937
  parseDistributionImagesStatement,
929
938
  parseImageDeliveryEntitlementStatement,
939
+ parseEditionCatalogueStatement,
940
+ editionCatalogueRefusal,
930
941
  parseDistributionImageEvidenceSet,
931
942
  parseDistributionImageReplayState,
932
943
  parseSigstoreOfflineTrustPolicy,
@@ -943,6 +954,8 @@ export async function loadKernelDistributionSchemas() {
943
954
  IMAGE_PRODUCER_PREDICATE_TYPE,
944
955
  DISTRIBUTION_IMAGES_PREDICATE_TYPE,
945
956
  IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
957
+ // Optional: a kernel without the E4 predicate is a catalogue consumer that cannot be armed (trust-policy-arming.mjs).
958
+ EDITION_CATALOGUE_PREDICATE_TYPE,
946
959
  EVIDENCE_CHANNELS,
947
960
  EVIDENCE_CHANNEL_REFS,
948
961
  resolveFirstPartyCapabilityDomains,
@@ -2030,16 +2043,10 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
2030
2043
  ...new Map(
2031
2044
  parsedIndex.biome.edges
2032
2045
  .filter((edge) => edge.kind === kernelBiome.BiomeGeneratedEdgeKind.RuntimeImage)
2033
- .map((edge) => [
2034
- `${edge.target.biomeId}\0${edge.target.componentKey}`,
2035
- { biomeId: edge.target.biomeId, componentKey: edge.target.componentKey },
2036
- ]),
2046
+ .map((edge) => projectDispatchedTarget(id, edge.target))
2047
+ .map((target) => [dispatchedEdgeIdentity(target), target]),
2037
2048
  ).values(),
2038
- ].sort(
2039
- (a, b) =>
2040
- compareStrings(a.biomeId, b.biomeId) ||
2041
- compareStrings(a.componentKey, b.componentKey),
2042
- );
2049
+ ].sort(compareDispatchedEdges);
2043
2050
 
2044
2051
  // Optional server biomes a web biome can integrate with but does not require
2045
2052
  // (never cause an ORPHANED resolution). Carried separately from `requires`.
@@ -2788,10 +2795,12 @@ export function validateImmutableBiomeIndex(index) {
2788
2795
  }
2789
2796
  const dispatched = new Set();
2790
2797
  for (const edge of biome.dispatchesRuntimeComponents ?? []) {
2791
- const edgeKey = `${edge.biomeId}\0${edge.componentKey}`;
2798
+ const edgeKey = dispatchedEdgeIdentity(projectDispatchedTarget(biome.id, edge));
2792
2799
  if (dispatched.has(edgeKey)) {
2793
2800
  throw new Error(
2794
- `biome "${biome.id}" dispatches runtime component "${edge.componentKey}" of biome "${edge.biomeId}" more than once`,
2801
+ isSupplementalEdge(edge)
2802
+ ? `biome "${biome.id}" dispatches ${edgeKey} more than once`
2803
+ : `biome "${biome.id}" dispatches runtime component "${edge.componentKey}" of biome "${edge.biomeId}" more than once`,
2795
2804
  );
2796
2805
  }
2797
2806
  dispatched.add(edgeKey);
@@ -22,6 +22,9 @@ import {
22
22
 
23
23
  export const IMAGE_SOURCE_EVIDENCE_ARTIFACT_TYPE =
24
24
  'application/vnd.xema.image-source-evidence.v2';
25
+ /** The E4 statement an edition artifact carries as an `oras attach` referrer (the catalogue's signed evidence). */
26
+ export const EDITION_CATALOGUE_EVIDENCE_ARTIFACT_TYPE =
27
+ 'application/vnd.xema.edition-catalogue-evidence.v1';
25
28
  export const OCI_IMAGE_MANIFEST_MEDIA_TYPE =
26
29
  'application/vnd.oci.image.manifest.v1+json';
27
30
  const OCI_TITLE_ANNOTATION = 'org.opencontainers.image.title';
@@ -8,16 +8,16 @@
8
8
  import { execFile } from 'node:child_process';
9
9
 
10
10
  /**
11
- * Run `file` with `args`; resolve with stdout bytes. A failure rejects with the
11
+ * Run `file` with `args` (in `cwd` when given); resolve with stdout bytes. A failure rejects with the
12
12
  * child's stderr text on `error.stderr` (a string), so the caller reads the
13
13
  * registry status from what oras itself wrote.
14
14
  */
15
- export function runExecutable(file, args, { input, env, timeoutMs, maxBufferBytes }) {
15
+ export function runExecutable(file, args, { input, env, cwd, timeoutMs, maxBufferBytes }) {
16
16
  return new Promise((resolve, reject) => {
17
17
  const child = execFile(
18
18
  file,
19
19
  args,
20
- { env, timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
20
+ { env, ...(cwd === undefined ? {} : { cwd }), timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
21
21
  (error, stdout, stderr) => {
22
22
  if (error) {
23
23
  error.stderr = stderr.toString('utf8');