@xemahq/xema 0.54.0 → 0.56.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/dist/edition/verify-image-evidence.bundle.mjs +1996 -1012
- package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
- package/dist/runtime/lock-loader.d.ts +2 -1
- package/dist/runtime/lock-loader.d.ts.map +1 -1
- package/dist/runtime/lock-loader.js +15 -5
- package/dist/runtime/lock-loader.js.map +1 -1
- package/edition/README.md +8 -3
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/customer-edition-verbs.mjs +15 -4
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-resolve.mjs +18 -10
- package/edition/develop-edition.mjs +30 -9
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +6 -4
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- package/edition/develop-select-records.mjs +29 -21
- package/edition/develop-selection-line.mjs +30 -0
- package/edition/develop-selection-pins.mjs +30 -0
- package/edition/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +6 -20
- package/edition/external-source-walk.mjs +37 -15
- package/edition/image-evidence.mjs +6 -2
- package/edition/image-lock-service-rows.mjs +4 -4
- package/edition/image-subject-identity.mjs +24 -0
- package/edition/images-from-record.mjs +46 -110
- package/edition/lib.mjs +24 -26
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +224 -12
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +11 -1
- package/package.json +3 -3
|
@@ -30,27 +30,13 @@ import { RefusalError } from './collected-refusals.mjs';
|
|
|
30
30
|
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
31
31
|
|
|
32
32
|
/** The edition protocols this library interprets: version -> content digest. A protocol is listed only
|
|
33
|
-
* while every kernel published under it exports what this library imports (`kernel-export-coverage.test.mjs`)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* v36 is the kernel's JobFailureReason workflow closure addition (XEMA-2282);
|
|
39
|
-
* v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v39 admits an optional operation on an undeclared domain as ungranted (`owner-undeclared`, TEAM-1 EDR-2); v40 is the kernel's resource `sharing` field (owner or shared; one owner row per resource); v33 to v39 are not yet published: their identities and contracts are captured from the
|
|
40
|
-
* linked kernel), so v29 is not listed either. */
|
|
33
|
+
* while every kernel published under it exports what this library imports (`kernel-export-coverage.test.mjs`).
|
|
34
|
+
* Only v45 is listed (contracts 0.119.0: image-lock v5, build record `members`, and an idempotent
|
|
35
|
+
* `carryForwardComposedArtifacts`). A v44 kernel's carry-forward bumps the generation on every call, so dist-check could never
|
|
36
|
+
* be green on its seals; v43 cannot carry build record members; v42 and older lack `DEFAULT_COMPOSED_IMAGE_CLASS` and
|
|
37
|
+
* `dedicatedServiceImageSubjects`. No legacy row is kept. */
|
|
41
38
|
export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
|
|
42
|
-
Object.freeze({ version:
|
|
43
|
-
Object.freeze({ version: 30, contentDigest: '91e3cdff255778b5aa94b64ca9316d031be549ead2267a9a9311ff06d4f1db2c' }),
|
|
44
|
-
Object.freeze({ version: 31, contentDigest: 'b2026847fdd202e7b9e05815cdc2c9819b60be7007c789cc5b5f291c9c682fdd' }),
|
|
45
|
-
Object.freeze({ version: 32, contentDigest: 'a4ca824486052ca7f61b9a68f759b798defb620b6b3e9cbaab2c701bee2c6700' }),
|
|
46
|
-
Object.freeze({ version: 33, contentDigest: 'b070713e7abeeefad6da3a9053843b0f7420a466f6d504f60aaa00ef196b2b05' }),
|
|
47
|
-
Object.freeze({ version: 34, contentDigest: '370ceef5956d66035265ddbf7b9c46aa686cd10c1e95171c806c0d590ef5f49c' }),
|
|
48
|
-
Object.freeze({ version: 35, contentDigest: '3e616e532c45ecbd6277dde31c0d9c12a12139abe06cc0f56329d1ffe9813f8d' }),
|
|
49
|
-
Object.freeze({ version: 36, contentDigest: 'b6b0c8ec3fc4182c6c3b623685af6293cfed1321f8d7c1891a83652d864c3df0' }),
|
|
50
|
-
Object.freeze({ version: 37, contentDigest: 'f10adca1ca5b6f51f3971756261032eeddd60e372ebfcff06a2a9d8a796d0fef' }),
|
|
51
|
-
Object.freeze({ version: 38, contentDigest: 'f670cb922c989fd40ac03703a612e7a6df2937233a1a20a394a859ec835d47d4' }),
|
|
52
|
-
Object.freeze({ version: 39, contentDigest: 'b2159ede1755526cb63b6c533bc00caaead8151784864caf1a4d682281f47808' }),
|
|
53
|
-
Object.freeze({ version: 40, contentDigest: 'e26613bfccc87e2ccc0d94ac2c412cc35d1f48f48559bb8d0606f14d499db528' }),
|
|
39
|
+
Object.freeze({ version: 45, contentDigest: '54eb2f7d40e966e6efd5bc2ef0f4751b1121786300f4c50066e8add10c3ecb6a' }),
|
|
54
40
|
]);
|
|
55
41
|
|
|
56
42
|
/** Where a kernel package publishes its identity and its contract, package-relative. */
|
|
@@ -180,6 +180,24 @@ function resolveRemoteCommit(source, gitEnv, exec = execFileSync) {
|
|
|
180
180
|
return commit;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** Fetch `commit` by id, depth 1, into the checkout at `directory` unless it already holds it. */
|
|
184
|
+
function fetchCommitUnlessHeld(directory, commit, gitEnv, exec) {
|
|
185
|
+
const have = (() => {
|
|
186
|
+
try {
|
|
187
|
+
return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
if (!have) {
|
|
193
|
+
exec(
|
|
194
|
+
'git',
|
|
195
|
+
['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
|
|
196
|
+
{ env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
183
201
|
/**
|
|
184
202
|
* Fetch a declared git source and return the directory + the EXACT commit its
|
|
185
203
|
* manifests will be read at.
|
|
@@ -209,20 +227,7 @@ export function materializeGitSource({
|
|
|
209
227
|
// reuses it: fetch from where the declaration says the source lives NOW.
|
|
210
228
|
git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv, exec);
|
|
211
229
|
}
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
|
|
215
|
-
} catch {
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
})();
|
|
219
|
-
if (!have) {
|
|
220
|
-
exec(
|
|
221
|
-
'git',
|
|
222
|
-
['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
|
|
223
|
-
{ env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
224
|
-
);
|
|
225
|
-
}
|
|
230
|
+
fetchCommitUnlessHeld(directory, commit, gitEnv, exec);
|
|
226
231
|
git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv, exec);
|
|
227
232
|
return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
|
|
228
233
|
});
|
|
@@ -405,6 +410,7 @@ export async function walkDeclaredSources({
|
|
|
405
410
|
} = {}) {
|
|
406
411
|
const entries = [];
|
|
407
412
|
const sourceRecords = new Map();
|
|
413
|
+
const checkouts = new Map();
|
|
408
414
|
// Every source is held (kind, then credential and URL) before the first is materialised.
|
|
409
415
|
const heldWorkspace = sources.length === 0 ? undefined : (workspace ?? editionWorkspaceConfig());
|
|
410
416
|
for (const source of sources) {
|
|
@@ -446,6 +452,7 @@ export async function walkDeclaredSources({
|
|
|
446
452
|
entries.push(stampEntry(assertSourceWalkedOrigin(await manifestToIndexEntry(manifest), source.name), source.name));
|
|
447
453
|
}
|
|
448
454
|
sourceRecords.set(source.name, lockedSourceRecord(source, commit));
|
|
455
|
+
checkouts.set(source.name, { source, directory });
|
|
449
456
|
}
|
|
450
457
|
// The last point at which an unstamped entry can be caught before it reaches
|
|
451
458
|
// the shared index, where absent-is-the-identity makes it admissible
|
|
@@ -459,5 +466,20 @@ export async function walkDeclaredSources({
|
|
|
459
466
|
'open-source edition. Refusing.',
|
|
460
467
|
);
|
|
461
468
|
}
|
|
462
|
-
return { entries, sourceRecords };
|
|
469
|
+
return { entries, sourceRecords, checkouts };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The git tree id of `path` (repository-relative, `.` for the root) of a declared git source at `commit`, read in the
|
|
474
|
+
* walk's checkout of that source (`checkouts` of `walkDeclaredSources`). A commit the checkout does not hold is fetched
|
|
475
|
+
* by id, depth 1, from the declared URL under the source's own credential hold: no history is needed.
|
|
476
|
+
*/
|
|
477
|
+
export function sourceTreeIdAt({ checkout, commit, path, env = process.env, workspace = editionWorkspaceConfig(), exec = execFileSync }) {
|
|
478
|
+
if (!EXACT_COMMIT.test(String(commit))) {
|
|
479
|
+
throw new Error(`source "${checkout.source.name}": tree of ${JSON.stringify(commit)} asked, which is not an exact 40-hex commit`);
|
|
480
|
+
}
|
|
481
|
+
return withSourceGitEnv(checkout.source, { workspace, env }, (gitEnv) => {
|
|
482
|
+
fetchCommitUnlessHeld(checkout.directory, commit, gitEnv, exec);
|
|
483
|
+
return git(checkout.directory, ['rev-parse', '--verify', path === '.' ? `${commit}^{tree}` : `${commit}:${path}`], gitEnv, exec);
|
|
484
|
+
});
|
|
463
485
|
}
|
|
@@ -20,6 +20,7 @@ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs'
|
|
|
20
20
|
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
21
21
|
import { effectiveRepositoryPins, manifestSourcePins } from './edition-source-pins.mjs';
|
|
22
22
|
import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
|
|
23
|
+
import { evidenceSubjectKey } from './image-subject-identity.mjs';
|
|
23
24
|
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
24
25
|
import {
|
|
25
26
|
compareStrings,
|
|
@@ -165,7 +166,7 @@ export function assertProducerTtlCeiling({ issuedAt, expiresAt, maxTtlSeconds, l
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
export function sourceIdentity(subject) {
|
|
168
|
-
return
|
|
169
|
+
return evidenceSubjectKey(subject);
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/** Root of the committed evidence tree, in every producing repository. */
|
|
@@ -424,7 +425,10 @@ export function validateProducerStatementBinding({
|
|
|
424
425
|
export function compareCompletenessImages(left, right) {
|
|
425
426
|
return (
|
|
426
427
|
compareStrings(left.subjectKind, right.subjectKind) ||
|
|
427
|
-
compareStrings(left.subjectId, right.subjectId)
|
|
428
|
+
compareStrings(left.subjectId, right.subjectId) ||
|
|
429
|
+
// The generations of one composed artifact share kind and id: they order by image, then manifest.
|
|
430
|
+
compareStrings(left.imageRepository, right.imageRepository) ||
|
|
431
|
+
compareStrings(left.manifestDigest, right.manifestDigest)
|
|
428
432
|
);
|
|
429
433
|
}
|
|
430
434
|
|
|
@@ -13,14 +13,14 @@ function isOwnedByBiome(subject, biomeId, enums) {
|
|
|
13
13
|
/**
|
|
14
14
|
* The subject carrying the image of `kind:id` for the biome `biomeId`, or undefined.
|
|
15
15
|
* A service is served by (a) a Service subject with that id owned by the biome, or (b) a subject owned by the biome
|
|
16
|
-
* whose `members[]` names the service. The member-to-subject step is the kernel's `
|
|
17
|
-
* (`enums.
|
|
16
|
+
* whose `members[]` names the service. The member-to-subject step is the kernel's `dedicatedServiceImageSubjects`
|
|
17
|
+
* (`enums.dedicatedServiceImageSubjects`), applied to the lock's `schemaVersion` and the biome's subjects; the same rule the launcher's `loadImageLock` applies; this file adds only the
|
|
18
18
|
* biome-owner filter. The biome's own `services` list supplies `id`.
|
|
19
19
|
*/
|
|
20
|
-
export function subjectServingBiome(subjects, biomeId, kind, id, enums) {
|
|
20
|
+
export function subjectServingBiome(schemaVersion, subjects, biomeId, kind, id, enums) {
|
|
21
21
|
const owned = subjects.filter((subject) => isOwnedByBiome(subject, biomeId, enums));
|
|
22
22
|
if (kind !== enums.RuntimeImageSubjectKind.Service) {
|
|
23
23
|
return owned.find((subject) => subject.kind === kind && subject.id === id);
|
|
24
24
|
}
|
|
25
|
-
return enums.
|
|
25
|
+
return enums.dedicatedServiceImageSubjects({ schemaVersion, subjects: owned }).get(id);
|
|
26
26
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The identity of a subject of an images lock, as the kernel's image-lock v5 uniqueness rule defines it
|
|
2
|
+
// (`parseImageLock`): a subject is `kind:id`, except a composed-process ARTIFACT, which is `imageRef:contentHash` — the
|
|
3
|
+
// generations of one composed artifact share one id and differ by that coordinate. Every map keyed by subject (digest
|
|
4
|
+
// plans, the seal, the lock validation) uses this one function, so two live generations never collapse onto one key.
|
|
5
|
+
|
|
6
|
+
const COMPOSED_PROCESS_KIND = 'composed-process';
|
|
7
|
+
|
|
8
|
+
/** The printable identity of a lock subject (or of a verification entry carrying `kind`, `id`, `imageRef`, `contentHash`). */
|
|
9
|
+
export function imageSubjectIdentity({ kind, id, imageRef, contentHash }) {
|
|
10
|
+
return kind === COMPOSED_PROCESS_KIND ? `${kind}:${imageRef}:${contentHash}` : `${kind}:${id}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The key of a subject in the evidence lane: `kind\0id` for every kind but a composed-process artifact, which is its
|
|
15
|
+
* `imageSubjectIdentity` (so two live generations of one composed artifact get distinct producer stems and index keys).
|
|
16
|
+
*/
|
|
17
|
+
export function evidenceSubjectKey({ kind, id, imageRef, contentHash }) {
|
|
18
|
+
return kind === COMPOSED_PROCESS_KIND ? imageSubjectIdentity({ kind, id, imageRef, contentHash }) : `${kind}\0${id}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The key of one image of a completeness statement, which names its image by repository and manifest digest, not by content hash. */
|
|
22
|
+
export function completenessImageKey({ subjectKind, subjectId, imageRepository, manifestDigest }) {
|
|
23
|
+
return subjectKind === COMPOSED_PROCESS_KIND ? `${subjectKind}:${imageRepository}@${manifestDigest}` : `${subjectKind}\0${subjectId}`;
|
|
24
|
+
}
|
|
@@ -24,13 +24,13 @@
|
|
|
24
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
25
|
// entry naming one is an extra subject.
|
|
26
26
|
//
|
|
27
|
-
// The record schema is
|
|
28
|
-
//
|
|
27
|
+
// The record schema is the kernel's (`ImageBuildRecordSchema` / `parseImageBuildRecord`, @xemahq/contracts, edition
|
|
28
|
+
// protocol v41), loaded through the kernel loader; this module owns no parser of its own.
|
|
29
29
|
|
|
30
30
|
import { canonicalJson } from './canonical-evidence-json.mjs';
|
|
31
31
|
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
32
32
|
import { RefusalError } from './collected-refusals.mjs';
|
|
33
|
-
import { developImageRef
|
|
33
|
+
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
34
34
|
import { withRecordVerifier } from './develop-record-verify.mjs';
|
|
35
35
|
import { findVerifiedProducerImageRecord } from './develop-verify-image-record.mjs';
|
|
36
36
|
import { EditionChannel } from './edition-channel.mjs';
|
|
@@ -41,9 +41,6 @@ import { RecordRefused } from './record-refused.mjs';
|
|
|
41
41
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
42
42
|
import { FirstPartyImageOrigin } from './workspace-sections.mjs';
|
|
43
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
44
|
/** Which list of the record an entry came from: how far it is trusted. */
|
|
48
45
|
export const RecordedImageOrigin = Object.freeze({
|
|
49
46
|
Built: 'images',
|
|
@@ -56,17 +53,6 @@ const LIST_OF_ORIGIN = Object.freeze({
|
|
|
56
53
|
[FirstPartyImageOrigin.Satellite]: RecordedImageOrigin.Satellite,
|
|
57
54
|
});
|
|
58
55
|
|
|
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
56
|
/** A build record that is malformed, or does not cover the edition exactly. */
|
|
71
57
|
export class ImageBuildRecordError extends RefusalError {
|
|
72
58
|
constructor(message) {
|
|
@@ -80,104 +66,32 @@ export const subjectKeyOf = (subject) => `${subject.kind}:${subject.id}`;
|
|
|
80
66
|
|
|
81
67
|
const platformKey = (platform) => [platform.os, platform.architecture, platform.variant ?? ''].join('/');
|
|
82
68
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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;
|
|
69
|
+
/** The kernel's image-build-record exports, refused as a set when any is absent (a kernel before edition protocol v41). */
|
|
70
|
+
export function requireImageBuildRecordKernel(schemas) {
|
|
71
|
+
const names = ['ImageBuildRecordSchema', 'parseImageBuildRecord', 'IMAGE_BUILD_RECORD_SCHEMA_VERSION'];
|
|
72
|
+
const missing = names.filter((name) => schemas?.[name] === undefined);
|
|
73
|
+
if (missing.length > 0) {
|
|
74
|
+
throw new ImageBuildRecordError(`the kernel's distribution module does not export ${missing.join(', ')}; the image build record needs a kernel at edition protocol v41 or newer`);
|
|
126
75
|
}
|
|
127
|
-
|
|
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);
|
|
76
|
+
return schemas;
|
|
140
77
|
}
|
|
141
78
|
|
|
142
79
|
/**
|
|
143
|
-
* Parse an image build record
|
|
144
|
-
*
|
|
80
|
+
* Parse an image build record with the kernel's own parser (`parseImageBuildRecord`, the ONE contract: both the writer
|
|
81
|
+
* `develop image-record` and this reader go through it): `{schemaVersion: 1, images: [entry], satellites: [entry]}`, every
|
|
82
|
+
* list sorted by subject, no subject twice, no unknown key. A refusal is an `ImageBuildRecordError` carrying the kernel's message.
|
|
145
83
|
*
|
|
146
84
|
* @param {unknown} document
|
|
147
85
|
* @param {string} source where the record came from (named in the refusal)
|
|
148
|
-
* @
|
|
86
|
+
* @param {{loadKernel?: () => Promise<object>}} [injections] tests replace the kernel loader
|
|
149
87
|
*/
|
|
150
|
-
export function parseImageBuildRecord(document, source) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
});
|
|
88
|
+
export async function parseImageBuildRecord(document, source, { loadKernel = loadKernelDistributionSchemas } = {}) {
|
|
89
|
+
const kernel = requireImageBuildRecordKernel(await loadKernel());
|
|
90
|
+
try {
|
|
91
|
+
return kernel.parseImageBuildRecord(document);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new ImageBuildRecordError(`image build record ${source} is refused: ${error.message}`);
|
|
176
94
|
}
|
|
177
|
-
if (problems.length > 0) {
|
|
178
|
-
throw new ImageBuildRecordError(`image build record ${source} is refused:\n ${problems.join('\n ')}`);
|
|
179
|
-
}
|
|
180
|
-
return document;
|
|
181
95
|
}
|
|
182
96
|
|
|
183
97
|
/**
|
|
@@ -194,7 +108,7 @@ export function imageBuildRecordDigest(record) {
|
|
|
194
108
|
}
|
|
195
109
|
|
|
196
110
|
/** Read and parse the record file `--images-from` names (duplicate JSON keys refused). */
|
|
197
|
-
export function readImageBuildRecord(path) {
|
|
111
|
+
export async function readImageBuildRecord(path) {
|
|
198
112
|
return parseImageBuildRecord(readStrictJsonFile(path), path);
|
|
199
113
|
}
|
|
200
114
|
|
|
@@ -240,7 +154,7 @@ export function createRecordedImages(record, { distributionId, declaredSources =
|
|
|
240
154
|
const absent = subject.requiredPlatforms.map(platformKey).filter((platform) => !recordedPlatforms.has(platform));
|
|
241
155
|
if (absent.length > 0) {problems.push(`${key}: the record's platforms lack the required ${absent.join(', ')}`);}
|
|
242
156
|
taken.set(key, { subject, ...recorded });
|
|
243
|
-
return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash };
|
|
157
|
+
return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash, ...recordedMembers(key, subject, entry, problems) };
|
|
244
158
|
},
|
|
245
159
|
settle() {
|
|
246
160
|
settled = true;
|
|
@@ -262,6 +176,28 @@ export function createRecordedImages(record, { distributionId, declaredSources =
|
|
|
262
176
|
};
|
|
263
177
|
}
|
|
264
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A composed subject's members with the keys its record entry carries (`members[{service, key}]`), never computed here.
|
|
181
|
+
* An entry without members, or whose services are not the subject's members, is a problem named once; a subject that is
|
|
182
|
+
* not composed gives nothing.
|
|
183
|
+
*/
|
|
184
|
+
function recordedMembers(key, subject, entry, problems) {
|
|
185
|
+
if (subject.members === undefined) {return {};}
|
|
186
|
+
if (entry.members === undefined) {
|
|
187
|
+
problems.push(`${key}: the record's composed entry carries no members[{service, key}]`);
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
const keyOf = new Map(entry.members.map((member) => [member.service, member.key]));
|
|
191
|
+
const wanted = subject.members.map((member) => member.service);
|
|
192
|
+
const absent = wanted.filter((service) => !keyOf.has(service));
|
|
193
|
+
const extra = [...keyOf.keys()].filter((service) => !wanted.includes(service));
|
|
194
|
+
if (absent.length > 0 || extra.length > 0) {
|
|
195
|
+
problems.push(`${key}: the record's members differ from the subject's${absent.length > 0 ? ` (no key for ${absent.join(', ')})` : ''}${extra.length > 0 ? ` (not members: ${extra.join(', ')})` : ''}`);
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
return { members: subject.members.map((member) => ({ ...member, key: keyOf.get(member.service) })) };
|
|
199
|
+
}
|
|
200
|
+
|
|
265
201
|
/** Why the record list `origin` may not carry the subject's entry, or null when its source's declared origin names it. */
|
|
266
202
|
function recordListProblem(key, subject, origin, declaredSources) {
|
|
267
203
|
if (!Object.hasOwn(declaredSources, subject.repo)) {
|
|
@@ -337,9 +273,9 @@ export function createSatelliteVerifier({ registry, cosignBinary, env = process.
|
|
|
337
273
|
*
|
|
338
274
|
* @returns {Promise<{imageLock: object, recorded: ReturnType<typeof createRecordedImages>}>}
|
|
339
275
|
*/
|
|
340
|
-
export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites }) {
|
|
276
|
+
export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites, previousLock = null }) {
|
|
341
277
|
const recorded = createRecordedImages(record, { distributionId: id });
|
|
342
|
-
const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded });
|
|
278
|
+
const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded, previousLock });
|
|
343
279
|
await verifySatellites(recorded.satelliteClaims());
|
|
344
280
|
return { imageLock, recorded };
|
|
345
281
|
}
|
package/edition/lib.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from './edition-workspace.mjs';
|
|
37
37
|
import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
|
|
38
38
|
import { readGitJsonAtCommit } from './git-json.mjs';
|
|
39
|
+
import { imageSubjectIdentity } from './image-subject-identity.mjs';
|
|
39
40
|
import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
|
|
40
41
|
import {
|
|
41
42
|
PRINCIPAL_PRIVILEGE_FIELDS,
|
|
@@ -719,23 +720,6 @@ export function assertKernelSealsComposedMembership(schemas, subjects, label) {
|
|
|
719
720
|
);
|
|
720
721
|
}
|
|
721
722
|
|
|
722
|
-
/**
|
|
723
|
-
* The READ side of the same rule, for a kernel before image-lock
|
|
724
|
-
* schemaVersion 4 (no `ImageLockSchemaVersion`): it reads only schemaVersion
|
|
725
|
-
* 3, which names no composed members, so a v3 lock carrying a composed-process
|
|
726
|
-
* subject cannot say which services run from it and is refused (worded as the
|
|
727
|
-
* kernel's own `LegacyComposedImageLockError`).
|
|
728
|
-
*/
|
|
729
|
-
export function assertLegacyLockHasNoComposedSubject(lock) {
|
|
730
|
-
const composed = (lock.subjects ?? []).find((subject) => subject.kind === 'composed-process');
|
|
731
|
-
if (composed === undefined) {return;}
|
|
732
|
-
throw new ComposedProcessNeedsLockV4Error(
|
|
733
|
-
`distribution "${lock.distributionId}": subjects "${composed.id}" is a schemaVersion ` +
|
|
734
|
-
`${String(lock.schemaVersion)} composed-process subject, which names no members: regenerate with a CLI ` +
|
|
735
|
-
'that writes image-lock schemaVersion 4, against a kernel at edition protocol >= v14',
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
723
|
/**
|
|
740
724
|
* The image-lock schemaVersion a writer emits for `subjects`: the loaded
|
|
741
725
|
* kernel's `IMAGE_LOCK_WRITE_SCHEMA_VERSION`. A kernel before edition protocol
|
|
@@ -800,7 +784,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
800
784
|
parseOciDigest,
|
|
801
785
|
parseGitTreeId,
|
|
802
786
|
parseImageLock,
|
|
803
|
-
|
|
787
|
+
dedicatedServiceImageSubjects,
|
|
788
|
+
carryForwardComposedArtifacts,
|
|
789
|
+
DEFAULT_COMPOSED_IMAGE_CLASS,
|
|
790
|
+
ComposedImageClassSchema,
|
|
804
791
|
IMAGE_LOCK_WRITE_SCHEMA_VERSION,
|
|
805
792
|
ImageLockSchemaVersion,
|
|
806
793
|
parseImageProducerStatement,
|
|
@@ -893,7 +880,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
893
880
|
typeof parseOciDigest !== 'function' ||
|
|
894
881
|
typeof parseGitTreeId !== 'function' ||
|
|
895
882
|
typeof parseImageLock !== 'function' ||
|
|
896
|
-
typeof
|
|
883
|
+
typeof dedicatedServiceImageSubjects !== 'function' ||
|
|
884
|
+
typeof carryForwardComposedArtifacts !== 'function' ||
|
|
885
|
+
typeof DEFAULT_COMPOSED_IMAGE_CLASS !== 'string' ||
|
|
886
|
+
typeof ComposedImageClassSchema?.safeParse !== 'function' ||
|
|
897
887
|
typeof parseImageProducerStatement !== 'function' ||
|
|
898
888
|
typeof parseDistributionImagesStatement !== 'function' ||
|
|
899
889
|
typeof parseImageDeliveryEntitlementStatement !== 'function' ||
|
|
@@ -997,6 +987,8 @@ export async function loadKernelDistributionSchemas() {
|
|
|
997
987
|
// The build-record exports (absent from a kernel before the build-record contract): carried as-is, NOT required
|
|
998
988
|
// here, so every other verb still loads an older kernel. `select-records` refuses a kernel that lacks them.
|
|
999
989
|
const { BUILD_RECORD_MEDIA_TYPE, BuildRecordSchema, buildRecordTag, parseBuildRecordTag, compareBuildRecordRuns, parseBuildRecord } = mod;
|
|
990
|
+
// The image-build-record exports (edition protocol v41): the same, carried as-is; `develop image-record` and `--images-from` refuse an older kernel.
|
|
991
|
+
const { IMAGE_BUILD_RECORD_SCHEMA_VERSION, ImageBuildRecordSchema, parseImageBuildRecord } = mod;
|
|
1000
992
|
return {
|
|
1001
993
|
BUILD_RECORD_MEDIA_TYPE,
|
|
1002
994
|
BuildRecordSchema,
|
|
@@ -1004,6 +996,9 @@ export async function loadKernelDistributionSchemas() {
|
|
|
1004
996
|
parseBuildRecordTag,
|
|
1005
997
|
compareBuildRecordRuns,
|
|
1006
998
|
parseBuildRecord,
|
|
999
|
+
IMAGE_BUILD_RECORD_SCHEMA_VERSION,
|
|
1000
|
+
ImageBuildRecordSchema,
|
|
1001
|
+
parseImageBuildRecord,
|
|
1007
1002
|
parseDistribution,
|
|
1008
1003
|
parseDistributionLock,
|
|
1009
1004
|
parseDistributionLockForReading,
|
|
@@ -1022,7 +1017,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
1022
1017
|
parseOciDigest,
|
|
1023
1018
|
parseGitTreeId,
|
|
1024
1019
|
parseImageLock,
|
|
1025
|
-
|
|
1020
|
+
dedicatedServiceImageSubjects,
|
|
1021
|
+
carryForwardComposedArtifacts,
|
|
1022
|
+
DEFAULT_COMPOSED_IMAGE_CLASS,
|
|
1023
|
+
ComposedImageClassSchema,
|
|
1026
1024
|
IMAGE_LOCK_WRITE_SCHEMA_VERSION,
|
|
1027
1025
|
ImageLockSchemaVersion,
|
|
1028
1026
|
parseImageProducerStatement,
|
|
@@ -3111,9 +3109,7 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3111
3109
|
throw new Error('trusted kernel distribution schemas are required');
|
|
3112
3110
|
}
|
|
3113
3111
|
assertKernelSealsComposedMembership(schemas, lock.subjects, `distribution "${lock.distributionId}"`);
|
|
3114
|
-
// The revisions the loaded kernel reads (`ImageLockSchemaVersion
|
|
3115
|
-
// composed membership, 4 with it); a kernel before image-lock v4 reads only 3.
|
|
3116
|
-
if (schemas.ImageLockSchemaVersion === undefined) {assertLegacyLockHasNoComposedSubject(lock);}
|
|
3112
|
+
// The revisions the loaded kernel reads (`ImageLockSchemaVersion`).
|
|
3117
3113
|
const readableVersions =
|
|
3118
3114
|
schemas.ImageLockSchemaVersion === undefined
|
|
3119
3115
|
? [PRE_V14_IMAGE_LOCK_SCHEMA_VERSION]
|
|
@@ -3128,11 +3124,13 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3128
3124
|
);
|
|
3129
3125
|
}
|
|
3130
3126
|
const identities = new Set();
|
|
3127
|
+
const namedSubjects = new Set();
|
|
3131
3128
|
for (const subject of lock.subjects ?? []) {
|
|
3132
|
-
|
|
3129
|
+
namedSubjects.add(`${subject.kind}:${subject.id}`);
|
|
3130
|
+
const identity = imageSubjectIdentity(subject);
|
|
3133
3131
|
if (identities.has(identity)) {
|
|
3134
3132
|
throw new Error(
|
|
3135
|
-
`distribution "${lock.distributionId}" has duplicate runtime image subject "${
|
|
3133
|
+
`distribution "${lock.distributionId}" has duplicate runtime image subject "${identity}"`,
|
|
3136
3134
|
);
|
|
3137
3135
|
}
|
|
3138
3136
|
identities.add(identity);
|
|
@@ -3167,7 +3165,7 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3167
3165
|
);
|
|
3168
3166
|
}
|
|
3169
3167
|
platformServices.add(binding.platformService);
|
|
3170
|
-
if (!
|
|
3168
|
+
if (!namedSubjects.has(`${binding.subjectKind}:${binding.subjectId}`)) {
|
|
3171
3169
|
throw new Error(
|
|
3172
3170
|
`distribution "${lock.distributionId}": platform service "${binding.platformService}" binds unknown runtime subject "${binding.subjectKind}:${binding.subjectId}"`,
|
|
3173
3171
|
);
|