@xemahq/xema 0.40.3 → 0.41.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 (44) 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 +281 -9
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +65 -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/edition-artifact.mjs +31 -14
  24. package/edition/edition-constants.mjs +9 -0
  25. package/edition/edition-contract.json +2 -1
  26. package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
  27. package/edition/edition-workspace.mjs +4 -3
  28. package/edition/evidence-trust-pin.mjs +28 -8
  29. package/edition/evidence-trust-policy.mjs +51 -7
  30. package/edition/image-evidence.mjs +2 -2
  31. package/edition/lib.mjs +7 -0
  32. package/edition/oci-image-source-evidence.mjs +3 -0
  33. package/edition/oras-process.mjs +3 -3
  34. package/edition/oras.mjs +47 -5
  35. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  36. package/edition/produce-image-source-evidence.mjs +11 -2
  37. package/edition/release-edition.mjs +54 -0
  38. package/edition/repin-evidence-trust.mjs +5 -3
  39. package/edition/sigstore-bundle-media-type.mjs +4 -0
  40. package/edition/trust-policy-arming.mjs +14 -1
  41. package/edition/verify-catalogue-evidence.mjs +245 -0
  42. package/edition/verify-image-evidence.mjs +15 -1
  43. package/package.json +2 -2
  44. package/edition/develop-customer-edition.mjs +0 -149
@@ -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
@@ -734,6 +734,8 @@ export async function loadKernelDistributionSchemas() {
734
734
  parseImageProducerStatement,
735
735
  parseDistributionImagesStatement,
736
736
  parseImageDeliveryEntitlementStatement,
737
+ parseEditionCatalogueStatement,
738
+ editionCatalogueRefusal,
737
739
  parseDistributionImageEvidenceSet,
738
740
  parseDistributionImageReplayState,
739
741
  parseSigstoreOfflineTrustPolicy,
@@ -751,6 +753,7 @@ export async function loadKernelDistributionSchemas() {
751
753
  IMAGE_PRODUCER_PREDICATE_TYPE,
752
754
  DISTRIBUTION_IMAGES_PREDICATE_TYPE,
753
755
  IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
756
+ EDITION_CATALOGUE_PREDICATE_TYPE,
754
757
  EVIDENCE_CHANNELS,
755
758
  EVIDENCE_CHANNEL_REFS,
756
759
  resolveFirstPartyCapabilityDomains,
@@ -927,6 +930,8 @@ export async function loadKernelDistributionSchemas() {
927
930
  parseImageProducerStatement,
928
931
  parseDistributionImagesStatement,
929
932
  parseImageDeliveryEntitlementStatement,
933
+ parseEditionCatalogueStatement,
934
+ editionCatalogueRefusal,
930
935
  parseDistributionImageEvidenceSet,
931
936
  parseDistributionImageReplayState,
932
937
  parseSigstoreOfflineTrustPolicy,
@@ -943,6 +948,8 @@ export async function loadKernelDistributionSchemas() {
943
948
  IMAGE_PRODUCER_PREDICATE_TYPE,
944
949
  DISTRIBUTION_IMAGES_PREDICATE_TYPE,
945
950
  IMAGE_DELIVERY_ENTITLEMENT_PREDICATE_TYPE,
951
+ // Optional: a kernel without the E4 predicate is a catalogue consumer that cannot be armed (trust-policy-arming.mjs).
952
+ EDITION_CATALOGUE_PREDICATE_TYPE,
946
953
  EVIDENCE_CHANNELS,
947
954
  EVIDENCE_CHANNEL_REFS,
948
955
  resolveFirstPartyCapabilityDomains,
@@ -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');
package/edition/oras.mjs CHANGED
@@ -255,7 +255,7 @@ export function prepareTrustedOras({ env = process.env, trustedDigests, notice =
255
255
  /** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
256
256
  function defaultRunner(trust = configuredOrasTrust) {
257
257
  let prepared;
258
- return async (args, { stdin } = {}) => {
258
+ return async (args, { stdin, cwd } = {}) => {
259
259
  if (prepared === undefined) {
260
260
  prepared = prepareTrustedOras({ trustedDigests: trust() });
261
261
  process.once('exit', prepared.dispose);
@@ -263,6 +263,7 @@ function defaultRunner(trust = configuredOrasTrust) {
263
263
  try {
264
264
  return await runExecutable(prepared.path(), args, {
265
265
  input: stdin,
266
+ ...(cwd === undefined ? {} : { cwd }),
266
267
  env: orasEnvironment(prepared.home),
267
268
  timeoutMs: ORAS_RUN_TIMEOUT_MS,
268
269
  maxBufferBytes: ORAS_MAX_BUFFER_BYTES,
@@ -364,7 +365,7 @@ export function disposeRegistrySessions() {
364
365
  }
365
366
 
366
367
  /**
367
- * A read-only OCI registry client bound to ONE registry authority and ONE
368
+ * An OCI registry client (reads, and `attach`) bound to ONE registry authority and ONE
368
369
  * resolved credential, doing every operation with the pinned oras.
369
370
  */
370
371
  export class OrasRegistry {
@@ -456,7 +457,7 @@ export class OrasRegistry {
456
457
  }
457
458
 
458
459
  /** One oras invocation under this registry's shared empty config; returns stdout bytes. */
459
- async #oras(subcommand, flags, ref) {
460
+ async #oras(subcommand, flags, ref, { cwd, operands = [] } = {}) {
460
461
  await this.#ensureRealmIsSameOrigin();
461
462
  const { configPath } = registrySession(this.authority);
462
463
  const args = [...subcommand, '--registry-config', configPath];
@@ -465,9 +466,9 @@ export class OrasRegistry {
465
466
  args.push('--username', this.#username, '--password-stdin');
466
467
  stdin = this.#token;
467
468
  }
468
- args.push(...flags, ref);
469
+ args.push(...flags, ref, ...operands);
469
470
  try {
470
- const out = await this.#run(args, { stdin });
471
+ const out = await this.#run(args, { stdin, ...(cwd === undefined ? {} : { cwd }) });
471
472
  return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
472
473
  } catch (error) {
473
474
  throw this.#failure(ref, error);
@@ -559,6 +560,47 @@ export class OrasRegistry {
559
560
  return descriptor;
560
561
  }
561
562
 
563
+ /**
564
+ * ATTACH `files` (paths relative to `cwd`, each with its media type) as an artifact of `artifactType` that is a referrer
565
+ * of `subjectDigest`, under the same referrers form discover reads ({@link REFERRERS_DISTRIBUTION_SPEC}). Returns the
566
+ * digest of the referrer manifest oras reports. The only write this client does; the caller states every byte.
567
+ *
568
+ * @param {string} repository
569
+ * @param {string} subjectDigest
570
+ * @param {{ artifactType: string, files: {path: string, mediaType: string}[], annotations?: Record<string,string>, cwd: string }} attachment
571
+ */
572
+ async attach(repository, subjectDigest, { artifactType, files, annotations = {}, cwd }) {
573
+ if (!SHA256_DIGEST_PATTERN.test(subjectDigest)) {
574
+ throw new Error(`"${subjectDigest}" is not a canonical lowercase sha256 digest`);
575
+ }
576
+ if (typeof artifactType !== 'string' || artifactType.length === 0) {
577
+ throw new Error('an attach requires a non-empty artifact type');
578
+ }
579
+ if (!Array.isArray(files) || files.length === 0 || typeof cwd !== 'string' || !isAbsolute(cwd)) {
580
+ throw new Error('an attach requires at least one file and an absolute working directory');
581
+ }
582
+ const ref = `${this.#ref(repository)}@${subjectDigest}`;
583
+ const flags = ['--distribution-spec', REFERRERS_DISTRIBUTION_SPEC, '--artifact-type', artifactType];
584
+ for (const [key, value] of Object.entries(annotations)) {
585
+ flags.push('--annotation', `${key}=${value}`);
586
+ }
587
+ flags.push('--format', 'json');
588
+ const out = await this.#oras(['attach'], flags, ref, {
589
+ cwd,
590
+ operands: files.map((file) => `${file.path}:${file.mediaType}`),
591
+ });
592
+ let document;
593
+ try {
594
+ document = JSON.parse(out.toString('utf8'));
595
+ } catch (error) {
596
+ throw new Error(`${ref} attach did not report JSON: ${error.message}`);
597
+ }
598
+ if (!SHA256_DIGEST_PATTERN.test(document?.digest)) {
599
+ throw new Error(`${ref} attach reported no canonical referrer digest`);
600
+ }
601
+ return document.digest;
602
+ }
603
+
562
604
  /** Every tag of a repository; oras follows the registry's pagination. A failure throws, never an empty list. */
563
605
  async listTags(repository) {
564
606
  const ref = this.#ref(repository);