@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.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +289 -19
- package/dist/edition/verify-image-evidence.bundle.provenance.json +71 -47
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +5 -0
- package/edition/catalogue-evidence.mjs +225 -0
- package/edition/catalogue-fetch.mjs +40 -7
- package/edition/customer-edition-verbs.mjs +477 -0
- package/edition/customer-flags.mjs +2 -0
- package/edition/develop-coverage-readers.mjs +212 -0
- package/edition/develop-coverage-run.mjs +48 -0
- package/edition/develop-coverage-verdict.mjs +153 -0
- package/edition/develop-coverage.mjs +120 -0
- package/edition/develop-edition-resolve.mjs +18 -7
- package/edition/develop-edition.mjs +47 -220
- package/edition/develop-freeze.mjs +50 -13
- package/edition/develop-image-probe.mjs +7 -7
- package/edition/dispatched-runtime-components.mjs +90 -1
- package/edition/edition-artifact.mjs +31 -14
- package/edition/edition-constants.mjs +9 -0
- package/edition/edition-contract.json +2 -1
- package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
- package/edition/edition-workspace.mjs +4 -3
- package/edition/evidence-trust-pin.mjs +28 -8
- package/edition/evidence-trust-policy.mjs +51 -7
- package/edition/image-evidence.mjs +2 -2
- package/edition/lib.mjs +20 -11
- package/edition/oci-image-source-evidence.mjs +3 -0
- package/edition/oras-process.mjs +3 -3
- package/edition/oras.mjs +47 -5
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-source-evidence.mjs +11 -2
- package/edition/release-edition.mjs +54 -0
- package/edition/repin-evidence-trust.mjs +5 -3
- package/edition/resolve-images.mjs +40 -5
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/trust-policy-arming.mjs +14 -1
- package/edition/verify-catalogue-evidence.mjs +245 -0
- package/edition/verify-image-evidence.mjs +15 -1
- package/package.json +2 -2
- 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 (
|
|
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:
|
|
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
|
|
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 "${
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
242
|
-
* the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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`
|
|
333
|
-
* as its catalogue provenance
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
48
|
+
* @returns the subjects with the channel's refs and verified digests
|
|
31
49
|
*/
|
|
32
|
-
export async function
|
|
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
|
|
60
|
+
const channelSubjects = subjects.map((subject) => ({
|
|
42
61
|
...subject,
|
|
43
|
-
imageRef:
|
|
62
|
+
imageRef: channelImageRef(channel, subject.imageRef),
|
|
44
63
|
}));
|
|
45
64
|
const report = await verifyDistributionPlatforms({
|
|
46
|
-
imageLock: { distributionId, subjects:
|
|
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:
|
|
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
|
|
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
|
|
92
|
+
* @returns the images lock with the channel's refs and verified digests, kernel-validated
|
|
74
93
|
*/
|
|
75
|
-
export async function
|
|
76
|
-
const subjects = await
|
|
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(
|
|
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:
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
*
|
|
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) {
|
|
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
|
-
|
|
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 =
|
|
2798
|
+
const edgeKey = dispatchedEdgeIdentity(projectDispatchedTarget(biome.id, edge));
|
|
2792
2799
|
if (dispatched.has(edgeKey)) {
|
|
2793
2800
|
throw new Error(
|
|
2794
|
-
|
|
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';
|
package/edition/oras-process.mjs
CHANGED
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Run `file` with `args
|
|
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');
|