@xemahq/xema 0.27.0 → 0.29.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 (63) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/dev/env-defaults.d.ts.map +1 -1
  5. package/dist/dev/env-defaults.js +0 -1
  6. package/dist/dev/env-defaults.js.map +1 -1
  7. package/dist/edition/verify-image-evidence.bundle.mjs +231 -50
  8. package/dist/edition/verify-image-evidence.bundle.provenance.json +55 -43
  9. package/dist/runtime/edition/verb-map.d.ts +2 -1
  10. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  11. package/dist/runtime/edition/verb-map.js +2 -0
  12. package/dist/runtime/edition/verb-map.js.map +1 -1
  13. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  14. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  15. package/dist/runtime/runner/chart-set-lock.js +39 -4
  16. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  17. package/edition/README.md +76 -41
  18. package/edition/build-biome-index.mjs +1 -1
  19. package/edition/build-platform-service-index.mjs +1 -1
  20. package/edition/build-runtime-image-source-index.mjs +64 -20
  21. package/edition/catalogue-fetch.mjs +36 -9
  22. package/edition/catalogue-pin.mjs +71 -0
  23. package/edition/catalogue.mjs +11 -0
  24. package/edition/chart-set-source-commit.mjs +17 -7
  25. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  26. package/edition/customer-flags.mjs +0 -1
  27. package/edition/develop-customer-edition.mjs +23 -5
  28. package/edition/develop-edition.mjs +147 -48
  29. package/edition/develop-freeze.mjs +69 -25
  30. package/edition/develop-seal.mjs +39 -25
  31. package/edition/dist-check.mjs +1 -1
  32. package/edition/edition-artifact.mjs +79 -18
  33. package/edition/edition-biome-manifests.mjs +2 -3
  34. package/edition/edition-chart-set.mjs +59 -6
  35. package/edition/edition-config.mjs +45 -21
  36. package/edition/edition-constants.mjs +10 -4
  37. package/edition/edition-contract-parse.mjs +52 -0
  38. package/edition/edition-contract.json +21 -5
  39. package/edition/edition-workspace.mjs +23 -9
  40. package/edition/evidence-checkout.mjs +22 -1
  41. package/edition/evidence-trust-policy.mjs +142 -78
  42. package/edition/external-source-walk.mjs +48 -61
  43. package/edition/git-source-auth.mjs +104 -25
  44. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  45. package/edition/image-evidence.mjs +21 -3
  46. package/edition/kernel-contract-authority.mjs +23 -1
  47. package/edition/lib.mjs +16 -24
  48. package/edition/oras.mjs +1 -1
  49. package/edition/pin-source.mjs +6 -1
  50. package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
  51. package/edition/registry-credentials.mjs +11 -1
  52. package/edition/release-channel-guard.mjs +11 -1
  53. package/edition/release-edition-artifact.mjs +5 -6
  54. package/edition/resolve-chart-set.mjs +50 -30
  55. package/edition/resolve-customer-edition.mjs +63 -7
  56. package/edition/resolve-distribution.mjs +1 -1
  57. package/edition/resolve-edition.mjs +12 -11
  58. package/edition/resolve-images.mjs +36 -15
  59. package/edition/seal-image-digests.mjs +31 -7
  60. package/edition/source-closure.mjs +1 -2
  61. package/edition/trust-policy-arming-verb.mjs +68 -0
  62. package/edition/verify-image-evidence.mjs +40 -12
  63. package/package.json +3 -3
@@ -60,12 +60,18 @@ import { basename, join } from 'node:path';
60
60
  import { EditionChannel } from './edition-channel.mjs';
61
61
  import {
62
62
  EDITION_CHART_SET_FILE,
63
+ CHART_SET_LOCK_SCHEMA_VERSION,
64
+ DEVELOP_CHART_VERSION_PREFIX,
63
65
  EditionChartSetCheck,
66
+ OCI_DIGEST_PATTERN,
64
67
  assertRosteredChartSetLock,
65
68
  checkEditionChartSet,
69
+ DEVELOP_CHART_SHA_LENGTH,
70
+ hasDevelopChartVersionPrefix,
71
+ isDevelopChartVersion,
66
72
  writeEditionChartSet,
67
73
  } from './edition-chart-set.mjs';
68
- import { editionWorkspaceConfig } from './edition-config.mjs';
74
+ import { vendorChartSetConfig } from './edition-config.mjs';
69
75
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule } from './edition-workspace.mjs';
70
76
  import { serializeJson } from './lib.mjs';
71
77
  import { OrasRegistry } from './oras.mjs';
@@ -95,11 +101,11 @@ export const chartSetLockPath = () => join(dataRoot(), CHART_SET_LOCK_FILE);
95
101
  * refuses a copy that differs — so the copies cannot disagree
96
102
  * (edition-chart-set.mjs).
97
103
  */
98
- const chartRegistry = () => editionWorkspaceConfig().chartRegistry;
99
- const chartSetPublishers = () => editionWorkspaceConfig().chartPublishers;
104
+ const chartRegistry = () => vendorChartSetConfig().chartRegistry;
105
+ const chartSetPublishers = () => vendorChartSetConfig().chartPublishers;
100
106
 
101
107
  /** `<host>/<path>` -> the repository path the registry API addresses. */
102
- function registryRepository(indexName) {
108
+ export function registryRepository(indexName) {
103
109
  return `${chartRegistry().replace(/^oci:\/\/[^/]+\//, '')}/${indexName}`;
104
110
  }
105
111
 
@@ -125,9 +131,9 @@ export function isVersionTag(tag) {
125
131
  */
126
132
  export function selectNewestIndex(allCandidates) {
127
133
  if (!Array.isArray(allCandidates)) {return null;}
128
- // A develop chart version (`0.0.0-dev.<sha12>`) is a frozen xema-deploy
129
- // snapshot, never a release index: it is not a candidate for the release pin.
130
- const candidates = allCandidates.filter((c) => !c.tag.startsWith(DEVELOP_CHART_VERSION_PREFIX));
134
+ // Anything with the develop prefix (`0.0.0-dev.`) is a frozen xema-deploy
135
+ // snapshot or a malformed develop version, never a release index: it is not a candidate for the release pin.
136
+ const candidates = allCandidates.filter((c) => !hasDevelopChartVersionPrefix(c.tag));
131
137
  if (candidates.length === 0) {return null;}
132
138
  const undated = candidates.filter((c) => !c.createdAt);
133
139
  if (undated.length > 0) {
@@ -142,12 +148,17 @@ export function selectNewestIndex(allCandidates) {
142
148
  )[0].tag;
143
149
  }
144
150
 
145
- const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
151
+ const DIGEST_PATTERN = OCI_DIGEST_PATTERN;
146
152
 
147
153
  /** The index chart annotation listing the charts the publisher closed the set with. */
148
154
  export const INDEX_ROSTER_ANNOTATION = 'xema.dev/chart-set-charts';
149
155
 
150
- export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSetPublishers()) {
156
+ /**
157
+ * The lock for `publishers`: each publisher's index version, the manifest digest
158
+ * the registry served for that index (`indexDigestsByIndex`, null for a develop
159
+ * snapshot, which has no index chart), and its sealed roster.
160
+ */
161
+ export function buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex, publishers = chartSetPublishers()) {
151
162
  // Every publisher's VERSION first: a pin that resolved nothing for one half is
152
163
  // the more fundamental refusal, whatever its roster.
153
164
  for (const publisher of publishers) {
@@ -156,10 +167,10 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
156
167
  }
157
168
  }
158
169
  return {
159
- // 3: every member is `{version, digest}` (its OCI manifest digest), so the
160
- // lock itself is the trust anchor. 2 added `publishers[].version` (one each)
161
- // in place of one shared version, which would couple the two halves.
162
- schemaVersion: 3,
170
+ // Every member is `{version, digest}` (its OCI manifest digest) and each
171
+ // publisher pins its index chart by `indexDigest`, so the lock itself is the
172
+ // trust anchor; `publishers[].version` is per publisher, not one shared version.
173
+ schemaVersion: CHART_SET_LOCK_SCHEMA_VERSION,
163
174
  registry: chartRegistry(),
164
175
  publishers: publishers.map((publisher) => {
165
176
  const version = versionsByIndex[publisher.index];
@@ -180,7 +191,13 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
180
191
  }
181
192
  charts[name] = { version: member.version, digest: member.digest };
182
193
  }
183
- return { ...publisher, version, charts };
194
+ // The digest the registry served for the index manifest the roster was read from.
195
+ const indexDigest = indexDigestsByIndex?.[publisher.index];
196
+ const wellFormed = isDevelopChartVersion(version) ? indexDigest === null : DIGEST_PATTERN.test(String(indexDigest));
197
+ if (!wellFormed) {
198
+ throw new Error(`no index manifest digest was read for '${publisher.index}@${version}' (got ${JSON.stringify(indexDigest ?? null)}; a release index needs sha256:<64 hex>, a develop snapshot null)`);
199
+ }
200
+ return { ...publisher, version, indexDigest, charts };
184
201
  }),
185
202
  };
186
203
  }
@@ -235,19 +252,21 @@ export async function sealRoster(roster, lookup, label) {
235
252
  return sealed;
236
253
  }
237
254
 
238
- /** The annotations of the index chart `<indexName>:<version>`, read from its OCI config blob (helm stores Chart.yaml's metadata there). */
239
- export async function indexAnnotations(client, indexName, version) {
255
+ /** The annotations of an index chart's served manifest `served` ({digest, bytes}), read from its OCI config blob (helm stores Chart.yaml's metadata there). */
256
+ export async function manifestAnnotations(client, indexName, version, served) {
240
257
  const repository = registryRepository(indexName);
241
- const manifest = JSON.parse((await client.getManifestByTag(repository, version)).bytes.toString('utf8'));
258
+ const manifest = JSON.parse(served.bytes.toString('utf8'));
242
259
  const configDigest = manifest?.config?.digest;
243
260
  if (typeof configDigest !== 'string') {throw new Error(`${indexName}@${version}: manifest names no config blob`);}
244
261
  const config = JSON.parse((await client.getBlob(repository, configDigest)).toString('utf8'));
245
262
  return config?.annotations ?? {};
246
263
  }
247
264
 
248
- async function indexRoster(client, indexName, version) {
249
- const annotations = await indexAnnotations(client, indexName, version);
250
- return parseIndexRoster(annotations[INDEX_ROSTER_ANNOTATION], `${indexName}@${version}`);
265
+ /** The roster and the served manifest digest of the index chart `<indexName>:<version>`, from ONE manifest read. */
266
+ async function indexRosterAndDigest(client, indexName, version) {
267
+ const served = await client.getManifestByTag(registryRepository(indexName), version);
268
+ const annotations = await manifestAnnotations(client, indexName, version, served);
269
+ return { roster: parseIndexRoster(annotations[INDEX_ROSTER_ANNOTATION], `${indexName}@${version}`), indexDigest: served.digest };
251
270
  }
252
271
 
253
272
  /** Every edition directory (one holding a distribution.lock.json) under the editions root. */
@@ -259,11 +278,9 @@ function editionDirs() {
259
278
  }
260
279
 
261
280
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
262
- const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
263
- const DEVELOP_SHA_LENGTH = 12;
264
281
 
265
282
  /** The develop chart version xema-deploy publishes for a frozen SHA: `0.0.0-dev.<sha12>`, also the OCI tag. */
266
- export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0, DEVELOP_SHA_LENGTH)}`;
283
+ export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0, DEVELOP_CHART_SHA_LENGTH)}`;
267
284
 
268
285
  /** Every chart (a directory with a Chart.yaml, library charts included) of an xema-deploy checkout, sorted. */
269
286
  function deployChartNames(xemaDeployDir) {
@@ -312,7 +329,8 @@ export async function resolveDevelopChartSet({ xemaDeployDir, xemaDeploySha, loo
312
329
  const digestOf = lookup ?? registryManifestDigest(chartRegistryClient());
313
330
  const roster = Object.fromEntries(names.map((name) => [name, { version }]));
314
331
  const sealed = await sealRoster(roster, digestOf, `${publishers[0].index}@${version}`);
315
- const lock = buildLock({ [publishers[0].index]: version }, { [publishers[0].index]: sealed }, publishers);
332
+ // A develop snapshot has no index chart: its roster is the deploy checkout's chart directories.
333
+ const lock = buildLock({ [publishers[0].index]: version }, { [publishers[0].index]: sealed }, { [publishers[0].index]: null }, publishers);
316
334
  return assertRosteredChartSetLock(lock, EDITION_CHART_SET_FILE, EditionChannel.Develop);
317
335
  }
318
336
 
@@ -343,11 +361,12 @@ async function indexCandidates(client, indexName) {
343
361
  return candidates;
344
362
  }
345
363
 
346
- async function resolveFromRegistry() {
347
- const client = chartRegistryClient();
364
+ /** Read every publisher's newest index through `client`: its version, its roster, and the manifest digest the registry served for it. */
365
+ export async function resolveFromRegistry(client = chartRegistryClient()) {
348
366
 
349
367
  const versionsByIndex = {};
350
368
  const rostersByIndex = {};
369
+ const indexDigestsByIndex = {};
351
370
  for (const { index, repo } of chartSetPublishers()) {
352
371
  const candidates = await indexCandidates(client, index);
353
372
  const version = selectNewestIndex(candidates);
@@ -359,7 +378,8 @@ async function resolveFromRegistry() {
359
378
  );
360
379
  }
361
380
  versionsByIndex[index] = version;
362
- const roster = await indexRoster(client, index, version);
381
+ const { roster, indexDigest } = await indexRosterAndDigest(client, index, version);
382
+ indexDigestsByIndex[index] = indexDigest;
363
383
  // The registry is asked for every member, so an index digest is checked
364
384
  // against it and a member without one still gets sealed.
365
385
  rostersByIndex[index] = await sealRoster(
@@ -368,13 +388,13 @@ async function resolveFromRegistry() {
368
388
  `${index}@${version}`,
369
389
  );
370
390
  }
371
- return { versionsByIndex, rostersByIndex };
391
+ return { versionsByIndex, rostersByIndex, indexDigestsByIndex };
372
392
  }
373
393
 
374
394
  async function main(argv) {
375
395
  const check = argv.includes('--check');
376
- const { versionsByIndex, rostersByIndex } = await resolveFromRegistry();
377
- const next = buildLock(versionsByIndex, rostersByIndex);
396
+ const { versionsByIndex, rostersByIndex, indexDigestsByIndex } = await resolveFromRegistry();
397
+ const next = buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex);
378
398
  assertRosteredChartSetLock(next, CHART_SET_LOCK_FILE, EditionChannel.Release);
379
399
  const pinText = serializeJson(next);
380
400
  const current = readLock();
@@ -1,10 +1,11 @@
1
1
  // resolve-customer-edition — resolve a CUSTOMER edition against a catalogue.
2
2
  //
3
- // resolve-edition.mjs <root flags> --definition <file> --catalogue oci://<repo>@sha256:<digest>
3
+ // resolve-edition.mjs <root flags> --definition <file>
4
4
  // --channel release|develop --out <dir> [--source-image <source>=<registry>/<prefix>]...
5
5
  // [--catalogue-username <name> --catalogue-token-env <VAR>]
6
6
  //
7
- // The catalogue (catalogue.mjs) is the CEILING: it supplies availability, pins,
7
+ // The catalogue of the channel is the one pinned in catalogue.pin.json beside the definition
8
+ // (catalogue-pin.mjs). The catalogue (catalogue.mjs) is the CEILING: it supplies availability, pins,
8
9
  // image digests, chart-set rows and the contract authority, and never adds a
9
10
  // component. The resolved set is the customer's positive selections, every
10
11
  // installationClass core entry of the catalogue and the transitive declared
@@ -15,6 +16,7 @@
15
16
 
16
17
  import { mkdirSync, writeFileSync } from 'node:fs';
17
18
  import { join } from 'node:path';
19
+ import { isDeepStrictEqual } from 'node:util';
18
20
 
19
21
  import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
20
22
  import { CATALOGUE_FILE, CatalogueError } from './catalogue.mjs';
@@ -31,7 +33,7 @@ import { validateKernelContractAuthorityFromGit } from './kernel-contract-author
31
33
  import { compareStrings, loadKernelDistributionSchemas, serializeJson, validateImmutableImageLock } from './lib.mjs';
32
34
  import { pinnedSource, snapshotPinSource } from './pin-source.mjs';
33
35
  import { composeAuthorizationBase, composeManifest, resolveDistributionWithIndex, UnknownSelectionError } from './resolve-distribution.mjs';
34
- import { resolveImages } from './resolve-images.mjs';
36
+ import { resolveImageRows } from './resolve-images.mjs';
35
37
 
36
38
  /** A selection or dependency the catalogue does not offer: the catalogue is the ceiling. */
37
39
  export class CatalogueCeilingError extends Error {
@@ -186,7 +188,57 @@ export function projectVendorImages(catalogue, lock, enums) {
186
188
  };
187
189
  }
188
190
 
189
- /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved. */
191
+ /** The first field in which two rows differ (compared by canonical deep equality, key by key), or undefined when they are equal. */
192
+ function firstDifferingField(expected, actual) {
193
+ for (const field of [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(compareStrings)) {
194
+ if (!isDeepStrictEqual(expected[field], actual[field])) {return field;}
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ /**
200
+ * The vendor rows of the merged images lock ARE the catalogue's rows: every subject, platform-service binding and web
201
+ * biome that is not the customer's own deep-equals the catalogue's row (owners narrowed to the selection by
202
+ * `projectVendorImages`, imageRef re-homed by the declared mirror through `mirrorVendorSubjects`), and every projected
203
+ * catalogue row is present. A difference throws `CatalogueCeilingError`, naming the row and the first differing field.
204
+ */
205
+ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, mirror, enums }) {
206
+ const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
207
+ const projected = projectVendorImages(catalogue, lock, enums);
208
+ const expected = {
209
+ subjects: new Map(mirrorVendorSubjects(projected.subjects, mirror).map((subject) => [subjectKey(subject), subject])),
210
+ platformServiceBindings: new Map(projected.platformServiceBindings.map((binding) => [binding.platformService, binding])),
211
+ webBiomes: new Map(projected.webBiomes.map((biome) => [biome.biomeId, biome])),
212
+ };
213
+ const isOwnSubject = (subject) => subject.owners.length > 0 && subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
214
+ const compare = (label, name, want, got) => {
215
+ if (want === undefined) {
216
+ throw new CatalogueCeilingError(`the merged images lock carries ${label} ${name}, which is neither the customer's own nor the catalogue's`);
217
+ }
218
+ const field = firstDifferingField(want, got);
219
+ if (field !== undefined) {
220
+ throw new CatalogueCeilingError(`the merged images lock's ${label} ${name} differs from the catalogue's row at ${field}`);
221
+ }
222
+ };
223
+ const kinds = [
224
+ ['image', 'subjects', (row) => subjectKey(row), (row) => `${row.kind}:${row.id}`, (row) => isOwnSubject(row)],
225
+ ['platform-service binding', 'platformServiceBindings', (row) => row.platformService, (row) => row.platformService, () => false],
226
+ ['web biome', 'webBiomes', (row) => row.biomeId, (row) => row.biomeId, (row) => ownBiomes.has(row.biomeId)],
227
+ ];
228
+ for (const [label, field, keyOf, nameOf, isOwn] of kinds) {
229
+ const present = new Set();
230
+ for (const row of imageLock[field]) {
231
+ if (isOwn(row)) {continue;}
232
+ present.add(keyOf(row));
233
+ compare(label, nameOf(row), expected[field].get(keyOf(row)), row);
234
+ }
235
+ for (const [key, want] of expected[field]) {
236
+ if (!present.has(key)) {throw new CatalogueCeilingError(`the merged images lock lacks the catalogue's ${label} ${nameOf(want)}`);}
237
+ }
238
+ }
239
+ }
240
+
241
+ /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes and returns subjects), the merged lock parsed once. */
190
242
  export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn }) {
191
243
  const vendor = projectVendorImages(catalogue, lock, schemas);
192
244
  const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
@@ -202,13 +254,15 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
202
254
  crossRepoBuildInputs: [],
203
255
  buildStepContextDirectories: [],
204
256
  };
205
- own = (await resolveImages(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock })).imageLock;
206
- if (sealOwn !== undefined) {own = await sealOwn(own);}
257
+ // The own rows are ROWS, not a lock: the one parse is of the merged lock below.
258
+ own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
259
+ // A source that builds no image (a web biome) has no row to seal.
260
+ if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
207
261
  }
208
262
  const seen = new Set(vendorSubjects.map(subjectKey));
209
263
  const clash = own.subjects.find((subject) => seen.has(subjectKey(subject)));
210
264
  if (clash !== undefined) {throw new Error(`the customer's own image ${clash.kind}:${clash.id} collides with a catalogue image of the same identity`);}
211
- return validateImmutableImageLock(
265
+ const merged = validateImmutableImageLock(
212
266
  schemas.parseImageLock({
213
267
  schemaVersion: 3,
214
268
  sourceHashAlgorithm: catalogue.images.sourceHashAlgorithm,
@@ -220,6 +274,8 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
220
274
  }),
221
275
  schemas,
222
276
  );
277
+ assertVendorRowsAreCatalogueRows({ catalogue, imageLock: merged, lock, mirror: composed.mirror, enums: schemas });
278
+ return merged;
223
279
  }
224
280
 
225
281
  /** The kernel at --kernel must carry the contract the catalogue's offering was resolved under. */
@@ -144,7 +144,7 @@ export function lockedServiceProcessFacts(entry) {
144
144
  }
145
145
 
146
146
  if (isEntryModule(import.meta.url)) {
147
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.SourceHash]);
147
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
148
148
  }
149
149
 
150
150
  /**
@@ -24,8 +24,8 @@ import { join } from 'node:path';
24
24
 
25
25
  import { AUTHORIZATION_LOCK_FILENAME } from './authorization-base.mjs';
26
26
  import { fetchCatalogue } from './catalogue-fetch.mjs';
27
+ import { readCataloguePin } from './catalogue-pin.mjs';
27
28
  import {
28
- CATALOGUE_FLAG,
29
29
  CATALOGUE_TOKEN_ENV_FLAG,
30
30
  CATALOGUE_USERNAME_FLAG,
31
31
  CHANNEL_FLAG,
@@ -49,17 +49,17 @@ import { writeResolvedImages } from './resolve-images.mjs';
49
49
  import { withoutSealedDigests } from './seal-image-digests.mjs';
50
50
  import { readStrictJsonFile } from './strict-json.mjs';
51
51
 
52
- // A customer resolve (`--catalogue`) has no vendor editions root: `--out` IS its
52
+ // A customer resolve (`--definition`) has no vendor editions root: `--out` IS its
53
53
  // editions root, the directory its `<id>/` locks are written under.
54
54
  if (isEntryModule(import.meta.url)) {
55
- if (process.argv.includes(CATALOGUE_FLAG)) {
55
+ if (process.argv.includes(DEFINITION_FLAG)) {
56
56
  const at = process.argv.indexOf('--out');
57
- if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${CATALOGUE_FLAG}`);}
57
+ if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${DEFINITION_FLAG}`);}
58
58
  mkdirSync(process.argv[at + 1], { recursive: true });
59
59
  process.argv.splice(at, 1, '--editions-root');
60
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
60
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache], { refuseOthers: true });
61
61
  } else {
62
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
62
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
63
63
  }
64
64
  }
65
65
 
@@ -135,7 +135,7 @@ export async function checkResolvedLockSet(id) {
135
135
  }
136
136
  }
137
137
 
138
- const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG, CATALOGUE_FLAG, CHANNEL_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG];
138
+ const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG, CHANNEL_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG];
139
139
 
140
140
  /** The arguments of a customer resolve (the root flags and `--out` are already consumed). */
141
141
  export function parseCustomerResolveArgs(args) {
@@ -145,12 +145,12 @@ export function parseCustomerResolveArgs(args) {
145
145
  const flag = args[i];
146
146
  const value = args[i + 1];
147
147
  if (![...CUSTOMER_VALUE_FLAGS, SOURCE_IMAGE_FLAG].includes(flag) || value === undefined || value.startsWith('--')) {
148
- throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${CATALOGUE_FLAG}, ${CHANNEL_FLAG}, ${SOURCE_IMAGE_FLAG} take a value)`);
148
+ throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${CHANNEL_FLAG}, ${SOURCE_IMAGE_FLAG} take a value)`);
149
149
  }
150
150
  i += 1;
151
151
  if (flag === SOURCE_IMAGE_FLAG) {sourceImages.push(value);} else if (values.has(flag)) {throw new Error(`${flag} given twice`);} else {values.set(flag, value);}
152
152
  }
153
- for (const required of [DEFINITION_FLAG, CATALOGUE_FLAG, CHANNEL_FLAG]) {
153
+ for (const required of [DEFINITION_FLAG, CHANNEL_FLAG]) {
154
154
  if (!values.has(required)) {throw new Error(`a customer resolve requires ${required}`);}
155
155
  }
156
156
  if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
@@ -162,7 +162,8 @@ export function parseCustomerResolveArgs(args) {
162
162
  async function mainCustomer() {
163
163
  const { values, sourceImages } = parseCustomerResolveArgs(process.argv.slice(2));
164
164
  const { catalogue, digest } = await fetchCatalogue({
165
- ref: values.get(CATALOGUE_FLAG),
165
+ ref: readCataloguePin({ definitionFile: values.get(DEFINITION_FLAG), channel: values.get(CHANNEL_FLAG) }),
166
+ channel: values.get(CHANNEL_FLAG),
166
167
  username: values.get(CATALOGUE_USERNAME_FLAG),
167
168
  tokenEnv: values.get(CATALOGUE_TOKEN_ENV_FLAG),
168
169
  });
@@ -172,7 +173,7 @@ async function mainCustomer() {
172
173
  }
173
174
 
174
175
  async function main() {
175
- if (process.argv.includes(CATALOGUE_FLAG)) {return mainCustomer();}
176
+ if (process.argv.includes(DEFINITION_FLAG)) {return mainCustomer();}
176
177
  const { id, check } = parseResolveEditionArgs(process.argv.slice(2));
177
178
  assertCanonicalDistributionId(id);
178
179
  await (check ? checkResolvedLockSet(id) : writeResolvedLockSet(id));
@@ -68,7 +68,7 @@ import { composeEdition } from './resolve-distribution.mjs';
68
68
  import { readStrictJsonFile } from './strict-json.mjs';
69
69
 
70
70
  if (isEntryModule(import.meta.url)) {
71
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
71
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
72
72
  }
73
73
 
74
74
  const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
@@ -1383,7 +1383,15 @@ function snapshotImageInputs(schemas, { inputs, lock }) {
1383
1383
  };
1384
1384
  }
1385
1385
 
1386
- export async function resolveImages(id, { inputs, lock } = {}) {
1386
+ /**
1387
+ * The image ROWS of one edition: its subjects (hashed at the commits the lock
1388
+ * pins), its platform-service bindings and its bundled web biomes, after every
1389
+ * check that needs no parsed lock. Rows are not a lock: the kernel's image-lock
1390
+ * grammar is the grammar of a COMPLETE lock, and a caller that holds only part
1391
+ * of one (a customer's own rows, merged with the catalogue's) parses the lock
1392
+ * it completes. `resolveImages` is these rows parsed as the whole lock.
1393
+ */
1394
+ export async function resolveImageRows(id, { inputs, lock } = {}) {
1387
1395
  assertCanonicalDistributionId(id);
1388
1396
  if ((inputs === undefined) !== (lock === undefined)) {
1389
1397
  throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
@@ -1509,18 +1517,6 @@ export async function resolveImages(id, { inputs, lock } = {}) {
1509
1517
  ...biome,
1510
1518
  sourceCommit: commitsBefore.get(biome.repo),
1511
1519
  }));
1512
- const imageLock = validateImmutableImageLock(
1513
- schemas.parseImageLock({
1514
- schemaVersion: 3,
1515
- sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1516
- distributionId: id,
1517
- contractAuthority: runtimeSourceIndex.contractAuthority,
1518
- subjects,
1519
- platformServiceBindings: structure.platformServiceBindings,
1520
- webBiomes,
1521
- }),
1522
- schemas,
1523
- );
1524
1520
  // The SAME locator the hash used. The default locator resolves every repo
1525
1521
  // handle to a first-party tree under the aggregator root, so for an edition
1526
1522
  // with an external source it looked for `<sourcesRoot()>/<source name>`, found no
@@ -1533,7 +1529,32 @@ export async function resolveImages(id, { inputs, lock } = {}) {
1533
1529
  'refusing image-lock write: a source repository commit changed while hashing',
1534
1530
  );
1535
1531
  }
1536
- return { imageLock, commits: [...commitsBefore] };
1532
+ return {
1533
+ subjects,
1534
+ platformServiceBindings: structure.platformServiceBindings,
1535
+ webBiomes,
1536
+ contractAuthority: runtimeSourceIndex.contractAuthority,
1537
+ commits: [...commitsBefore],
1538
+ };
1539
+ }
1540
+
1541
+ /** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
1542
+ export async function resolveImages(id, { inputs, lock } = {}) {
1543
+ const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock });
1544
+ const schemas = await loadKernelDistributionSchemas();
1545
+ const imageLock = validateImmutableImageLock(
1546
+ schemas.parseImageLock({
1547
+ schemaVersion: 3,
1548
+ sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1549
+ distributionId: id,
1550
+ contractAuthority,
1551
+ subjects,
1552
+ platformServiceBindings,
1553
+ webBiomes,
1554
+ }),
1555
+ schemas,
1556
+ );
1557
+ return { imageLock, commits };
1537
1558
  }
1538
1559
 
1539
1560
  /**
@@ -114,7 +114,9 @@ import { join } from 'node:path';
114
114
 
115
115
  import { sealLock, verifyBiomeContentHashes } from './biome-content.mjs';
116
116
  import { fetchCatalogue } from './catalogue-fetch.mjs';
117
- import { CATALOGUE_FLAG, CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG } from './customer-flags.mjs';
117
+ import { readCataloguePin } from './catalogue-pin.mjs';
118
+ import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, DEFINITION_FLAG } from './customer-flags.mjs';
119
+ import { EditionChannel } from './edition-constants.mjs';
118
120
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
119
121
  import {
120
122
  assertCoordinatesFinal,
@@ -134,7 +136,20 @@ import {
134
136
  } from './verify-image-platforms.mjs';
135
137
 
136
138
  if (isEntryModule(import.meta.url)) {
137
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
139
+ // The customer form (it names --definition) reads exactly these roots - the data root (the vendor's
140
+ // runtime-image-source-index.json), the editions root, the sources root (the readability check of the
141
+ // vendor repositories), the kernel and the cache (external source trees) - and refuses any other.
142
+ let namesDefinition;
143
+ try {
144
+ namesDefinition = parseSealArgs(process.argv.slice(2)).catalogueFlags.has(DEFINITION_FLAG);
145
+ } catch (error) {
146
+ process.stderr.write(`${error.message}\n`);
147
+ process.exit(1);
148
+ }
149
+ configureEditionWorkspaceFromProcess(
150
+ [WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache],
151
+ { refuseOthers: namesDefinition },
152
+ );
138
153
  }
139
154
 
140
155
  const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/u;
@@ -393,12 +408,19 @@ export function isNotPublishedYet(error) {
393
408
  */
394
409
  export function parseSealArgs(args) {
395
410
  const MODES = new Set(['--check', '--if-published', '--publication-verdict']);
396
- const CATALOGUE_VALUE_FLAGS = new Set([CATALOGUE_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
411
+ const CATALOGUE_VALUE_FLAGS = new Set([DEFINITION_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
397
412
  const positional = [];
398
413
  const catalogueFlags = new Map();
399
414
  for (let i = 0; i < args.length; i += 1) {
400
415
  const arg = args[i];
401
416
  if (MODES.has(arg)) {continue;}
417
+ const eq = arg.indexOf('=');
418
+ if (eq > 0 && CATALOGUE_VALUE_FLAGS.has(arg.slice(0, eq))) {
419
+ const value = arg.slice(eq + 1);
420
+ if (value === '' || value.startsWith('--')) {throw new Error(`${arg.slice(0, eq)} takes a value`);}
421
+ catalogueFlags.set(arg.slice(0, eq), value);
422
+ continue;
423
+ }
402
424
  if (CATALOGUE_VALUE_FLAGS.has(arg)) {
403
425
  if (args[i + 1] === undefined || args[i + 1].startsWith('--')) {throw new Error(`${arg} takes a value`);}
404
426
  catalogueFlags.set(arg, args[i + 1]);
@@ -530,7 +552,7 @@ async function main() {
530
552
  throw new Error(
531
553
  'usage: xema edition seal <distributionId> ' +
532
554
  `[--check | --if-published | --publication-verdict] ` +
533
- `[--catalogue oci://<repository>@sha256:<digest> [--catalogue-username <name> --catalogue-token-env <VAR>]]`,
555
+ `[--definition <file> [--catalogue-username <name> --catalogue-token-env <VAR>]]`,
534
556
  );
535
557
  }
536
558
  // The modes answer different questions and combining them would make the
@@ -621,12 +643,14 @@ async function main() {
621
643
  // `authTokenEnv` and the test reads which variable the refusal names, because
622
644
  // no structural check can tell a threaded argument from an omitted one.
623
645
  const root = editionsRoot();
624
- // CUSTOMER MODE: the digest-pinned catalogue the edition was resolved against.
646
+ // CUSTOMER MODE (`--definition`): the digest-pinned RELEASE catalogue of catalogue.pin.json beside the
647
+ // definition, the one the edition was resolved against.
625
648
  // Its vendor biomes' tree ids are inputs on ITS authority (biome-content.mjs).
626
649
  const fetched =
627
- catalogueFlags.has(CATALOGUE_FLAG)
650
+ catalogueFlags.has(DEFINITION_FLAG)
628
651
  ? await fetchCatalogue({
629
- ref: catalogueFlags.get(CATALOGUE_FLAG),
652
+ ref: readCataloguePin({ definitionFile: catalogueFlags.get(DEFINITION_FLAG), channel: EditionChannel.Release }),
653
+ channel: EditionChannel.Release,
630
654
  username: catalogueFlags.get(CATALOGUE_USERNAME_FLAG),
631
655
  tokenEnv: catalogueFlags.get(CATALOGUE_TOKEN_ENV_FLAG),
632
656
  })
@@ -2,7 +2,7 @@
2
2
  // `xema edition source-closure`: the source tree hash of one directory of a
3
3
  // source repository at an exact commit, with its workspace-package closure.
4
4
  //
5
- // usage: xema edition source-closure --repo <repo> --path <dir> --commit <40 hex> --sources-root <dir> --source-hash <dir> --kernel <dir>
5
+ // usage: xema edition source-closure --repo <repo> --path <dir> --commit <40 hex> --sources-root <dir> --kernel <dir>
6
6
  //
7
7
  // These are the facts a content coordinate is derived from. A consumer frames
8
8
  // them into its own coordinate; it never reads a git tree, a workspace graph
@@ -85,7 +85,6 @@ export function readSourceClosure({ repoDir, repo, directory, commit }) {
85
85
  function main() {
86
86
  const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
87
87
  WorkspaceRoot.Sources,
88
- WorkspaceRoot.SourceHash,
89
88
  // The hash implementation is held to the kernel it was built for.
90
89
  WorkspaceRoot.Kernel,
91
90
  ]);
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // `xema edition arming`: is the data root's declared evidence trust policy ARMED
3
+ // on one channel?
4
+ //
5
+ // usage: xema edition arming --channel <release|develop> --data-root <dir> --kernel <dir>
6
+ // stdout: {"channel":"<channel>","state":"armed|unarmed|partial","unmet":["…"]}
7
+ // exit: 0 only when the state is armed; 1 for any other state (each unmet
8
+ // entry on stderr) or a policy that does not hold to its pin; 2 for a
9
+ // usage error. A data root that declares no policy is `unarmed`.
10
+ // It reads the policy the way `xema edition check` does and never writes.
11
+
12
+ import { EditionChannel } from './edition-channel.mjs';
13
+ import {
14
+ WorkspaceRoot,
15
+ configureEditionWorkspaceFromArgv,
16
+ isEntryModule,
17
+ } from './edition-workspace.mjs';
18
+ import { assessDeclaredTrustPolicyArming } from './evidence-trust-policy.mjs';
19
+ import { TrustPolicyArmingState } from './trust-policy-arming.mjs';
20
+
21
+ export const ARMING_USAGE = `usage: xema edition arming --channel <${Object.values(EditionChannel).join('|')}> --data-root <dir> --kernel <dir>`;
22
+
23
+ /** The process exit codes of the verb. */
24
+ export const ArmingExit = Object.freeze({ Armed: 0, NotArmed: 1, Usage: 2 });
25
+
26
+ /** The channel of `--channel <channel>` among `rest`; anything else is a usage error. */
27
+ export function parseArmingArgs(rest) {
28
+ if (rest.length !== 2 || rest[0] !== '--channel' || !Object.values(EditionChannel).includes(rest[1])) {
29
+ throw new Error(`--channel <${Object.values(EditionChannel).join('|')}> is required and nothing else is accepted (got ${rest.length === 0 ? 'nothing' : JSON.stringify(rest.join(' '))})`);
30
+ }
31
+ return { channel: rest[1] };
32
+ }
33
+
34
+ /**
35
+ * Judge the configured data root's policy on `channel`, writing the one JSON
36
+ * line to `stdout` and each unmet entry to `stderr`. Returns the exit code.
37
+ */
38
+ export async function runArming({ channel, locateVerifier, stdout = process.stdout, stderr = process.stderr }) {
39
+ let verdict;
40
+ try {
41
+ verdict = await assessDeclaredTrustPolicyArming({ channel, locateVerifier });
42
+ } catch (error) {
43
+ stderr.write(`arming: ${error.message}\n`);
44
+ return ArmingExit.NotArmed;
45
+ }
46
+ const { state, unmet } = verdict;
47
+ stdout.write(`${JSON.stringify({ channel, state, unmet })}\n`);
48
+ if (state === TrustPolicyArmingState.ARMED) {return ArmingExit.Armed;}
49
+ for (const entry of unmet) {stderr.write(`${entry}\n`);}
50
+ return ArmingExit.NotArmed;
51
+ }
52
+
53
+ async function main() {
54
+ let channel;
55
+ try {
56
+ const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [WorkspaceRoot.Data, WorkspaceRoot.Kernel]);
57
+ ({ channel } = parseArmingArgs(rest));
58
+ } catch (error) {
59
+ process.stderr.write(`arming: ${error.message}\n${ARMING_USAGE}\n`);
60
+ process.exitCode = ArmingExit.Usage;
61
+ return;
62
+ }
63
+ process.exitCode = await runArming({ channel });
64
+ }
65
+
66
+ if (isEntryModule(import.meta.url)) {
67
+ await main();
68
+ }