@xemahq/xema 0.53.0 → 0.54.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.
- package/README.md +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +66 -0
- package/dist/commands/auth.js.map +1 -0
- 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 +916 -860
- package/dist/edition/verify-image-evidence.bundle.provenance.json +495 -489
- package/dist/runtime/edition/verb-map.d.ts +2 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +54 -2
- package/edition/adapter-bundled-versions.mjs +2 -1
- package/edition/authorization-base.mjs +9 -8
- package/edition/biome-content.mjs +23 -29
- package/edition/candidate-edition.mjs +692 -0
- package/edition/checkout-indexed-source-repositories.mjs +6 -8
- package/edition/collected-refusals.mjs +59 -0
- package/edition/contract-authority.mjs +122 -0
- package/edition/cosign-certificate-constraints.mjs +18 -8
- package/edition/customer-edition-verbs.mjs +30 -8
- package/edition/develop-chart-set.mjs +7 -6
- package/edition/develop-edition-invariants.mjs +2 -1
- package/edition/develop-edition-resolve.mjs +109 -31
- package/edition/develop-edition.mjs +49 -8
- package/edition/develop-engine-image.mjs +95 -0
- package/edition/develop-image-probe.mjs +2 -1
- package/edition/develop-resolve-report.mjs +159 -0
- package/edition/develop-snapshot-inputs.mjs +4 -3
- package/edition/develop-verify-image-record.mjs +23 -4
- package/edition/edition-artifact.mjs +17 -7
- package/edition/edition-protocol.mjs +5 -2
- package/edition/images-from-record.mjs +362 -0
- package/edition/lib.mjs +1 -19
- package/edition/locked-bundle-signer.mjs +15 -3
- package/edition/operation-grant-admission.mjs +51 -9
- package/edition/resolve-distribution.mjs +95 -82
- package/edition/resolve-images.mjs +12 -5
- package/edition/shallow-checkout.mjs +18 -0
- package/edition/workspace-sections.mjs +23 -3
- package/package.json +7 -3
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// images-from-record — a develop resolve's image digests taken from a BUILD RECORD, with no registry probe and no rehash.
|
|
2
|
+
//
|
|
3
|
+
// The everyday job builds (or reuses by content-hash tag) the edition's images in the aggregator workspace and hands
|
|
4
|
+
// `edition develop resolve --images-from <file>` the record of what it built. Each entry names one image subject of
|
|
5
|
+
// the edition by the identity images.lock.json and the seal key it by (`<kind>:<id>`), and carries
|
|
6
|
+
// `{repository, contentHash, digest, platforms, sourceSha}`:
|
|
7
|
+
//
|
|
8
|
+
// images what the job built or reused itself. Trusted as the job's own statement: the job signs the edition.
|
|
9
|
+
// satellites a satellite producer's develop digest (a first-party source that is not built in the workspace, such
|
|
10
|
+
// as submodules/xema-host-web). Admitted only after a cosign verify that the producer's own develop
|
|
11
|
+
// ci.yaml build record, signed by the data root's record workflow called by that producer
|
|
12
|
+
// (develop-record-verify.mjs), lists exactly this repository, digest and content hash at sourceSha.
|
|
13
|
+
// The producer is the subject's declared `firstPartySources[repo].repositoryUrl`, never the record's.
|
|
14
|
+
//
|
|
15
|
+
// Which list an entry sits in is not the record's choice: each first-party source declares its `imageOrigin` once in
|
|
16
|
+
// `firstPartySources` (FirstPartyImageOrigin: `workspace` -> `images`, `satellite` -> `satellites`). An entry in the
|
|
17
|
+
// other list, or for a source that declares no origin, is refused, so no satellite is trusted without its verify.
|
|
18
|
+
//
|
|
19
|
+
// Coverage is exact: every first-party subject of the edition comes from exactly one entry, and every entry is a
|
|
20
|
+
// subject of the edition. A missing, duplicated or extra subject, a sourceSha other than the commit the snapshot pins
|
|
21
|
+
// for the subject's repository, a repository other than the subject's develop repository, or a platform set without
|
|
22
|
+
// every required platform refuses the whole resolve, every refusal named at once. There is no fallback to the probe.
|
|
23
|
+
// contentHash and sourceSha are carried into the images lock where the probe path writes them, never recomputed.
|
|
24
|
+
// A third-party subject (a pinned OCI biome's image) is not built by the job: it keeps its pinned seal, and a record
|
|
25
|
+
// entry naming one is an extra subject.
|
|
26
|
+
//
|
|
27
|
+
// The record schema is CLI-owned and versioned here, behind this one module. It is a generic image-evidence concept
|
|
28
|
+
// that belongs in @xemahq/contracts (beside BuildRecordSchema); moving it there changes only `parseImageBuildRecord`.
|
|
29
|
+
|
|
30
|
+
import { canonicalJson } from './canonical-evidence-json.mjs';
|
|
31
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
32
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
33
|
+
import { developImageRef, isDevelopImageRef } from './develop-image-coordinate.mjs';
|
|
34
|
+
import { withRecordVerifier } from './develop-record-verify.mjs';
|
|
35
|
+
import { findVerifiedProducerImageRecord } from './develop-verify-image-record.mjs';
|
|
36
|
+
import { EditionChannel } from './edition-channel.mjs';
|
|
37
|
+
import { sealImageSubjects } from './edition-image-seal.mjs';
|
|
38
|
+
import { firstPartySources } from './first-party-source-ids.mjs';
|
|
39
|
+
import { compareStrings, loadKernelDistributionSchemas, sha256Hex, validateImmutableImageLock } from './lib.mjs';
|
|
40
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
41
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
42
|
+
import { FirstPartyImageOrigin } from './workspace-sections.mjs';
|
|
43
|
+
|
|
44
|
+
/** The one schema version of the image build record this CLI reads. */
|
|
45
|
+
export const IMAGE_BUILD_RECORD_SCHEMA_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
/** Which list of the record an entry came from: how far it is trusted. */
|
|
48
|
+
export const RecordedImageOrigin = Object.freeze({
|
|
49
|
+
Built: 'images',
|
|
50
|
+
Satellite: 'satellites',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The one record list each declared image origin admits. */
|
|
54
|
+
const LIST_OF_ORIGIN = Object.freeze({
|
|
55
|
+
[FirstPartyImageOrigin.Workspace]: RecordedImageOrigin.Built,
|
|
56
|
+
[FirstPartyImageOrigin.Satellite]: RecordedImageOrigin.Satellite,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const RECORD_KEYS = Object.freeze(['schemaVersion', ...Object.values(RecordedImageOrigin)]);
|
|
60
|
+
const ENTRY_KEYS = Object.freeze(['subject', 'repository', 'contentHash', 'digest', 'platforms', 'sourceSha']);
|
|
61
|
+
const PLATFORM_REQUIRED_KEYS = Object.freeze(['os', 'architecture']);
|
|
62
|
+
const PLATFORM_KEYS = Object.freeze([...PLATFORM_REQUIRED_KEYS, 'variant']);
|
|
63
|
+
|
|
64
|
+
const SUBJECT = /^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$/;
|
|
65
|
+
const REPOSITORY = /^[a-z0-9.-]+(?::[1-9]\d{0,4})?\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+$/;
|
|
66
|
+
const CONTENT_HASH = /^[0-9a-f]{64}$/;
|
|
67
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
68
|
+
const SOURCE_SHA = /^[0-9a-f]{40}$/;
|
|
69
|
+
|
|
70
|
+
/** A build record that is malformed, or does not cover the edition exactly. */
|
|
71
|
+
export class ImageBuildRecordError extends RefusalError {
|
|
72
|
+
constructor(message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = 'ImageBuildRecordError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The subject key the image lock, the probe and the seal use. */
|
|
79
|
+
export const subjectKeyOf = (subject) => `${subject.kind}:${subject.id}`;
|
|
80
|
+
|
|
81
|
+
const platformKey = (platform) => [platform.os, platform.architecture, platform.variant ?? ''].join('/');
|
|
82
|
+
|
|
83
|
+
function isPlainObject(value) {
|
|
84
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function refuseUnknownKeys(value, allowed, where, problems) {
|
|
88
|
+
for (const key of Object.keys(value)) {
|
|
89
|
+
if (!allowed.includes(key)) {problems.push(`${where}: unknown key ${JSON.stringify(key)}`);}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkText(value, pattern, where, problems) {
|
|
94
|
+
if (typeof value !== 'string' || !pattern.test(value)) {problems.push(`${where} must match ${pattern}; got ${JSON.stringify(value)}`);}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkPlatforms(platforms, where, problems) {
|
|
98
|
+
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
99
|
+
problems.push(`${where} must be a non-empty array of {os, architecture[, variant]}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
platforms.forEach((platform, index) => {
|
|
104
|
+
const at = `${where}[${index}]`;
|
|
105
|
+
if (!isPlainObject(platform)) {
|
|
106
|
+
problems.push(`${at} must be an object`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
refuseUnknownKeys(platform, PLATFORM_KEYS, at, problems);
|
|
110
|
+
for (const key of Object.keys(platform).filter((name) => PLATFORM_KEYS.includes(name))) {
|
|
111
|
+
if (typeof platform[key] !== 'string' || platform[key].length === 0) {problems.push(`${at}.${key} must be a non-empty string`);}
|
|
112
|
+
}
|
|
113
|
+
for (const key of PLATFORM_REQUIRED_KEYS) {
|
|
114
|
+
if (!Object.hasOwn(platform, key)) {problems.push(`${at} has no ${key}`);}
|
|
115
|
+
}
|
|
116
|
+
const key = platformKey(platform);
|
|
117
|
+
if (seen.has(key)) {problems.push(`${at} repeats platform ${key}`);}
|
|
118
|
+
seen.add(key);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function checkEntry(entry, where, problems) {
|
|
123
|
+
if (!isPlainObject(entry)) {
|
|
124
|
+
problems.push(`${where} must be an object`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
refuseUnknownKeys(entry, ENTRY_KEYS, where, problems);
|
|
128
|
+
for (const key of ENTRY_KEYS) {
|
|
129
|
+
if (!Object.hasOwn(entry, key)) {problems.push(`${where} has no ${key}`);}
|
|
130
|
+
}
|
|
131
|
+
checkText(entry.subject, SUBJECT, `${where}.subject`, problems);
|
|
132
|
+
checkText(entry.repository, REPOSITORY, `${where}.repository`, problems);
|
|
133
|
+
if (typeof entry.repository === 'string' && REPOSITORY.test(entry.repository) && !isDevelopImageRef(entry.repository)) {
|
|
134
|
+
problems.push(`${where}.repository ${entry.repository} is not a develop repository`);
|
|
135
|
+
}
|
|
136
|
+
checkText(entry.contentHash, CONTENT_HASH, `${where}.contentHash`, problems);
|
|
137
|
+
checkText(entry.digest, DIGEST, `${where}.digest`, problems);
|
|
138
|
+
checkText(entry.sourceSha, SOURCE_SHA, `${where}.sourceSha`, problems);
|
|
139
|
+
checkPlatforms(entry.platforms, `${where}.platforms`, problems);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse an image build record: `{schemaVersion: 1, images: [entry], satellites: [entry]}`, every list sorted by
|
|
144
|
+
* subject, no subject in two entries, and no unknown key anywhere. Every problem is named in one refusal.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} document
|
|
147
|
+
* @param {string} source where the record came from (named in the refusal)
|
|
148
|
+
* @returns {{schemaVersion: 1, images: object[], satellites: object[]}}
|
|
149
|
+
*/
|
|
150
|
+
export function parseImageBuildRecord(document, source) {
|
|
151
|
+
const problems = [];
|
|
152
|
+
if (!isPlainObject(document)) {throw new ImageBuildRecordError(`image build record ${source} must be a JSON object`);}
|
|
153
|
+
refuseUnknownKeys(document, RECORD_KEYS, 'record', problems);
|
|
154
|
+
if (document.schemaVersion !== IMAGE_BUILD_RECORD_SCHEMA_VERSION) {
|
|
155
|
+
problems.push(`record.schemaVersion must be ${IMAGE_BUILD_RECORD_SCHEMA_VERSION}; got ${JSON.stringify(document.schemaVersion)}`);
|
|
156
|
+
}
|
|
157
|
+
const owner = new Map();
|
|
158
|
+
for (const origin of Object.values(RecordedImageOrigin)) {
|
|
159
|
+
const entries = document[origin];
|
|
160
|
+
if (!Array.isArray(entries)) {
|
|
161
|
+
problems.push(`record.${origin} must be an array`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
entries.forEach((entry, index) => {
|
|
165
|
+
const where = `record.${origin}[${index}]`;
|
|
166
|
+
checkEntry(entry, where, problems);
|
|
167
|
+
const subject = entry?.subject;
|
|
168
|
+
if (typeof subject !== 'string') {return;}
|
|
169
|
+
const previous = entries[index - 1]?.subject;
|
|
170
|
+
if (typeof previous === 'string' && previous >= subject && previous !== subject) {
|
|
171
|
+
problems.push(`${where}: record.${origin} must be sorted by subject ("${subject}" follows "${previous}")`);
|
|
172
|
+
}
|
|
173
|
+
if (owner.has(subject)) {problems.push(`${where}: subject ${subject} is already recorded at ${owner.get(subject)}`);}
|
|
174
|
+
else {owner.set(subject, where);}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (problems.length > 0) {
|
|
178
|
+
throw new ImageBuildRecordError(`image build record ${source} is refused:\n ${problems.join('\n ')}`);
|
|
179
|
+
}
|
|
180
|
+
return document;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* sha256 (hex) of the parsed record's canonical JSON (canonical-evidence-json.mjs: keys sorted), every list in its
|
|
185
|
+
* canonical order: entries by subject (parse enforces it) and each entry's platforms by os/architecture/variant. It is
|
|
186
|
+
* the `imageBuildRecord` input of the develop edition's target tag (snapshotEditionTag).
|
|
187
|
+
*/
|
|
188
|
+
export function imageBuildRecordDigest(record) {
|
|
189
|
+
const canonicalEntry = (entry) => ({ ...entry, platforms: [...entry.platforms].sort((a, b) => compareStrings(platformKey(a), platformKey(b))) });
|
|
190
|
+
return sha256Hex(canonicalJson({
|
|
191
|
+
schemaVersion: record.schemaVersion,
|
|
192
|
+
...Object.fromEntries(Object.values(RecordedImageOrigin).map((origin) => [origin, record[origin].map(canonicalEntry)])),
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read and parse the record file `--images-from` names (duplicate JSON keys refused). */
|
|
197
|
+
export function readImageBuildRecord(path) {
|
|
198
|
+
return parseImageBuildRecord(readStrictJsonFile(path), path);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The record as `resolveImages` consumes it (`recordedImages`): `take` gives each first-party subject its recorded
|
|
203
|
+
* content hash and source commit, and `settle` refuses, all at once, every subject the record does not cover exactly.
|
|
204
|
+
*
|
|
205
|
+
* Each subject's entry must sit in the list its source's declared `imageOrigin` names (LIST_OF_ORIGIN); an entry in the
|
|
206
|
+
* other list, or a source with no declared origin, is one more problem `settle` names.
|
|
207
|
+
*
|
|
208
|
+
* @param {{images: object[], satellites: object[]}} record a parsed record
|
|
209
|
+
* @param {{distributionId: string, declaredSources?: object}} context `declaredSources` is `firstPartySources` (tests pass it)
|
|
210
|
+
*/
|
|
211
|
+
export function createRecordedImages(record, { distributionId, declaredSources = firstPartySources('checking each build record entry against its source\'s declared image origin') }) {
|
|
212
|
+
const entries = new Map();
|
|
213
|
+
for (const origin of Object.values(RecordedImageOrigin)) {
|
|
214
|
+
for (const entry of record[origin]) {entries.set(entry.subject, { origin, entry });}
|
|
215
|
+
}
|
|
216
|
+
const taken = new Map();
|
|
217
|
+
const problems = [];
|
|
218
|
+
let settled = false;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
take(subject) {
|
|
222
|
+
const key = subjectKeyOf(subject);
|
|
223
|
+
const recorded = entries.get(key);
|
|
224
|
+
if (recorded === undefined) {
|
|
225
|
+
problems.push(`${key} (${subject.repo}@${subject.sourceCommit}) is not in the record`);
|
|
226
|
+
return { sourceCommit: subject.sourceCommit, contentHash: null };
|
|
227
|
+
}
|
|
228
|
+
if (taken.has(key)) {throw new Error(`images-from-record: subject ${key} was taken twice`);}
|
|
229
|
+
const { entry, origin } = recorded;
|
|
230
|
+
const listProblem = recordListProblem(key, subject, origin, declaredSources);
|
|
231
|
+
if (listProblem !== null) {problems.push(listProblem);}
|
|
232
|
+
if (entry.sourceSha !== subject.sourceCommit) {
|
|
233
|
+
problems.push(`${key}: the record's sourceSha ${entry.sourceSha} is not ${subject.sourceCommit}, the commit the snapshot pins for ${subject.repo}`);
|
|
234
|
+
}
|
|
235
|
+
const repository = developImageRef(subject.imageRef);
|
|
236
|
+
if (entry.repository !== repository) {
|
|
237
|
+
problems.push(`${key}: the record's repository ${entry.repository} is not its develop repository ${repository}`);
|
|
238
|
+
}
|
|
239
|
+
const recordedPlatforms = new Set(entry.platforms.map(platformKey));
|
|
240
|
+
const absent = subject.requiredPlatforms.map(platformKey).filter((platform) => !recordedPlatforms.has(platform));
|
|
241
|
+
if (absent.length > 0) {problems.push(`${key}: the record's platforms lack the required ${absent.join(', ')}`);}
|
|
242
|
+
taken.set(key, { subject, ...recorded });
|
|
243
|
+
return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash };
|
|
244
|
+
},
|
|
245
|
+
settle() {
|
|
246
|
+
settled = true;
|
|
247
|
+
const extra = [...entries.keys()].filter((key) => !taken.has(key)).map((key) => `${key} (record.${entries.get(key).origin}) is not a first-party image subject of "${distributionId}"`);
|
|
248
|
+
const all = [...problems, ...extra];
|
|
249
|
+
if (all.length > 0) {
|
|
250
|
+
throw new ImageBuildRecordError(`the image build record does not cover "${distributionId}" exactly (${all.length} problem(s); no probe fallback):\n ${all.join('\n ')}`);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
/** The satellite entries taken, each with the subject it covers, for the cosign verify. */
|
|
254
|
+
satelliteClaims() {
|
|
255
|
+
if (!settled) {throw new Error('images-from-record: satellite claims are read only after settle()');}
|
|
256
|
+
return [...taken.values()].filter(({ origin }) => origin === RecordedImageOrigin.Satellite).map(({ subject, entry }) => ({ subject, entry }));
|
|
257
|
+
},
|
|
258
|
+
/** The recorded entry of a subject, or undefined for a subject the record does not cover (a third-party one). */
|
|
259
|
+
entryOf(subject) {
|
|
260
|
+
return taken.get(subjectKeyOf(subject))?.entry;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Why the record list `origin` may not carry the subject's entry, or null when its source's declared origin names it. */
|
|
266
|
+
function recordListProblem(key, subject, origin, declaredSources) {
|
|
267
|
+
if (!Object.hasOwn(declaredSources, subject.repo)) {
|
|
268
|
+
return `${key} (record.${origin}): ${subject.repo} is not a declared first-party source`;
|
|
269
|
+
}
|
|
270
|
+
const declared = declaredSources[subject.repo].imageOrigin;
|
|
271
|
+
if (declared === undefined) {
|
|
272
|
+
return `${key} (record.${origin}): firstPartySources["${subject.repo}"] declares no imageOrigin (one of ${Object.values(FirstPartyImageOrigin).join(', ')}); --images-from admits no entry of an unconfigured source`;
|
|
273
|
+
}
|
|
274
|
+
const wanted = LIST_OF_ORIGIN[declared];
|
|
275
|
+
if (origin !== wanted) {
|
|
276
|
+
return `${key} is in record.${origin}, but firstPartySources["${subject.repo}"].imageOrigin is "${declared}": its entry belongs in record.${wanted}`;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The `owner/name` a satellite subject's develop identity is bound to: its repository's declared URL. */
|
|
282
|
+
export function satelliteProducerOf(subject) {
|
|
283
|
+
const sources = firstPartySources('naming a satellite producer');
|
|
284
|
+
if (!Object.hasOwn(sources, subject.repo)) {
|
|
285
|
+
throw new ImageBuildRecordError(`${subjectKeyOf(subject)}: ${subject.repo} is not a declared first-party source; a satellite producer is never named by the record`);
|
|
286
|
+
}
|
|
287
|
+
const context = `firstPartySources["${subject.repo}"].repositoryUrl`;
|
|
288
|
+
return repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(sources[subject.repo].repositoryUrl, context), context);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The cosign verifier of satellite claims: for each, the producer's newest verified develop ci.yaml build record
|
|
293
|
+
* (develop-record-verify.mjs: keyless, offline against the pinned trusted root, issuer
|
|
294
|
+
* https://token.actions.githubusercontent.com, identity the data root's record workflow at refs/heads/develop, called by
|
|
295
|
+
* the producer) whose commit is the claim's sourceSha and which lists its repository, digest and content hash. Every
|
|
296
|
+
* refused claim is named at once; a registry or cosign that could not answer is a plain Error. The registry is read
|
|
297
|
+
* for the records and signatures only: no digest is discovered and nothing is rehashed.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} request
|
|
300
|
+
* @param {string} request.registry the registry root that holds `build-records/<repo>`
|
|
301
|
+
* @param {string} request.cosignBinary absolute path of cosign
|
|
302
|
+
* @param {Function} [request.withVerifier] the pinned record verifier scope (tests replace it, to run offline)
|
|
303
|
+
* @param {Function} [request.findRecord] the newest-first verified record walk (tests replace it)
|
|
304
|
+
* @returns {(claims: {subject: object, entry: object}[]) => Promise<void>}
|
|
305
|
+
*/
|
|
306
|
+
export function createSatelliteVerifier({ registry, cosignBinary, env = process.env, withVerifier = withRecordVerifier, findRecord = findVerifiedProducerImageRecord, ...verifierInjections }) {
|
|
307
|
+
return async (claims) => {
|
|
308
|
+
if (claims.length === 0) {return;}
|
|
309
|
+
await withVerifier({ cosignBinary, env, ...verifierInjections }, async (verifier) => {
|
|
310
|
+
const refused = [];
|
|
311
|
+
for (const { subject, entry } of claims) {
|
|
312
|
+
try {
|
|
313
|
+
await findRecord({
|
|
314
|
+
...verifier,
|
|
315
|
+
registry,
|
|
316
|
+
producer: satelliteProducerOf(subject),
|
|
317
|
+
wanted: `at ${entry.sourceSha} lists ${entry.repository}@${entry.digest} with content hash ${entry.contentHash}`,
|
|
318
|
+
matches: (record) =>
|
|
319
|
+
record.commit === entry.sourceSha &&
|
|
320
|
+
record.images.some((listed) => listed.name === entry.repository && listed.digest === entry.digest && listed.contentHash === entry.contentHash),
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (!(error instanceof RecordRefused)) {throw error;}
|
|
324
|
+
refused.push(`${subjectKeyOf(subject)}: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (refused.length > 0) {
|
|
328
|
+
throw new ImageBuildRecordError(`${refused.length} satellite image(s) of the build record are not verified:\n ${refused.join('\n ')}`);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The develop images lock of a snapshot from the record: the subjects as `resolveImages` structures them, each
|
|
336
|
+
* first-party one with the record's content hash and source commit, coverage settled, satellites verified.
|
|
337
|
+
*
|
|
338
|
+
* @returns {Promise<{imageLock: object, recorded: ReturnType<typeof createRecordedImages>}>}
|
|
339
|
+
*/
|
|
340
|
+
export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites }) {
|
|
341
|
+
const recorded = createRecordedImages(record, { distributionId: id });
|
|
342
|
+
const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded });
|
|
343
|
+
await verifySatellites(recorded.satelliteClaims());
|
|
344
|
+
return { imageLock, recorded };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Seal the lock from the record: each recorded subject takes the record's develop repository and digest, with no
|
|
349
|
+
* registry read. A third-party subject (the only kind the record does not cover) keeps its pinned seal.
|
|
350
|
+
*/
|
|
351
|
+
export async function sealImagesFromRecord({ imageLock, recorded, thirdPartyPins, ...sealOptions }) {
|
|
352
|
+
const unrecorded = imageLock.subjects.filter((subject) => recorded.entryOf(subject) === undefined);
|
|
353
|
+
const pinned = unrecorded.length === 0 ? [] : await sealImageSubjects({ channel: EditionChannel.Develop, distributionId: imageLock.distributionId, subjects: unrecorded, thirdPartyPins, ...sealOptions });
|
|
354
|
+
const pinnedByKey = new Map(pinned.map((subject) => [subjectKeyOf(subject), subject]));
|
|
355
|
+
const subjects = imageLock.subjects.map((subject) => {
|
|
356
|
+
const entry = recorded.entryOf(subject);
|
|
357
|
+
if (entry === undefined) {return pinnedByKey.get(subjectKeyOf(subject));}
|
|
358
|
+
return { ...subject, imageRef: entry.repository, imageDigest: entry.digest };
|
|
359
|
+
});
|
|
360
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
361
|
+
return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
|
|
362
|
+
}
|
package/edition/lib.mjs
CHANGED
|
@@ -1981,6 +1981,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
|
|
|
1981
1981
|
logicalName: requirement.logicalName,
|
|
1982
1982
|
scope: requirement.scope,
|
|
1983
1983
|
inclusion: requirement.inclusion,
|
|
1984
|
+
...(requirement.sharing === undefined ? {} : { sharing: requirement.sharing }),
|
|
1984
1985
|
});
|
|
1985
1986
|
}
|
|
1986
1987
|
for (const provision of provisions) {
|
|
@@ -2097,25 +2098,6 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
|
|
|
2097
2098
|
providesResources.sort(
|
|
2098
2099
|
(a, b) => compareStrings(a.service, b.service) || compareStrings(a.kind, b.kind),
|
|
2099
2100
|
);
|
|
2100
|
-
// The provider derives a resource's PHYSICAL identity from the owning biome
|
|
2101
|
-
// plus (kind, scope, logicalName). Two consumers of this biome naming the
|
|
2102
|
-
// same tuple would be handed ONE physical resource while each believes it
|
|
2103
|
-
// owns it — so the declaration is refused here, where the biome is the whole
|
|
2104
|
-
// subject, rather than discovered at the provider.
|
|
2105
|
-
const physicalResources = new Map();
|
|
2106
|
-
for (const row of serviceResources) {
|
|
2107
|
-
const identity = `${row.kind}\0${row.scope}\0${row.logicalName}`;
|
|
2108
|
-
const holder = physicalResources.get(identity);
|
|
2109
|
-
if (holder !== undefined && holder !== row.consumerService) {
|
|
2110
|
-
throw new Error(
|
|
2111
|
-
`${rel}: services "${holder}" and "${row.consumerService}" both declare ` +
|
|
2112
|
-
`${row.scope}-scoped ${row.kind} "${row.logicalName}" — biome "${id}" ` +
|
|
2113
|
-
`derives ONE physical resource from that, so two consumers would share ` +
|
|
2114
|
-
`it while each believes it owns it. Give each its own logicalName.`,
|
|
2115
|
-
);
|
|
2116
|
-
}
|
|
2117
|
-
physicalResources.set(identity, row.consumerService);
|
|
2118
|
-
}
|
|
2119
2101
|
// The two arrays partition the biome's images — a name in both would be
|
|
2120
2102
|
// simultaneously rostered and unschedulable.
|
|
2121
2103
|
for (const name of runtimeComponents) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
|
|
18
|
+
import { KernelRefusalName, RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
|
|
18
19
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
19
20
|
import { assertKernelEditionProtocol } from './edition-protocol.mjs';
|
|
20
21
|
import { dataRoot, kernelDistPath, kernelPackageDir } from './edition-workspace.mjs';
|
|
@@ -31,13 +32,16 @@ const PUBLIC_KEY_KIND = 'public-key';
|
|
|
31
32
|
const KERNEL_EXPORTS = Object.freeze(['lockedBundleSignerOf', 'assertLockedBundleSignerMatches', 'LockedBundleSignerUnknownPolicyError', 'LockedBundleSignerAmbiguousPolicyError']);
|
|
32
33
|
|
|
33
34
|
/** A named refusal of the bundle signer plane. */
|
|
34
|
-
class BundleSignerError extends
|
|
35
|
+
export class BundleSignerError extends RefusalError {
|
|
35
36
|
constructor(message) {
|
|
36
37
|
super(message);
|
|
37
38
|
this.name = new.target.name;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/** Whether `error` is a refusal of the bundle signer plane: this module's own, or one the kernel's signer projection throws. */
|
|
43
|
+
export const isBundleSignerRefusal = (error) => error instanceof BundleSignerError || (isRefusal(error) && Object.values(KernelRefusalName).includes(error.name));
|
|
44
|
+
|
|
41
45
|
/** No trust policy row can be selected for the subject: no pin, no `firstPartyBundleSigner`, or no policy at all. */
|
|
42
46
|
export class BundleSignerPolicyAbsentError extends BundleSignerError {}
|
|
43
47
|
/** The selected row is not of the kind its origin requires. */
|
|
@@ -54,6 +58,14 @@ export class BundleSourceOnGitRowError extends BundleSignerError {}
|
|
|
54
58
|
export class GitSourcedHostModuleBundleError extends BundleSignerError {}
|
|
55
59
|
/** The loaded kernel does not export what the bundle signer plane needs. */
|
|
56
60
|
export class BundleSignerKernelUnsupportedError extends BundleSignerError {}
|
|
61
|
+
/** Several OCI rows were refused in one pass; `.errors` holds each row's own refusal. */
|
|
62
|
+
export class BundleSignerRefusedError extends BundleSignerError {
|
|
63
|
+
/** @param {readonly Error[]} errors */
|
|
64
|
+
constructor(errors) {
|
|
65
|
+
super(`${errors.length} OCI bundle rows are refused:\n\n${joinRefusalMessages(errors)}`);
|
|
66
|
+
this.errors = errors;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
57
69
|
|
|
58
70
|
/** What the kernel must export, validated once: `module` is the loaded kernel `distribution` module. */
|
|
59
71
|
export function bundleSignerKernelFrom(module, location) {
|
|
@@ -234,8 +246,8 @@ export function assertLockBundleSigners({ lock, selection, schemas }) {
|
|
|
234
246
|
const subjects = lockBundleSubjects(lock);
|
|
235
247
|
if (subjects.length === 0) {return;}
|
|
236
248
|
const chosen = selection ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas });
|
|
237
|
-
|
|
249
|
+
collectRefusals(subjects, ({ row, subject }) => {
|
|
238
250
|
assertNotGitSourced(subject);
|
|
239
251
|
chosen.assertSealed(subject, row.bundleSigner);
|
|
240
|
-
}
|
|
252
|
+
}, isRefusal, BundleSignerRefusedError);
|
|
241
253
|
}
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
* and the edition's store-workload ceiling are admitted the same way.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
|
|
14
|
+
|
|
13
15
|
/** Thrown when one or more declared operation requirements are not admitted. */
|
|
14
|
-
export class OperationGrantAdmissionError extends
|
|
16
|
+
export class OperationGrantAdmissionError extends RefusalError {
|
|
15
17
|
/**
|
|
16
18
|
* @param {string} distributionId
|
|
17
19
|
* @param {ReadonlyArray<{code: string, operation: string, message: string}>} refusals
|
|
20
|
+
* @param {ReadonlyArray<{caller: object, operation: string, reason: string}>} [ungrantedOptional] what the kernel left ungranted beside the refusals
|
|
18
21
|
*/
|
|
19
|
-
constructor(distributionId, refusals) {
|
|
22
|
+
constructor(distributionId, refusals, ungrantedOptional = []) {
|
|
20
23
|
super(
|
|
21
24
|
`distribution "${distributionId}" declares operation requirements that are not admitted:\n` +
|
|
22
25
|
refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
|
|
@@ -24,6 +27,7 @@ export class OperationGrantAdmissionError extends Error {
|
|
|
24
27
|
this.name = 'OperationGrantAdmissionError';
|
|
25
28
|
this.distributionId = distributionId;
|
|
26
29
|
this.refusals = refusals;
|
|
30
|
+
this.ungrantedOptional = ungrantedOptional;
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -49,7 +53,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
|
|
|
49
53
|
// A platform service's refused requirement fails exactly like a biome component's, through the same error.
|
|
50
54
|
const allRefusals = [...refusals, ...platformServiceRefusals];
|
|
51
55
|
if (allRefusals.length > 0) {
|
|
52
|
-
throw new OperationGrantAdmissionError(lock.distributionId, allRefusals);
|
|
56
|
+
throw new OperationGrantAdmissionError(lock.distributionId, allRefusals, ungrantedOptional);
|
|
53
57
|
}
|
|
54
58
|
if (report !== undefined) {
|
|
55
59
|
for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
|
|
@@ -58,7 +62,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Thrown when one or more declared organization sweeps are not admitted. */
|
|
61
|
-
export class OrganizationSweepAdmissionError extends
|
|
65
|
+
export class OrganizationSweepAdmissionError extends RefusalError {
|
|
62
66
|
/**
|
|
63
67
|
* @param {string} distributionId
|
|
64
68
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, sweep: string, message: string}>} refusals
|
|
@@ -89,7 +93,7 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
|
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/** Thrown when one or more declared exact audiences are not admitted. */
|
|
92
|
-
export class ExactAudienceAdmissionError extends
|
|
96
|
+
export class ExactAudienceAdmissionError extends RefusalError {
|
|
93
97
|
/**
|
|
94
98
|
* @param {string} distributionId
|
|
95
99
|
* @param {ReadonlyArray<{code: string, message: string}>} refusals
|
|
@@ -120,7 +124,7 @@ export function assertExactAudiencesAdmitted(schemas, lock) {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** Thrown when one or more requested installed operations are not admitted into the ceiling. */
|
|
123
|
-
export class InstalledOperationCeilingAdmissionError extends
|
|
127
|
+
export class InstalledOperationCeilingAdmissionError extends RefusalError {
|
|
124
128
|
/**
|
|
125
129
|
* @param {string} distributionId
|
|
126
130
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, operation: string, message: string}>} refusals
|
|
@@ -151,7 +155,7 @@ export function assertInstalledOperationCeilingAdmitted(schemas, lock) {
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
/** Thrown when one or more domains of the edition's store-workload ceiling are not admitted. */
|
|
154
|
-
export class StoreWorkloadCeilingAdmissionError extends
|
|
158
|
+
export class StoreWorkloadCeilingAdmissionError extends RefusalError {
|
|
155
159
|
/**
|
|
156
160
|
* @param {string} distributionId
|
|
157
161
|
* @param {ReadonlyArray<{code: string, domain: string, message: string}>} refusals
|
|
@@ -182,7 +186,7 @@ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
/** Thrown when one or more declared organization-context relays are not admitted. */
|
|
185
|
-
export class OrgContextRelayAdmissionError extends
|
|
189
|
+
export class OrgContextRelayAdmissionError extends RefusalError {
|
|
186
190
|
/**
|
|
187
191
|
* @param {string} distributionId
|
|
188
192
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
@@ -212,7 +216,7 @@ export function assertOrgContextRelaysAdmitted(schemas, lock) {
|
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
/** Thrown when one or more declared event subscription clauses are not admitted. */
|
|
215
|
-
export class EventSubscriptionAdmissionError extends
|
|
219
|
+
export class EventSubscriptionAdmissionError extends RefusalError {
|
|
216
220
|
/**
|
|
217
221
|
* @param {string} distributionId
|
|
218
222
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
@@ -241,3 +245,41 @@ export function assertEventSubscriptionsAdmitted(schemas, lock) {
|
|
|
241
245
|
}
|
|
242
246
|
return admission;
|
|
243
247
|
}
|
|
248
|
+
|
|
249
|
+
/** Thrown when several admission categories refuse the same lock; `.errors` holds each category's own error. */
|
|
250
|
+
export class LockAdmissionRefusedError extends RefusalError {
|
|
251
|
+
/** @param {readonly Error[]} errors */
|
|
252
|
+
constructor(errors) {
|
|
253
|
+
super(`the distribution lock is refused by ${errors.length} admission checks:\n\n${joinRefusalMessages(errors)}`);
|
|
254
|
+
this.name = 'LockAdmissionRefusedError';
|
|
255
|
+
this.errors = errors;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run every admission a lock must pass, in the order the resolve has always run them, and refuse with all of them: one
|
|
261
|
+
* refusal is rethrown unchanged, several are one {@link LockAdmissionRefusedError}. `report` receives the optional
|
|
262
|
+
* operations left ungranted. Nothing here changes `lock`.
|
|
263
|
+
*/
|
|
264
|
+
export function admitDistributionLock(schemas, lock, { report } = {}) {
|
|
265
|
+
// The two ceilings read the lock's own fields, not the outcome of the grants, so they are checked beside them.
|
|
266
|
+
const admissions = [
|
|
267
|
+
() => assertOperationGrantsAdmitted(schemas, lock, { report }),
|
|
268
|
+
() => assertOrganizationSweepsAdmitted(schemas, lock),
|
|
269
|
+
() => assertExactAudiencesAdmitted(schemas, lock),
|
|
270
|
+
() => assertOrgContextRelaysAdmitted(schemas, lock),
|
|
271
|
+
() => assertEventSubscriptionsAdmitted(schemas, lock),
|
|
272
|
+
() => assertInstalledOperationCeilingAdmitted(schemas, lock),
|
|
273
|
+
() => assertStoreWorkloadCeilingAdmitted(schemas, lock),
|
|
274
|
+
];
|
|
275
|
+
collectRefusals(admissions, (admit) => admit(), isRefusal, LockAdmissionRefusedError);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The {@link ungrantedOptionalOperationLines} a refused admission carries: the refusal of the operation grants itself, or the
|
|
280
|
+
* one among several that is. Empty when the grants were not what refused.
|
|
281
|
+
*/
|
|
282
|
+
export function ungrantedOptionalLinesOfRefusal(error) {
|
|
283
|
+
const grantRefusal = [error, ...(error.errors ?? [])].find((candidate) => candidate instanceof OperationGrantAdmissionError);
|
|
284
|
+
return grantRefusal === undefined ? [] : ungrantedOptionalOperationLines(grantRefusal.distributionId, grantRefusal.ungrantedOptional);
|
|
285
|
+
}
|