@xemahq/xema 0.28.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 (54) 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/edition/verify-image-evidence.bundle.mjs +88 -11
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
  6. package/dist/runtime/edition/verb-map.d.ts +2 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +2 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  11. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  12. package/dist/runtime/runner/chart-set-lock.js +39 -4
  13. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  14. package/edition/README.md +69 -32
  15. package/edition/build-biome-index.mjs +1 -1
  16. package/edition/build-platform-service-index.mjs +1 -1
  17. package/edition/build-runtime-image-source-index.mjs +64 -20
  18. package/edition/catalogue-fetch.mjs +36 -9
  19. package/edition/catalogue-pin.mjs +71 -0
  20. package/edition/catalogue.mjs +11 -0
  21. package/edition/chart-set-source-commit.mjs +17 -7
  22. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  23. package/edition/customer-flags.mjs +0 -1
  24. package/edition/develop-customer-edition.mjs +23 -5
  25. package/edition/develop-edition.mjs +147 -48
  26. package/edition/develop-freeze.mjs +69 -25
  27. package/edition/develop-seal.mjs +39 -25
  28. package/edition/dist-check.mjs +1 -1
  29. package/edition/edition-artifact.mjs +65 -18
  30. package/edition/edition-biome-manifests.mjs +2 -3
  31. package/edition/edition-chart-set.mjs +59 -6
  32. package/edition/edition-config.mjs +28 -2
  33. package/edition/edition-constants.mjs +10 -4
  34. package/edition/edition-contract-parse.mjs +52 -0
  35. package/edition/edition-contract.json +10 -1
  36. package/edition/edition-workspace.mjs +12 -9
  37. package/edition/evidence-trust-policy.mjs +26 -0
  38. package/edition/external-source-walk.mjs +48 -61
  39. package/edition/git-source-auth.mjs +104 -25
  40. package/edition/image-evidence.mjs +20 -10
  41. package/edition/kernel-contract-authority.mjs +23 -1
  42. package/edition/lib.mjs +13 -24
  43. package/edition/pin-source.mjs +6 -1
  44. package/edition/registry-credentials.mjs +11 -1
  45. package/edition/release-edition-artifact.mjs +3 -3
  46. package/edition/resolve-chart-set.mjs +50 -30
  47. package/edition/resolve-customer-edition.mjs +63 -7
  48. package/edition/resolve-distribution.mjs +1 -1
  49. package/edition/resolve-edition.mjs +12 -11
  50. package/edition/resolve-images.mjs +36 -15
  51. package/edition/seal-image-digests.mjs +31 -7
  52. package/edition/source-closure.mjs +1 -2
  53. package/edition/trust-policy-arming-verb.mjs +68 -0
  54. package/package.json +3 -3
@@ -0,0 +1,52 @@
1
+ // edition-contract-parse — the strict parse of edition-contract.json. A consumer
2
+ // that vendors the contract holds it to this same shape: a contract that is not
3
+ // v2, or lacks `repositories`, or names a channel this CLI does not know, is
4
+ // refused rather than read with a default.
5
+
6
+ import { EditionChannel } from './edition-channel.mjs';
7
+
8
+ export const EDITION_CONTRACT_SCHEMA = 'xema.edition-contract/v2';
9
+
10
+ const SEGMENT = /^[a-z0-9][a-z0-9.-]*$/;
11
+
12
+ /** The strict parse of the contract; returns it unchanged. */
13
+ export function parseEditionContract(contract) {
14
+ if (contract === null || typeof contract !== 'object' || Array.isArray(contract)) {
15
+ throw new Error('edition-contract.json must be a JSON object');
16
+ }
17
+ if (contract.schema !== EDITION_CONTRACT_SCHEMA) {
18
+ throw new Error(`edition-contract.json: schema is ${JSON.stringify(contract.schema)}, expected ${JSON.stringify(EDITION_CONTRACT_SCHEMA)}`);
19
+ }
20
+ if (typeof contract.vendoring !== 'string' || contract.vendoring === '') {
21
+ throw new Error('edition-contract.json: "vendoring" must be the non-empty statement of how a consumer vendors this file');
22
+ }
23
+ if (typeof contract.repositoryPrefix !== 'string' || contract.repositoryPrefix === '') {
24
+ throw new Error('edition-contract.json: "repositoryPrefix" must be a non-empty string');
25
+ }
26
+ const repositories = contract.repositories;
27
+ if (repositories === null || typeof repositories !== 'object' || Array.isArray(repositories)) {
28
+ throw new Error('edition-contract.json has no "repositories": the namespace segment of each channel is read from it and nowhere else');
29
+ }
30
+ const channels = Object.values(EditionChannel);
31
+ const keys = Object.keys(repositories);
32
+ const unknown = keys.filter((key) => !channels.includes(key));
33
+ if (unknown.length > 0 || keys.length !== channels.length) {
34
+ throw new Error(
35
+ `edition-contract.json: "repositories" is exactly one entry per channel (${channels.join(' | ')}); ` +
36
+ (unknown.length > 0 ? `unknown channel ${unknown.map((key) => JSON.stringify(key)).join(', ')}` : `got ${keys.join(', ') || 'none'}`),
37
+ );
38
+ }
39
+ const segments = new Set();
40
+ for (const channel of channels) {
41
+ const entry = repositories[channel];
42
+ const entryKeys = entry !== null && typeof entry === 'object' && !Array.isArray(entry) ? Object.keys(entry) : [];
43
+ if (entryKeys.join() !== 'namespaceSegment' || typeof entry.namespaceSegment !== 'string' || !SEGMENT.test(entry.namespaceSegment)) {
44
+ throw new Error(`edition-contract.json: repositories.${channel} is exactly {namespaceSegment}, one lowercase path segment`);
45
+ }
46
+ segments.add(entry.namespaceSegment);
47
+ }
48
+ if (segments.size !== channels.length) {
49
+ throw new Error('edition-contract.json: the channels\' namespace segments must differ');
50
+ }
51
+ return contract;
52
+ }
@@ -1,7 +1,16 @@
1
1
  {
2
- "schema": "xema.edition-contract/v1",
2
+ "schema": "xema.edition-contract/v2",
3
+ "vendoring": "This file is the single source of the edition artifact coordinates and names. A consumer vendors it BYTE-IDENTICAL from a stated xema-cli commit and never edits it. It reads both repository namespace segments (repositories.<channel>.namespaceSegment) and the repository prefix (repositoryPrefix) only from this file, and refuses a contract that has no `repositories`.",
3
4
  "artifactType": "application/vnd.xema.edition.v1+json",
4
5
  "repositoryPrefix": "xema-edition-",
6
+ "repositories": {
7
+ "release": {
8
+ "namespaceSegment": "editions"
9
+ },
10
+ "develop": {
11
+ "namespaceSegment": "develop-editions"
12
+ }
13
+ },
5
14
  "descriptorFile": "xema-edition.json",
6
15
  "descriptorSchema": "xema.edition/v1",
7
16
  "locks": [
@@ -14,7 +14,6 @@ export const WorkspaceRoot = Object.freeze({
14
14
  Kernel: 'kernelPackageDir',
15
15
  Deploy: 'deployRoot',
16
16
  Cache: 'cacheDir',
17
- SourceHash: 'sourceHashPackageDir',
18
17
  });
19
18
 
20
19
  export const WORKSPACE_FLAGS = Object.freeze({
@@ -24,7 +23,6 @@ export const WORKSPACE_FLAGS = Object.freeze({
24
23
  [WorkspaceRoot.Kernel]: '--kernel',
25
24
  [WorkspaceRoot.Deploy]: '--deploy-root',
26
25
  [WorkspaceRoot.Cache]: '--cache-dir',
27
- [WorkspaceRoot.SourceHash]: '--source-hash',
28
26
  });
29
27
 
30
28
  const ROOT_MEANING = Object.freeze({
@@ -37,8 +35,6 @@ const ROOT_MEANING = Object.freeze({
37
35
  [WorkspaceRoot.Kernel]: 'the BUILT kernel-contracts package directory',
38
36
  [WorkspaceRoot.Deploy]: 'the deploy (chart tree) checkout',
39
37
  [WorkspaceRoot.Cache]: 'a writable directory for fetched artifacts',
40
- [WorkspaceRoot.SourceHash]:
41
- 'the BUILT distribution-source-hash package directory (the source-tree and image-input hash implementation)',
42
38
  });
43
39
 
44
40
  const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
@@ -90,7 +86,6 @@ export const sourcesRoot = () => root(WorkspaceRoot.Sources);
90
86
  export const kernelPackageDir = () => root(WorkspaceRoot.Kernel);
91
87
  export const deployRoot = () => root(WorkspaceRoot.Deploy);
92
88
  export const cacheDir = () => root(WorkspaceRoot.Cache);
93
- export const sourceHashPackageDir = () => root(WorkspaceRoot.SourceHash);
94
89
 
95
90
  /** A source checkout at its aggregator-relative path, e.g. `repos/xema-base`. */
96
91
  export const sourcePath = (repo) => join(sourcesRoot(), repo);
@@ -113,9 +108,11 @@ export const runtimeImageSourceIndexPath = () =>
113
108
  /**
114
109
  * Parse the workspace flags out of an argv (both `--flag v` and `--flag=v`),
115
110
  * configure the roots, and return the remaining argv. `needs` names the roots
116
- * the entry requires: a missing one is refused here, before any work.
111
+ * the entry requires: a missing one is refused here, before any work. With
112
+ * `refuseOthers` a root flag whose root `needs` does not name is refused too
113
+ * (a customer verb takes only the roots it needs).
117
114
  */
118
- export function configureEditionWorkspaceFromArgv(argv, needs) {
115
+ export function configureEditionWorkspaceFromArgv(argv, needs, { refuseOthers = false } = {}) {
119
116
  const byFlag = new Map(
120
117
  Object.entries(WORKSPACE_FLAGS).map(([name, flag]) => [flag, name]),
121
118
  );
@@ -130,6 +127,11 @@ export function configureEditionWorkspaceFromArgv(argv, needs) {
130
127
  rest.push(arg);
131
128
  continue;
132
129
  }
130
+ if (refuseOthers && !needs.includes(name)) {
131
+ throw new Error(
132
+ `${flag} is not a root this command takes (it takes: ${needs.length === 0 ? 'no root' : needs.map((root) => WORKSPACE_FLAGS[root]).join(' ')})`,
133
+ );
134
+ }
133
135
  const value = eq > 0 ? arg.slice(eq + 1) : argv[(i += 1)];
134
136
  if (typeof value !== 'string' || value.length === 0 || value.startsWith('--')) {
135
137
  throw new Error(`${flag} requires a directory path`);
@@ -169,11 +171,12 @@ export function isEntryModule(moduleUrl) {
169
171
  /**
170
172
  * The entry's one call: configure the roots from `process.argv` and remove the
171
173
  * root flags from it, so the entry's own parser sees only its own arguments.
174
+ * `options` are those of `configureEditionWorkspaceFromArgv`.
172
175
  */
173
- export function configureEditionWorkspaceFromProcess(needs) {
176
+ export function configureEditionWorkspaceFromProcess(needs, options) {
174
177
  let rest;
175
178
  try {
176
- rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs);
179
+ rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs, options);
177
180
  } catch (error) {
178
181
  // An entry calls this before it has a `main` to catch anything: a missing
179
182
  // or malformed root is the operator's mistake, answered with the roots the
@@ -133,6 +133,32 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
133
133
  return { policy, policyDigest };
134
134
  }
135
135
 
136
+ /**
137
+ * The arming of the data root's declared policy on `channel`, loaded and pinned
138
+ * exactly as `descriptorEvidenceTrust` and the check load it: `{ declared, state,
139
+ * unmet }`. A data root that declares no policy is `unarmed` with `declared:
140
+ * false` and one unmet entry; a declared policy that does not hold to its pin
141
+ * (digest, verifier) throws. Nothing is written.
142
+ *
143
+ * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
144
+ */
145
+ export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier }) {
146
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
147
+ const root = configuredDataRoot();
148
+ const schemas = await loadKernelDistributionSchemas();
149
+ assertArmingChannel(schemas, channel);
150
+ if (declaration.policy === null) {
151
+ return {
152
+ declared: false,
153
+ state: TrustPolicyArmingState.UNARMED,
154
+ unmet: [`${join(root, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy`],
155
+ };
156
+ }
157
+ const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
158
+ const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
159
+ return { declared: true, state, unmet };
160
+ }
161
+
136
162
  /** What a develop edition says, once, when it is written or checked without an armed develop channel. */
137
163
  export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
138
164
 
@@ -82,8 +82,9 @@ import { existsSync, mkdirSync } from 'node:fs';
82
82
  import { join } from 'node:path';
83
83
 
84
84
  import { readBiomeAuthorizationModuleAt } from './authorization-base.mjs';
85
+ import { editionWorkspaceConfig } from './edition-config.mjs';
85
86
  import { cacheDir } from './edition-workspace.mjs';
86
- import { gitAuthEnv } from './git-source-auth.mjs';
87
+ import { holdSource, withSourceGitEnv } from './git-source-auth.mjs';
87
88
  import {
88
89
  BIOME_GENERATED_INDEX_FILENAME,
89
90
  loadKernelBiomeContracts,
@@ -114,48 +115,24 @@ const EXACT_COMMIT = /^[a-f0-9]{40}$/;
114
115
  */
115
116
  const EXTERNAL_SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist']);
116
117
 
117
- function git(directory, args, env = process.env) {
118
- return execFileSync('git', ['-C', directory, ...args], {
118
+ function git(directory, args, env = process.env, exec = execFileSync) {
119
+ return exec('git', ['-C', directory, ...args], {
119
120
  encoding: 'utf8',
120
121
  env,
121
122
  stdio: ['ignore', 'pipe', 'pipe'],
122
123
  }).trim();
123
124
  }
124
125
 
125
- /**
126
- * Environment for a git call against one declared source.
127
- *
128
- * `authTokenEnv` NAMES an environment variable; the token is never inlined in
129
- * the manifest (`CredentialFreeGitUrlSchema` refuses a URL carrying userinfo).
130
- * A declared-but-unset variable is REFUSED rather than falling back to the
131
- * ambient credential helper: a source that states it needs a credential and
132
- * silently resolves without one has resolved as somebody else, and the commit
133
- * it pins would be whatever that other identity can see.
134
- */
135
- export function gitEnvForSource(source, env = process.env) {
136
- const base = { ...env, GIT_TERMINAL_PROMPT: '0' };
137
- if (source.authTokenEnv === undefined) {return base;}
138
- const token = env[source.authTokenEnv];
139
- if (typeof token !== 'string' || token.length === 0) {
140
- throw new Error(
141
- `source "${source.name}" declares authTokenEnv "${source.authTokenEnv}", but that ` +
142
- 'environment variable is unset or empty. Refusing to fall back to the ambient git ' +
143
- 'credential helper: a declared credential that silently resolves as another identity ' +
144
- 'would pin whatever commit that identity can see.',
145
- );
146
- }
147
- return gitAuthEnv(source.url, source.authUsername, token, base);
148
- }
149
-
150
126
  /**
151
127
  * Resolve a declared ref to an exact commit AT THE REMOTE.
152
128
  *
153
129
  * A moving branch may DISCOVER an upgrade candidate; it must never be the
154
130
  * IDENTITY of a released member. This is the only place a ref becomes a commit,
155
131
  * and everything downstream — the checkout, the manifest reads, the lock —
156
- * carries the commit.
132
+ * carries the commit. `gitEnv` is the environment `withSourceGitEnv` computed for
133
+ * this source; `exec` is the spawn seam.
157
134
  */
158
- export function resolveRemoteCommit(source, env = process.env) {
135
+ function resolveRemoteCommit(source, gitEnv, exec = execFileSync) {
159
136
  const ref = source.ref ?? 'HEAD';
160
137
  // AN EXACT COMMIT IS ALREADY THE ANSWER, and it must not be sent to
161
138
  // `ls-remote`, which matches REFS and would find nothing for a raw sha —
@@ -171,9 +148,9 @@ export function resolveRemoteCommit(source, env = process.env) {
171
148
  if (EXACT_COMMIT.test(ref)) {return ref;}
172
149
  let out;
173
150
  try {
174
- out = execFileSync('git', ['ls-remote', '--exit-code', source.url, ref], {
151
+ out = exec('git', ['ls-remote', '--exit-code', source.url, ref], {
175
152
  encoding: 'utf8',
176
- env: gitEnvForSource(source, env),
153
+ env: gitEnv,
177
154
  stdio: ['ignore', 'pipe', 'pipe'],
178
155
  }).trim();
179
156
  } catch (error) {
@@ -213,35 +190,38 @@ export function materializeGitSource({
213
190
  source,
214
191
  cacheRoot = externalSourceCacheDir(),
215
192
  env = process.env,
193
+ workspace = editionWorkspaceConfig(),
194
+ exec = execFileSync,
216
195
  }) {
217
- const gitEnv = gitEnvForSource(source, env);
218
- const commit = resolveRemoteCommit(source, env);
219
- const directory = join(cacheRoot, source.name);
220
- if (!existsSync(join(directory, '.git'))) {
221
- mkdirSync(directory, { recursive: true });
222
- execFileSync('git', ['init', '--quiet', directory], { env: gitEnv });
223
- git(directory, ['remote', 'add', 'origin', source.url], gitEnv);
224
- } else {
225
- // The cache is keyed by the DECLARED name, so a renamed or moved source
226
- // reuses it: fetch from where the declaration says the source lives NOW.
227
- git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv);
228
- }
229
- const have = (() => {
230
- try {
231
- return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv) === '';
232
- } catch {
233
- return false;
196
+ return withSourceGitEnv(source, { workspace, env }, (gitEnv) => {
197
+ const commit = resolveRemoteCommit(source, gitEnv, exec);
198
+ const directory = join(cacheRoot, source.name);
199
+ if (!existsSync(join(directory, '.git'))) {
200
+ mkdirSync(directory, { recursive: true });
201
+ exec('git', ['init', '--quiet', directory], { env: gitEnv });
202
+ git(directory, ['remote', 'add', 'origin', source.url], gitEnv, exec);
203
+ } else {
204
+ // The cache is keyed by the DECLARED name, so a renamed or moved source
205
+ // reuses it: fetch from where the declaration says the source lives NOW.
206
+ git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv, exec);
234
207
  }
235
- })();
236
- if (!have) {
237
- execFileSync(
238
- 'git',
239
- ['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
240
- { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
241
- );
242
- }
243
- git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv);
244
- return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
208
+ const have = (() => {
209
+ try {
210
+ return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
211
+ } catch {
212
+ return false;
213
+ }
214
+ })();
215
+ if (!have) {
216
+ exec(
217
+ 'git',
218
+ ['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
219
+ { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
220
+ );
221
+ }
222
+ git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv, exec);
223
+ return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
224
+ });
245
225
  }
246
226
 
247
227
  /**
@@ -407,7 +387,8 @@ function lockedSourceRecord(source, commit) {
407
387
  * Walk every declared source for one edition.
408
388
  *
409
389
  * Returns the stamped index entries and the `LockedBiomeSource` record per
410
- * source name. `materialize` is injectable so the contract suite can drive a
390
+ * source name. `workspace` (default: the configured data root) supplies the one source credential.
391
+ * `materialize` is injectable so the contract suite can drive a
411
392
  * real local git repository instead of reaching the network — the walk under
412
393
  * test is then the real one, not a fixture of it.
413
394
  */
@@ -415,9 +396,12 @@ export async function walkDeclaredSources({
415
396
  sources = [],
416
397
  materialize = materializeGitSource,
417
398
  env = process.env,
399
+ workspace = undefined,
418
400
  } = {}) {
419
401
  const entries = [];
420
402
  const sourceRecords = new Map();
403
+ // Every source is held (kind, then credential and URL) before the first is materialised.
404
+ const heldWorkspace = sources.length === 0 ? undefined : (workspace ?? editionWorkspaceConfig());
421
405
  for (const source of sources) {
422
406
  if (source.kind !== 'git') {
423
407
  throw new Error(
@@ -428,7 +412,10 @@ export async function walkDeclaredSources({
428
412
  'revision. Refusing rather than resolving.',
429
413
  );
430
414
  }
431
- const { directory, commit } = materialize({ source, env });
415
+ holdSource(source, heldWorkspace, env);
416
+ }
417
+ for (const source of sources) {
418
+ const { directory, commit } = materialize({ source, env, workspace: heldWorkspace });
432
419
  if (!EXACT_COMMIT.test(String(commit))) {
433
420
  throw new Error(
434
421
  `source "${source.name}": walk produced commit "${commit}", which is not exact 40-hex`,
@@ -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
+ }
@@ -600,17 +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.
606
608
  *
607
- * A signer belongs to one channel: the kernel policy schema, which every policy
608
- * this plane loads is parsed by, holds a row's `githubWorkflow.ref` to the ref
609
- * of its channel (`EVIDENCE_CHANNEL_REFS`) and its `certificateIdentity` to
610
- * that ref, so no signer can be authorized on two channels. The signature
611
- * therefore authenticates the channel: an edition descriptor that states the
612
- * other channel selects signers whose certificates the evidence does not carry,
613
- * or none at all.
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.
614
624
  */
615
625
  function policyCandidates(policy, predicateType, channel, imageRepository) {
616
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,