@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.
Files changed (46) hide show
  1. package/dist/edition/verify-image-evidence.bundle.mjs +1996 -1012
  2. package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
  3. package/dist/runtime/lock-loader.d.ts +2 -1
  4. package/dist/runtime/lock-loader.d.ts.map +1 -1
  5. package/dist/runtime/lock-loader.js +15 -5
  6. package/dist/runtime/lock-loader.js.map +1 -1
  7. package/edition/README.md +8 -3
  8. package/edition/biome-content.mjs +4 -4
  9. package/edition/candidate-edition.mjs +8 -3
  10. package/edition/check-committed-image-evidence.mjs +44 -15
  11. package/edition/customer-edition-verbs.mjs +15 -4
  12. package/edition/develop-current-resolution.mjs +29 -0
  13. package/edition/develop-edition-resolve.mjs +18 -10
  14. package/edition/develop-edition.mjs +30 -9
  15. package/edition/develop-freeze.mjs +24 -1
  16. package/edition/develop-git-bundles.mjs +201 -0
  17. package/edition/develop-graph.mjs +5 -3
  18. package/edition/develop-image-probe.mjs +6 -4
  19. package/edition/develop-image-record.mjs +194 -0
  20. package/edition/develop-record-ancestry.mjs +23 -5
  21. package/edition/develop-select-records.mjs +29 -21
  22. package/edition/develop-selection-line.mjs +30 -0
  23. package/edition/develop-selection-pins.mjs +30 -0
  24. package/edition/dist-check.mjs +45 -9
  25. package/edition/distribution-evidence-set.mjs +14 -9
  26. package/edition/edition-image-seal.mjs +2 -1
  27. package/edition/edition-protocol.mjs +6 -20
  28. package/edition/external-source-walk.mjs +37 -15
  29. package/edition/image-evidence.mjs +6 -2
  30. package/edition/image-lock-service-rows.mjs +4 -4
  31. package/edition/image-subject-identity.mjs +24 -0
  32. package/edition/images-from-record.mjs +46 -110
  33. package/edition/lib.mjs +24 -26
  34. package/edition/locked-bundle-signer.mjs +52 -8
  35. package/edition/resolve-customer-edition.mjs +1 -1
  36. package/edition/resolve-distribution.mjs +84 -21
  37. package/edition/resolve-edition.mjs +2 -1
  38. package/edition/resolve-images.mjs +224 -12
  39. package/edition/seal-image-digests.mjs +24 -13
  40. package/edition/third-party-bundle-registry.mjs +73 -17
  41. package/edition/third-party-source-evidence.mjs +39 -14
  42. package/edition/third-party-source-writer.mjs +2 -18
  43. package/edition/trust-policy-arming.mjs +17 -6
  44. package/edition/verify-image-platforms.mjs +2 -0
  45. package/edition/workspace-sections.mjs +11 -1
  46. 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
- * v27 and older kernels lack `serviceImageSubjects` (first exported by the v28 kernel), so they are not listed.
35
- * No published kernel declares v29 (0.110.2 is v28, 0.110.3 is v30, 0.111.0 is v31, XEMA-2070 kernel is v32; v33 is the kernel's
36
- * bundle-signer protocol (`bundleSigner` on every OCI bundle row); v34 is the kernel's output-mapping, JSON Pointer and
37
- * handoff-repair-turns closure additions (XEMA-2122); v35 is the kernel's independent releases on a resolved deployment (XEMA-2244);
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: 28, contentDigest: 'f7795478b1bfe9a41bcbea9efe496011fcd209a56c90294029a08801e84cf92c' }),
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
- const have = (() => {
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 `${subject.kind}\0${subject.id}`;
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 `serviceImageSubjects`
17
- * (`enums.serviceImageSubjects`), the same rule the launcher's `loadImageLock` applies; this file adds only the
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.serviceImageSubjects(owned).get(id);
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 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`.
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, isDevelopImageRef } from './develop-image-coordinate.mjs';
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
- 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;
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
- 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);
76
+ return schemas;
140
77
  }
141
78
 
142
79
  /**
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.
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
- * @returns {{schemaVersion: 1, images: object[], satellites: object[]}}
86
+ * @param {{loadKernel?: () => Promise<object>}} [injections] tests replace the kernel loader
149
87
  */
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
- });
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
- serviceImageSubjects,
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 serviceImageSubjects !== 'function' ||
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
- serviceImageSubjects,
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`: 3 without
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
- const identity = `${subject.kind}\0${subject.id}`;
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 "${subject.kind}:${subject.id}"`,
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 (!identities.has(`${binding.subjectKind}\0${binding.subjectId}`)) {
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
  );