@xemahq/xema 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +8 -7
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +88 -11
- package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
- package/dist/runtime/edition/verb-map.d.ts +2 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +39 -4
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/edition/README.md +69 -32
- package/edition/build-biome-index.mjs +1 -1
- package/edition/build-platform-service-index.mjs +1 -1
- package/edition/build-runtime-image-source-index.mjs +64 -20
- package/edition/catalogue-fetch.mjs +36 -9
- package/edition/catalogue-pin.mjs +71 -0
- package/edition/catalogue.mjs +11 -0
- package/edition/chart-set-source-commit.mjs +17 -7
- package/edition/checkout-indexed-source-repositories.mjs +17 -17
- package/edition/customer-flags.mjs +0 -1
- package/edition/develop-customer-edition.mjs +23 -5
- package/edition/develop-edition.mjs +147 -48
- package/edition/develop-freeze.mjs +69 -25
- package/edition/develop-seal.mjs +39 -25
- package/edition/dist-check.mjs +1 -1
- package/edition/edition-artifact.mjs +65 -18
- package/edition/edition-biome-manifests.mjs +2 -3
- package/edition/edition-chart-set.mjs +59 -6
- package/edition/edition-config.mjs +28 -2
- package/edition/edition-constants.mjs +10 -4
- package/edition/edition-contract-parse.mjs +52 -0
- package/edition/edition-contract.json +10 -1
- package/edition/edition-workspace.mjs +12 -9
- package/edition/evidence-trust-policy.mjs +26 -0
- package/edition/external-source-walk.mjs +48 -61
- package/edition/git-source-auth.mjs +104 -25
- package/edition/image-evidence.mjs +20 -10
- package/edition/kernel-contract-authority.mjs +23 -1
- package/edition/lib.mjs +13 -24
- package/edition/oras.mjs +32 -10
- package/edition/pin-source.mjs +6 -1
- package/edition/registry-credentials.mjs +11 -1
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/resolve-chart-set.mjs +50 -30
- package/edition/resolve-customer-edition.mjs +63 -7
- package/edition/resolve-distribution.mjs +1 -1
- package/edition/resolve-edition.mjs +12 -11
- package/edition/resolve-images.mjs +36 -15
- package/edition/seal-image-digests.mjs +31 -7
- package/edition/source-closure.mjs +1 -2
- package/edition/trust-policy-arming-verb.mjs +68 -0
- 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/
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
151
|
+
out = exec('git', ['ls-remote', '--exit-code', source.url, ref], {
|
|
175
152
|
encoding: 'utf8',
|
|
176
|
-
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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. `
|
|
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
|
-
|
|
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
|
|
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,
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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 ${
|
|
29
|
-
'it is declared, never defaulted: `sourceAuthUsername` in edition-workspace.json
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
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
|
-
|
|
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,
|