@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
@@ -1,13 +1,18 @@
1
1
  // git-source-auth — the ONE place the edition library turns a read token into git
2
2
  // authentication for a repository URL, shared by every script that fetches or
3
- // lists a repository (first-party sources and a customer's own git source alike).
3
+ // lists a repository (first-party and customer sources alike).
4
4
  //
5
5
  // The credential reaches git through GIT_CONFIG_* environment variables, never
6
6
  // argv, a file or a clone's config, and it is scoped to the ORIGIN of the URL it
7
7
  // is for: `http.<origin>/.extraHeader` is applied by git only to URLs under that
8
- // origin, so the token is not sent to any other host, nor to a host a redirect
9
- // leads to. For a first-party source pass `sourceRepositoryUrlPrefix` (from
10
- // edition-workspace.json); for a customer git source pass its own url.
8
+ // origin, and redirects are not followed for a credentialed fetch. Source
9
+ // credentials are declared only by the data root's edition-workspace.json
10
+ // (`sourceRepositoryUrlPrefix` + `sourceAuthUsername`), the token comes from
11
+ // SOURCE_READ_TOKEN_ENV, and `holdSource` / `withSourceGitEnv` are the hold and the
12
+ // environment every network git call on a source passes: a definition never names
13
+ // a credential, a URL outside the prefix is fetched with none, and git runs
14
+ // isolated from the user's own git configuration (global and system config,
15
+ // ~/.netrc, credential helpers, URL rewrites).
11
16
  //
12
17
  // Why this is not `gitEnvWithAuth` of the CLI's runtime (src/runtime/sources/kinds/git.ts):
13
18
  // the edition library is plain ESM that `node` runs straight from this directory
@@ -16,37 +21,111 @@
16
21
  // on purpose (Basic `<declared username>:<token>`, `Authorization` header, origin
17
22
  // scope; the username is a required argument, never a built-in default), so there is exactly ONE implementation per runtime.
18
23
 
24
+ import { mkdtempSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
29
+
30
+ /** The fixed environment variable holding the source read token. */
31
+ export const SOURCE_READ_TOKEN_ENV = 'EVIDENCE_REPOSITORIES_TOKEN';
32
+
33
+ /** Whether `canonicalUrl` names a repository under the workspace's `sourceRepositoryUrlPrefix`. */
34
+ export function isUnderSourcePrefix(canonicalUrl, prefix) {
35
+ return canonicalUrl.startsWith(prefix) && canonicalUrl.length > prefix.length;
36
+ }
37
+
38
+ /** A definition does not declare source credentials; the data root does. */
39
+ export function assertSourceDeclaresNoCredential(source) {
40
+ for (const key of ['authTokenEnv', 'authUsername']) {
41
+ if (source[key] !== undefined) {
42
+ throw new Error(
43
+ `source "${source.name}" declares "${key}". A definition does not declare source credentials; ` +
44
+ 'the data root\'s edition-workspace.json does (`sourceRepositoryUrlPrefix` + `sourceAuthUsername`, ' +
45
+ `token from ${SOURCE_READ_TOKEN_ENV}). Remove the key.`,
46
+ );
47
+ }
48
+ }
49
+ }
50
+
51
+ /** The source's url, which must already be written in its canonical public https form. */
52
+ export function assertCanonicalSourceUrl(source) {
53
+ const context = `source "${source.name}" url`;
54
+ const canonical = canonicalPublicHttpsRepositoryUrl(source.url, context);
55
+ if (canonical !== source.url) {
56
+ throw new Error(`${context} ${JSON.stringify(source.url)} is not in its canonical form (${canonical})`);
57
+ }
58
+ return canonical;
59
+ }
60
+
61
+ const INHERITED_GIT_VARIABLES = /^(GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|PARAMETERS|GLOBAL|NOSYSTEM)|GIT_ASKPASS|SSH_ASKPASS|GIT_SSL_NO_VERIFY|HOME|XDG_CONFIG_HOME)$/;
62
+
19
63
  /**
20
- * `base` plus the GIT_CONFIG_* header that authenticates git against `url`'s
21
- * origin as `username` with `token`. A token that cannot be applied is refused: an empty
22
- * token, or a URL that is not an absolute https URL with a host. There is no
23
- * fall-back to the ambient credential helper.
64
+ * THE HOLD, pure: refuses a source that declares a credential or whose URL is not canonical, and
65
+ * decides whether the workspace credential covers it. A covered source needs the token in
66
+ * SOURCE_READ_TOKEN_ENV (unset is refused, never replaced by an ambient credential). Spawns nothing.
24
67
  */
25
- export function gitAuthEnv(url, username, token, base = process.env) {
68
+ export function holdSource(source, workspace, env = process.env) {
69
+ assertSourceDeclaresNoCredential(source);
70
+ const url = assertCanonicalSourceUrl(source);
71
+ if (!isUnderSourcePrefix(url, workspace.sourceRepositoryUrlPrefix)) {return { url, credential: null };}
72
+ const token = env[SOURCE_READ_TOKEN_ENV];
73
+ if (typeof token !== 'string' || token.length === 0) {
74
+ throw new Error(
75
+ `source "${source.name}" is under the workspace's sourceRepositoryUrlPrefix ${workspace.sourceRepositoryUrlPrefix}, ` +
76
+ `which needs a read token, but ${SOURCE_READ_TOKEN_ENV} is unset or empty`,
77
+ );
78
+ }
79
+ const username = workspace.sourceAuthUsername;
26
80
  if (typeof username !== 'string' || username.length === 0 || username.includes(':')) {
27
81
  throw new Error(
28
- `a git username is required to authenticate git against ${String(url)} (non-empty, no ":"); ` +
29
- 'it is declared, never defaulted: `sourceAuthUsername` in edition-workspace.json for first-party sources, ' +
30
- 'the source\'s own `authUsername` for a customer git source',
82
+ `a git username is required to authenticate git against ${url} (non-empty, no ":"); ` +
83
+ 'it is declared, never defaulted: `sourceAuthUsername` in edition-workspace.json',
31
84
  );
32
85
  }
33
- if (typeof token !== 'string' || token.length === 0) {
34
- throw new Error(`a read token is required to authenticate git against ${String(url)}`);
86
+ return { url, credential: { origin: new URL(url).origin, username, token } };
87
+ }
88
+
89
+ function isolatedGitEnv({ credential }, env, home) {
90
+ const base = {};
91
+ for (const [key, value] of Object.entries(env)) {
92
+ if (!INHERITED_GIT_VARIABLES.test(key)) {base[key] = value;}
35
93
  }
36
- let origin;
37
- try {
38
- const parsed = new URL(url);
39
- if (parsed.protocol !== 'https:' || parsed.hostname === '') {throw new Error('not an https URL with a host');}
40
- origin = parsed.origin;
41
- } catch {
42
- throw new Error(`cannot scope a git credential to "${String(url)}": it is not an absolute https URL with a host`);
94
+ const entries = [
95
+ ['credential.helper', ''],
96
+ ['http.extraHeader', ''],
97
+ ];
98
+ if (credential !== null) {
99
+ const basic = Buffer.from(`${credential.username}:${credential.token}`, 'utf8').toString('base64');
100
+ entries.push(['http.followRedirects', 'false'], [`http.${credential.origin}/.extraHeader`, `Authorization: Basic ${basic}`]);
43
101
  }
44
- const credential = Buffer.from(`${username}:${token}`, 'utf8').toString('base64');
102
+ const config = { GIT_CONFIG_COUNT: String(entries.length) };
103
+ entries.forEach(([key, value], index) => {
104
+ config[`GIT_CONFIG_KEY_${index}`] = key;
105
+ config[`GIT_CONFIG_VALUE_${index}`] = value;
106
+ });
45
107
  return {
46
108
  ...base,
47
- GIT_CONFIG_COUNT: '1',
48
- GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
49
- GIT_CONFIG_VALUE_0: `Authorization: Basic ${credential}`,
109
+ ...config,
110
+ GIT_CONFIG_GLOBAL: '/dev/null',
111
+ GIT_CONFIG_NOSYSTEM: '1',
112
+ HOME: home,
113
+ XDG_CONFIG_HOME: home,
50
114
  GIT_TERMINAL_PROMPT: '0',
51
115
  };
52
116
  }
117
+
118
+ /**
119
+ * Run `fn(gitEnv)` with the git environment for one declared source; the hold comes first, before
120
+ * anything is created or spawned. The environment points HOME at an empty directory that exists
121
+ * only for the duration of `fn`.
122
+ */
123
+ export function withSourceGitEnv(source, { workspace, env = process.env }, fn) {
124
+ const decision = holdSource(source, workspace, env);
125
+ const home = mkdtempSync(join(tmpdir(), 'xema-git-home-'));
126
+ try {
127
+ return fn(isolatedGitEnv(decision, env, home));
128
+ } finally {
129
+ rmSync(home, { recursive: true, force: true });
130
+ }
131
+ }
@@ -0,0 +1,106 @@
1
+ // `entitlement.json`: the file `xema edition evidence statement entitlement
2
+ // accept` writes beside the signed statement, and the ONE reader of it
3
+ // (`verify-evidence --entitlement`). Writer and reader share this module so the
4
+ // shape is stated once.
5
+ //
6
+ // Every field but `evidence` is an UNAUTHENTICATED convenience for the operator
7
+ // and for `verify-evidence`'s early refusals: the signed statement stays the
8
+ // authority (`validateEntitlementStatementBinding`). The file names no workflow
9
+ // SHA: the commit the entitlement's certificate is held to is stated by the
10
+ // caller of the verifier (`--entitlement-workflow-sha`), never by the evidence.
11
+
12
+ import { dirname, join } from 'node:path';
13
+
14
+ export const ENTITLEMENT_DESCRIPTOR_FILE = 'entitlement.json';
15
+ export const ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION = 2;
16
+
17
+ const ENTITLEMENT_DESCRIPTOR_KEYS = Object.freeze([
18
+ 'schemaVersion',
19
+ 'distributionId',
20
+ 'customerId',
21
+ 'mode',
22
+ 'targetRegistry',
23
+ 'idempotencyKey',
24
+ 'distributionStatementDigest',
25
+ 'evidence',
26
+ ]);
27
+
28
+ /** The document `accept` writes. `evidence` is the kernel Sigstore evidence descriptor of the signed statement. */
29
+ export function entitlementDescriptorDocument({
30
+ distributionId,
31
+ customerId,
32
+ mode,
33
+ targetRegistry,
34
+ idempotencyKey,
35
+ distributionStatementDigest,
36
+ evidence,
37
+ }) {
38
+ return {
39
+ schemaVersion: ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION,
40
+ distributionId,
41
+ customerId,
42
+ mode,
43
+ targetRegistry,
44
+ idempotencyKey,
45
+ distributionStatementDigest,
46
+ evidence,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Parse an `entitlement.json`: exactly the keys `accept` writes, at the one
52
+ * schema version, with `evidence` parsed by the kernel's Sigstore evidence
53
+ * descriptor schema. Any other shape is refused.
54
+ */
55
+ export function parseEntitlementDescriptorDocument(raw, schemas) {
56
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
57
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} must be a JSON object`);
58
+ }
59
+ if (raw.schemaVersion !== ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION) {
60
+ throw new Error(
61
+ `${ENTITLEMENT_DESCRIPTOR_FILE} has schemaVersion ${JSON.stringify(raw.schemaVersion)}; ` +
62
+ `this verifier reads schemaVersion ${ENTITLEMENT_DESCRIPTOR_SCHEMA_VERSION} only (accept the entitlement again with this CLI)`,
63
+ );
64
+ }
65
+ const keys = Object.keys(raw).sort();
66
+ const expected = [...ENTITLEMENT_DESCRIPTOR_KEYS].sort();
67
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
68
+ throw new Error(
69
+ `${ENTITLEMENT_DESCRIPTOR_FILE} must hold exactly the keys ${ENTITLEMENT_DESCRIPTOR_KEYS.join(', ')}; it holds ${keys.join(', ')}`,
70
+ );
71
+ }
72
+ for (const key of ['distributionId', 'customerId', 'mode', 'idempotencyKey', 'distributionStatementDigest']) {
73
+ if (typeof raw[key] !== 'string' || raw[key] === '') {
74
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} "${key}" must be a non-empty string`);
75
+ }
76
+ }
77
+ if (raw.targetRegistry !== null && (typeof raw.targetRegistry !== 'string' || raw.targetRegistry === '')) {
78
+ throw new Error(`${ENTITLEMENT_DESCRIPTOR_FILE} "targetRegistry" must be null or a non-empty string`);
79
+ }
80
+ return { ...raw, evidence: schemas.SigstoreEvidenceDescriptorSchema.parse(raw.evidence) };
81
+ }
82
+
83
+ /**
84
+ * Refuse an `entitlement.json` that states another request than the one being
85
+ * verified. `expected` holds the caller's flags and the completeness statement
86
+ * digest the verifier has just verified.
87
+ */
88
+ export function assertEntitlementDescriptorStates(document, expected) {
89
+ for (const key of ['distributionId', 'customerId', 'mode', 'targetRegistry', 'distributionStatementDigest']) {
90
+ if (document[key] !== expected[key]) {
91
+ throw new Error(
92
+ `${ENTITLEMENT_DESCRIPTOR_FILE} states ${key} ${JSON.stringify(document[key])}, ` +
93
+ `not the ${JSON.stringify(expected[key])} this verification was asked for`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The evidence descriptor's paths are relative to the directory `accept` sealed
101
+ * (the one holding `entitlement.json`), which lies inside the evidence root the
102
+ * descriptor file itself was read from.
103
+ */
104
+ export function entitlementEvidenceRoot(evidenceRoot, entitlementDescriptorPath) {
105
+ return dirname(join(evidenceRoot, entitlementDescriptorPath));
106
+ }
@@ -600,9 +600,27 @@ function assertEvidenceChannel(channel) {
600
600
  }
601
601
 
602
602
  /**
603
- * The signers a policy authorizes for one predicate on ONE channel. Producer
604
- * evidence is further bound to the exact image repository the subject's
605
- * imageRef names (no prefix match).
603
+ * The policy rows that authorize `predicateType` on `channel`: a filter on the
604
+ * row's `channel` and `predicateTypes`, and, when `imageRepository` is given
605
+ * (producer evidence), on the row's `imageRepositories` holding that exact
606
+ * repository (no prefix match). It reads no certificate and proves nothing by
607
+ * itself.
608
+ *
609
+ * What ties a row to its channel is the kernel policy schema the policy was
610
+ * parsed by: a row's `githubWorkflow.ref` must be the ref of its `channel`
611
+ * (`EVIDENCE_CHANNEL_REFS`) and its `certificateIdentity` must be exactly
612
+ * `https://github.com/<repository>/<path>@<ref>`. One ROW is therefore one
613
+ * channel; the same workflow file may still be authorized on both channels, as
614
+ * two rows naming the two refs. The caller then runs cosign once per returned
615
+ * row with that row's `certificateIdentity`, `oidcIssuer`, workflow repository
616
+ * and ref (and the exact workflow commit), so evidence signed from the other
617
+ * channel's ref matches no row selected here and fails verification; an
618
+ * edition that states a channel with no row for the predicate is refused
619
+ * before cosign runs ("authorizes no signer").
620
+ *
621
+ * The channel an edition DESCRIPTOR states is unauthenticated input: it only
622
+ * selects which policy rows are tried; what authenticates a channel is the
623
+ * certificate matching a row of that channel.
606
624
  */
607
625
  function policyCandidates(policy, predicateType, channel, imageRepository) {
608
626
  return policy.policies.filter(
@@ -291,11 +291,33 @@ export function assertExactCleanKernelContractCheckout({
291
291
  return head;
292
292
  }
293
293
 
294
+ /** The flag by which a caller STATES the kernel commit a new contract authority is taken from. */
295
+ export const KERNEL_COMMIT_FLAG = '--kernel-commit';
296
+
297
+ /**
298
+ * The contract authority of the kernel checkout, held to the commit the caller
299
+ * STATES. A clean checkout at the kernel's origin URL proves only which
300
+ * repository the directory claims to be, never which commit somebody meant to
301
+ * pin: `kernelCommit` is that decision, it is required, and the checkout's HEAD
302
+ * must equal it. It is the `sourceCommit` the authority records.
303
+ */
294
304
  export async function buildTrustedKernelContractAuthority({
295
305
  repositoryDirectory = kernelRepoDir(),
296
306
  schemas,
307
+ kernelCommit,
297
308
  }) {
298
- const sourceCommit = exactHeadCommit(repositoryDirectory);
309
+ if (typeof kernelCommit !== 'string' || !COMMIT_PATTERN.test(kernelCommit)) {
310
+ throw new Error(
311
+ `${KERNEL_COMMIT_FLAG} <40-hex> is required: a new kernel contract authority is pinned to the commit the caller states, never to whatever the checkout happens to hold`,
312
+ );
313
+ }
314
+ const head = exactHeadCommit(repositoryDirectory);
315
+ if (head !== kernelCommit) {
316
+ throw new Error(
317
+ `kernel contract repository HEAD ${head} is not the stated ${KERNEL_COMMIT_FLAG} ${kernelCommit}: check out the stated commit, or state the commit you mean to pin`,
318
+ );
319
+ }
320
+ const sourceCommit = kernelCommit;
299
321
  assertExactCleanKernelContractCheckout({
300
322
  repositoryDirectory,
301
323
  sourceCommit,
package/edition/lib.mjs CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  kernelDistPath,
25
25
  kernelPackageDir,
26
26
  kernelRepoDir,
27
- sourceHashPackageDir,
28
27
  sourcePath,
29
28
  } from './edition-workspace.mjs';
30
29
  import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
@@ -149,38 +148,28 @@ export const BIOME_WORKLOAD_KINDS = new Set(['service', 'runtime-component']);
149
148
  export const DEFAULT_BIOME_WORKLOAD_KIND = 'service';
150
149
 
151
150
  /**
152
- * The BUILT `@xemahq/distribution-source-hash` package (CJS dist), loaded from
153
- * the directory the entry states with `--source-hash`.
151
+ * The `@xemahq/distribution-source-hash` package (CJS dist), a caret (0.x minor-locked)
152
+ * dependency of the CLI, loaded by name on first use.
154
153
  *
155
154
  * That package is the SINGLE owner of `xema-source-tree-sha256-v2` — the same
156
155
  * implementation every producing repository's CI uses to derive the
157
156
  * `<contentHash>` release tag. This tooling must never carry a second copy: a
158
157
  * divergence would put a lock and its images at coordinates that silently
159
- * disagree. It is NOT an npm dependency of the CLI: the evidence verifier never
160
- * hashes a source tree, so its bundle must not reach this package; only the
161
- * entries that resolve, build, index or seal state the root.
158
+ * disagree. The evidence verifier never hashes a source tree, so its bundle
159
+ * does not reach this package; only the entries that resolve, build, index or
160
+ * seal load it.
162
161
  */
163
162
  const requireFromLib = createRequire(import.meta.url);
164
163
 
165
- const loadedSourceHash = new Map();
164
+ let loadedSourceHash;
166
165
 
167
166
  function distributionSourceHash() {
168
- const directory = sourceHashPackageDir();
169
- const known = loadedSourceHash.get(directory);
170
- if (known) {return known;}
171
- let mod;
172
- try {
173
- mod = createRequire(join(directory, 'package.json'))(directory);
174
- } catch (err) {
175
- throw new Error(
176
- `Cannot load the built distribution-source-hash package from ${directory}. ` +
177
- `Build it first (\`pnpm install --frozen-lockfile && pnpm run build\` in its workspace). ` +
178
- `Underlying error: ${err.message}`,
179
- );
167
+ if (loadedSourceHash === undefined) {
168
+ const mod = requireFromLib('@xemahq/distribution-source-hash');
169
+ assertSourceHashMatchesKernel(mod);
170
+ loadedSourceHash = mod;
180
171
  }
181
- assertSourceHashMatchesKernel(mod);
182
- loadedSourceHash.set(directory, mod);
183
- return mod;
172
+ return loadedSourceHash;
184
173
  }
185
174
 
186
175
  /**
@@ -214,9 +203,9 @@ function requireAlgorithmMember(enumObject, enumName, member) {
214
203
  const value = enumObject?.[member];
215
204
  if (typeof value !== 'string' || value.length === 0) {
216
205
  throw new Error(
217
- `Built @xemahq/distribution-source-hash does not declare ` +
206
+ `@xemahq/distribution-source-hash does not declare ` +
218
207
  `${enumName}.${member}. The installed package is `+
219
- `stale or the member was renamed; raise the @xemahq/distribution-source-hash range and, if the `+
208
+ `stale or the member was renamed; change the pinned @xemahq/distribution-source-hash version and, if the `+
220
209
  `rename was deliberate, move every reader in the same change.`,
221
210
  );
222
211
  }
@@ -722,6 +711,7 @@ export async function loadKernelDistributionSchemas() {
722
711
  parseDistributionImageEvidenceSet,
723
712
  parseDistributionImageReplayState,
724
713
  parseSigstoreOfflineTrustPolicy,
714
+ SigstoreEvidenceDescriptorSchema,
725
715
  parsePlatformServiceSourceIndex,
726
716
  parseRuntimeImageSourceIndex,
727
717
  resolveRuntimeImagePlatforms,
@@ -798,6 +788,7 @@ export async function loadKernelDistributionSchemas() {
798
788
  typeof parseDistributionImageEvidenceSet !== 'function' ||
799
789
  typeof parseDistributionImageReplayState !== 'function' ||
800
790
  typeof parseSigstoreOfflineTrustPolicy !== 'function' ||
791
+ typeof SigstoreEvidenceDescriptorSchema?.parse !== 'function' ||
801
792
  typeof parsePlatformServiceSourceIndex !== 'function' ||
802
793
  typeof parseRuntimeImageSourceIndex !== 'function' ||
803
794
  typeof resolveRuntimeImagePlatforms !== 'function' ||
@@ -892,6 +883,7 @@ export async function loadKernelDistributionSchemas() {
892
883
  parseDistributionImageEvidenceSet,
893
884
  parseDistributionImageReplayState,
894
885
  parseSigstoreOfflineTrustPolicy,
886
+ SigstoreEvidenceDescriptorSchema,
895
887
  parsePlatformServiceSourceIndex,
896
888
  parseRuntimeImageSourceIndex,
897
889
  resolveRuntimeImagePlatforms,
package/edition/oras.mjs CHANGED
@@ -173,7 +173,7 @@ function assertOrasVersion(bin, home) {
173
173
  /**
174
174
  * The oras digests the configured data root trusts: `trustedOrasBinaryDigests`
175
175
  * of its evidence-trust pin. `null` when the data root declares no evidence
176
- * trust at all (`evidenceTrustPolicy: {"required": false}` with no policy):
176
+ * trust at all (`evidenceTrustPolicy: null`):
177
177
  * such a data root has no reviewed pin to hold a binary to.
178
178
  */
179
179
  export function configuredOrasTrust() {
@@ -65,13 +65,18 @@ export function sourceNameOfPinKey(key) {
65
65
  return key.slice(SOURCE_PIN_PREFIX.length);
66
66
  }
67
67
 
68
+ /** Whether `key` is the snapshot key of a declared source (`sources/<name>`); pure, it reads no workspace section. */
69
+ export function isSourcePinKey(key) {
70
+ return key.startsWith(SOURCE_PIN_PREFIX) && key.length > SOURCE_PIN_PREFIX.length;
71
+ }
72
+
68
73
  /** Which closed namespace a snapshot key belongs to; refuses a key in none. */
69
74
  export function snapshotPinNamespace(key) {
70
75
  if (key === dataPinKey()) {return SnapshotPinNamespace.Distributions;}
71
76
  if (key === kernelPinKey()) {return SnapshotPinNamespace.Kernel;}
72
77
  if (key === deployPinKey()) {return SnapshotPinNamespace.ChartSource;}
73
78
  if (key === EDITIONS_ROOT_PIN_KEY) {return SnapshotPinNamespace.EditionsRoot;}
74
- if (key.startsWith(SOURCE_PIN_PREFIX) && key.length > SOURCE_PIN_PREFIX.length) {
79
+ if (isSourcePinKey(key)) {
75
80
  return SnapshotPinNamespace.Source;
76
81
  }
77
82
  if (GRAPH_PIN_PATTERN.test(key)) {return SnapshotPinNamespace.Graph;}
@@ -28,6 +28,7 @@ import {
28
28
  dataRoot,
29
29
  isEntryModule,
30
30
  } from './edition-workspace.mjs';
31
+ import { ENTITLEMENT_DESCRIPTOR_FILE, entitlementDescriptorDocument } from './image-delivery-entitlement-descriptor.mjs';
31
32
  import { canonicalJsonBytes, readCanonicalJsonFromRoot } from './image-evidence.mjs';
32
33
  import { serializeJson } from './lib.mjs';
33
34
  import { githubWorkflowSigner } from './produce-distribution-completeness-evidence.mjs';
@@ -38,7 +39,6 @@ import {
38
39
  } from './produce-image-source-evidence.mjs';
39
40
  import { assertStagedExactly, reserveSealing, sealAccepted } from './sealed-evidence-accept.mjs';
40
41
 
41
- const ENTITLEMENT_DESCRIPTOR_FILE = 'entitlement.json';
42
42
  const ENTITLEMENT_STATEMENT_PATH = 'entitlement/statement.json';
43
43
  const ENTITLEMENT_BUNDLE_PATH = 'entitlement/bundle.sigstore.json';
44
44
 
@@ -176,17 +176,17 @@ export function acceptImageDeliveryEntitlementEvidence(options) {
176
176
  const descriptor = acceptSignedEvidenceStatement({ ...signing, sealingRoot: sealing, runCosign });
177
177
  writeFileSync(
178
178
  join(sealing, ENTITLEMENT_DESCRIPTOR_FILE),
179
- canonicalJsonBytes({
180
- schemaVersion: 1,
181
- distributionId: options.distributionId,
182
- customerId: options.customerId,
183
- mode: options.mode,
184
- targetRegistry: options.targetRegistry,
185
- idempotencyKey: options.idempotencyKey,
186
- distributionStatementDigest: completenessStatementDigest,
187
- signingWorkflowSha: options.signingWorkflowSha,
188
- evidence: descriptor,
189
- }),
179
+ canonicalJsonBytes(
180
+ entitlementDescriptorDocument({
181
+ distributionId: options.distributionId,
182
+ customerId: options.customerId,
183
+ mode: options.mode,
184
+ targetRegistry: options.targetRegistry,
185
+ idempotencyKey: options.idempotencyKey,
186
+ distributionStatementDigest: completenessStatementDigest,
187
+ evidence: descriptor,
188
+ }),
189
+ ),
190
190
  { flag: 'wx', mode: 0o644 },
191
191
  );
192
192
  return { outputDirectory, statement, descriptor, completenessStatementDigest };
@@ -117,7 +117,17 @@ export function loadRegistryCredentialDeclarations(
117
117
  distributionsRoot = dataRoot(),
118
118
  ) {
119
119
  const path = join(distributionsRoot, REGISTRY_CREDENTIALS_FILE);
120
- const raw = JSON.parse(readFileSync(path, 'utf8'));
120
+ let text;
121
+ try {
122
+ text = readFileSync(path, 'utf8');
123
+ } catch (error) {
124
+ if (error?.code !== 'ENOENT') {throw error;}
125
+ throw new Error(
126
+ `${REGISTRY_CREDENTIALS_FILE} does not exist in ${distributionsRoot}: the editions root declares the registry credentials its images are verified with`,
127
+ { cause: error },
128
+ );
129
+ }
130
+ const raw = JSON.parse(text);
121
131
  const keys = Object.keys(raw ?? {}).sort().join(',');
122
132
  if (
123
133
  typeof raw !== 'object' ||
@@ -60,8 +60,18 @@ export function assertReleaseEditionTree(dir) {
60
60
  */
61
61
  export const RELEASE_COMPANION_FILES = Object.freeze([EDITION_CHART_SET_FILE]);
62
62
 
63
+ /**
64
+ * The release locks an edition directory holds (the distribution and the
65
+ * authorization lock). A directory holding either IS a release edition: a
66
+ * develop resolve writes nothing into an editions tree, so a lock committed
67
+ * there was written by the release resolve.
68
+ */
69
+ export function heldReleaseLocks(dir) {
70
+ return [EditionLockFile.Distribution, EditionLockFile.Authorization].filter((file) => existsSync(join(dir, file)));
71
+ }
72
+
63
73
  function assertReleaseLockSet(dir) {
64
- const held = [EditionLockFile.Distribution, EditionLockFile.Authorization].filter((file) => existsSync(join(dir, file)));
74
+ const held = heldReleaseLocks(dir);
65
75
  if (held.length === 0) {return;}
66
76
  const missing = RELEASE_COMPANION_FILES.filter((file) => !existsSync(join(dir, file)));
67
77
  if (missing.length > 0) {
@@ -2,12 +2,12 @@
2
2
  // release-edition-artifact — write the OCI artifact of a RELEASE edition from
3
3
  // the committed, sealed locks of the editions root.
4
4
  //
5
- // artifact <id> --channel release --registry <namespace> --coordinate <tag>
5
+ // artifact <id> --channel release --registry <root> --coordinate <tag>
6
6
  // --engine-image <repository>@sha256:<digest> --out <dir> [--local]
7
7
  //
8
8
  // The artifact has the layout of every edition (edition-artifact.mjs): the
9
9
  // descriptor plus the four locks and the offering's catalogue.json, written to <dir>, and the target
10
- // `<registry>/xema-edition-<id>:<coordinate>` is printed. The workflow pushes
10
+ // `<root>/<release namespace segment>/xema-edition-<id>:<coordinate>` is printed. The workflow pushes
11
11
  // <dir> with oras and signs the manifest DIGEST with cosign; the tag is for
12
12
  // humans. A lock set that is not sealed is refused: an unsealed images lock names
13
13
  // no image digests, so nothing could be admitted against it.
@@ -128,13 +128,12 @@ export async function readCommittedEdition(id, root = editionsRoot()) {
128
128
 
129
129
  async function run(argv, env) {
130
130
  const { id, options } = parseReleaseArtifactArgs(argv);
131
- const target = editionTarget(id, options['--registry'], options['--coordinate']);
131
+ const target = editionTarget(id, options['--registry'], EditionChannel.Release, options['--coordinate']);
132
132
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
133
133
  const locks = await readCommittedEdition(id);
134
+ // Refuses (EvidenceTrustRequiredError) unless the policy is ARMED on the
135
+ // release channel: a release artifact is never written not-armed.
134
136
  const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel: EditionChannel.Release });
135
- if (evidenceTrustPolicy === 'not-armed') {
136
- console.warn('[edition-artifact] evidence trust policy: NOT ARMED — the descriptor records not-armed.');
137
- }
138
137
  const outDir = resolve(options['--out']);
139
138
  writeEditionArtifact({
140
139
  outDir,