@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.
- 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/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +0 -1
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +231 -50
- package/dist/edition/verify-image-evidence.bundle.provenance.json +55 -43
- 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 +76 -41
- 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 +79 -18
- package/edition/edition-biome-manifests.mjs +2 -3
- package/edition/edition-chart-set.mjs +59 -6
- package/edition/edition-config.mjs +45 -21
- package/edition/edition-constants.mjs +10 -4
- package/edition/edition-contract-parse.mjs +52 -0
- package/edition/edition-contract.json +21 -5
- package/edition/edition-workspace.mjs +23 -9
- package/edition/evidence-checkout.mjs +22 -1
- package/edition/evidence-trust-policy.mjs +142 -78
- package/edition/external-source-walk.mjs +48 -61
- package/edition/git-source-auth.mjs +104 -25
- package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
- package/edition/image-evidence.mjs +21 -3
- package/edition/kernel-contract-authority.mjs +23 -1
- package/edition/lib.mjs +16 -24
- package/edition/oras.mjs +1 -1
- package/edition/pin-source.mjs +6 -1
- package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
- package/edition/registry-credentials.mjs +11 -1
- package/edition/release-channel-guard.mjs +11 -1
- package/edition/release-edition-artifact.mjs +5 -6
- 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/edition/verify-image-evidence.mjs +40 -12
- package/package.json +3 -3
|
@@ -1,40 +1,36 @@
|
|
|
1
|
-
// The evidence-trust part of `xema edition check
|
|
1
|
+
// The evidence-trust part of `xema edition check`, and the state an edition
|
|
2
|
+
// records in its descriptor.
|
|
2
3
|
//
|
|
3
|
-
// A data root
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// refuses every state in which the answer would be a silent one:
|
|
4
|
+
// A data root DECLARES an evidence trust policy (`evidenceTrustPolicy: {policy,
|
|
5
|
+
// pin}` in `edition-workspace.json`) or states that it declares none (`null`).
|
|
6
|
+
// No switch admits an ungated release:
|
|
7
7
|
//
|
|
8
|
-
// *
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
8
|
+
// * a RELEASE edition is written only against a policy ARMED on the release
|
|
9
|
+
// channel. No policy, UNARMED or PARTIALLY ARMED there -> refused
|
|
10
|
+
// (EvidenceTrustRequiredError, code EVIDENCE_TRUST_REQUIRED_UNARMED);
|
|
11
|
+
// * a DEVELOP edition admits not-armed and says so in one line; a policy
|
|
12
|
+
// PARTIALLY ARMED on the develop channel is refused;
|
|
13
|
+
// * `xema edition check` FAILS when the editions tree holds a release
|
|
14
|
+
// edition (an edition directory holding a release lock) and the policy is
|
|
15
|
+
// not ARMED on the release channel (none declared, UNARMED or PARTIAL):
|
|
16
|
+
// that edition cannot be published as an artifact. A tree holding no
|
|
17
|
+
// release edition passes for develop-only use, with a notice saying so;
|
|
17
18
|
// * no policy declared but a policy file is present at the top level or under
|
|
18
|
-
// `trust/`
|
|
19
|
-
//
|
|
20
|
-
// * declared
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// judged PER CHANNEL (trust-policy-arming.mjs): on
|
|
26
|
-
// the release channel it is unarmed (passes,
|
|
27
|
-
// delivery fail-closed) or armed; the develop
|
|
28
|
-
// channel admits not-armed and says so in one
|
|
29
|
-
// line; a channel that is partially armed is refused.
|
|
19
|
+
// `trust/` -> refused: a policy nothing reads is a policy someone believes
|
|
20
|
+
// is enforced;
|
|
21
|
+
// * declared -> the policy parses with the kernel schema, its pinned Sigstore
|
|
22
|
+
// root matches, the data-root pin records the policy's digest AND this
|
|
23
|
+
// CLI's built verifier (a missing bundle is refused, never skipped), and
|
|
24
|
+
// the authority configuration is judged PER CHANNEL
|
|
25
|
+
// (trust-policy-arming.mjs); a channel that is partially armed is refused.
|
|
30
26
|
|
|
31
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
32
28
|
import { join } from 'node:path';
|
|
33
29
|
|
|
34
30
|
import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
|
|
35
31
|
import { EDITION_WORKSPACE_CONFIG_FILE, editionWorkspaceConfig } from './edition-config.mjs';
|
|
36
32
|
import { EDITION_CONTRACT, EditionChannel, EvidenceTrustState } from './edition-constants.mjs';
|
|
37
|
-
import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
|
|
33
|
+
import { dataRoot as configuredDataRoot, editionsRoot as configuredEditionsRoot } from './edition-workspace.mjs';
|
|
38
34
|
import {
|
|
39
35
|
assertPinnedPolicy,
|
|
40
36
|
assertPinnedVerifier,
|
|
@@ -42,6 +38,7 @@ import {
|
|
|
42
38
|
readEvidenceTrustPin,
|
|
43
39
|
} from './evidence-trust-pin.mjs';
|
|
44
40
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
41
|
+
import { heldReleaseLocks } from './release-channel-guard.mjs';
|
|
45
42
|
import {
|
|
46
43
|
TrustPolicyArmingState,
|
|
47
44
|
assertArmingChannel,
|
|
@@ -51,25 +48,72 @@ import {
|
|
|
51
48
|
const TAG = '[dist:check]';
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
|
-
* The stable code of the refusal to write a
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* The stable code of the refusal to write a RELEASE edition without an armed
|
|
52
|
+
* evidence trust policy. Consumers (xema-deploy, workflows) match on the code,
|
|
53
|
+
* never on the message; it is also listed in edition-contract.json.
|
|
57
54
|
*/
|
|
58
55
|
export const EVIDENCE_TRUST_REQUIRED_UNARMED = EDITION_CONTRACT.evidenceTrust.refusal.code;
|
|
59
56
|
|
|
60
57
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
58
|
+
* What `xema edition check` says of a data root whose policy is not armed on
|
|
59
|
+
* the release channel. Sealing is not gated (nothing sealed leaves without
|
|
60
|
+
* `xema edition artifact`); publishing is.
|
|
61
|
+
*/
|
|
62
|
+
export const NO_RELEASE_ARTIFACT_NOTICE =
|
|
63
|
+
'no release edition of this data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The release editions of the configured editions tree: the edition directories
|
|
67
|
+
* (those holding `xema-distribution.json`) that hold a release lock. An edition
|
|
68
|
+
* that is only defined, with no lock resolved yet, is not one.
|
|
69
|
+
*/
|
|
70
|
+
export function releaseEditionIds() {
|
|
71
|
+
const root = configuredEditionsRoot();
|
|
72
|
+
if (!existsSync(root)) {return [];}
|
|
73
|
+
return readdirSync(root, { withFileTypes: true })
|
|
74
|
+
.filter(
|
|
75
|
+
(entry) =>
|
|
76
|
+
entry.isDirectory() &&
|
|
77
|
+
existsSync(join(root, entry.name, 'xema-distribution.json')) &&
|
|
78
|
+
heldReleaseLocks(join(root, entry.name)).length > 0,
|
|
79
|
+
)
|
|
80
|
+
.map((entry) => entry.name)
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The verdict of the check for a policy that is not armed on the release
|
|
86
|
+
* channel: red when the tree holds a release edition (it names them and the
|
|
87
|
+
* repair), green with the notice when it holds none.
|
|
88
|
+
*/
|
|
89
|
+
function notArmedOnReleaseVerdict(what, repair) {
|
|
90
|
+
const releaseEditions = releaseEditionIds();
|
|
91
|
+
if (releaseEditions.length === 0) {
|
|
92
|
+
console.warn(
|
|
93
|
+
`${TAG} image-evidence trust policy: NOT ARMED on the release channel — ${what}. ${configuredEditionsRoot()} holds no release edition, ` +
|
|
94
|
+
`so this passes for develop-only use: ${NO_RELEASE_ARTIFACT_NOTICE}.`,
|
|
95
|
+
);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
console.error(
|
|
99
|
+
`${TAG} image-evidence trust policy FAILED: ${what}, and ${configuredEditionsRoot()} holds ${String(releaseEditions.length)} release edition(s) ` +
|
|
100
|
+
`(${releaseEditions.join(', ')}): ${NO_RELEASE_ARTIFACT_NOTICE}. ${repair}`,
|
|
101
|
+
);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A release edition requires an armed evidence trust policy and this data root
|
|
107
|
+
* has none: it declares no policy, or declares one that is UNARMED or PARTIALLY
|
|
108
|
+
* ARMED on the release channel. Writing the release descriptor or artifact is
|
|
109
|
+
* refused with this error; a release edition never records `not-armed`.
|
|
65
110
|
*/
|
|
66
111
|
export class EvidenceTrustRequiredError extends Error {
|
|
67
112
|
/** @param {string} dataRoot @param {string} reason what is not armed, in words */
|
|
68
113
|
constructor(dataRoot, reason) {
|
|
69
114
|
super(
|
|
70
|
-
|
|
71
|
-
'arm the
|
|
72
|
-
'or set required to false to run this data root NOT ARMED.',
|
|
115
|
+
`a release edition requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
|
|
116
|
+
'declare a policy ({"policy": ..., "pin": ...}) and arm it on the release channel (`xema edition check` says what is unmet).',
|
|
73
117
|
);
|
|
74
118
|
this.name = 'EvidenceTrustRequiredError';
|
|
75
119
|
this.code = EVIDENCE_TRUST_REQUIRED_UNARMED;
|
|
@@ -89,27 +133,51 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
|
|
|
89
133
|
return { policy, policyDigest };
|
|
90
134
|
}
|
|
91
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
|
+
|
|
92
162
|
/** What a develop edition says, once, when it is written or checked without an armed develop channel. */
|
|
93
163
|
export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
|
|
94
164
|
|
|
95
165
|
/**
|
|
96
|
-
* The evidence state an edition of `channel` records in its descriptor
|
|
97
|
-
*
|
|
98
|
-
*
|
|
166
|
+
* The evidence state an edition of `channel` records in its descriptor. Arming
|
|
167
|
+
* is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
|
|
168
|
+
* the other channel count for nothing.
|
|
99
169
|
*
|
|
100
170
|
* * `armed` - the data root declares a policy, the policy is pinned (the
|
|
101
171
|
* policy and this CLI's verifier match the data-root pin) and
|
|
102
172
|
* ARMED on `channel`;
|
|
103
|
-
* * `not-armed` - a
|
|
104
|
-
* policy
|
|
105
|
-
* `required` says, and it says so in one stderr line.
|
|
173
|
+
* * `not-armed` - a DEVELOP edition only: its channel is not armed (no
|
|
174
|
+
* policy, or UNARMED there), and it says so in one stderr line.
|
|
106
175
|
*
|
|
107
|
-
* Every other state is refused
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* either channel.
|
|
176
|
+
* Every other state is refused. A RELEASE edition that is not ARMED on the
|
|
177
|
+
* release channel (no policy, UNARMED or PARTIAL there) throws
|
|
178
|
+
* EvidenceTrustRequiredError: this function never returns `not-armed` for it.
|
|
179
|
+
* A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
|
|
180
|
+
* ARMED on `channel` are refused on either channel.
|
|
113
181
|
*
|
|
114
182
|
* @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
|
|
115
183
|
*/
|
|
@@ -118,25 +186,24 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
|
|
|
118
186
|
const root = configuredDataRoot();
|
|
119
187
|
const schemas = await loadKernelDistributionSchemas();
|
|
120
188
|
assertArmingChannel(schemas, channel);
|
|
121
|
-
const
|
|
122
|
-
|
|
189
|
+
const release = channel === EditionChannel.Release;
|
|
190
|
+
const developNotArmed = () => {
|
|
191
|
+
console.error(DEVELOP_NOT_ARMED_NOTICE);
|
|
123
192
|
return EvidenceTrustState.NotArmed;
|
|
124
193
|
};
|
|
125
194
|
if (declaration.policy === null) {
|
|
126
|
-
if (
|
|
127
|
-
return
|
|
195
|
+
if (release) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
|
|
196
|
+
return developNotArmed();
|
|
128
197
|
}
|
|
129
198
|
const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
|
|
130
199
|
const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
|
|
200
|
+
if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
|
|
131
201
|
if (state === TrustPolicyArmingState.PARTIAL) {
|
|
132
|
-
if (
|
|
202
|
+
if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
|
|
133
203
|
throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
|
|
134
204
|
}
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
throw new EvidenceTrustRequiredError(root, `its declared policy is UNARMED on the ${channel} channel`);
|
|
138
|
-
}
|
|
139
|
-
return notArmed();
|
|
205
|
+
if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is UNARMED on the ${channel} channel`);}
|
|
206
|
+
return developNotArmed();
|
|
140
207
|
}
|
|
141
208
|
|
|
142
209
|
export const UNDECLARED_TRUST_DIRECTORY = 'trust';
|
|
@@ -184,21 +251,19 @@ export async function checkEvidenceTrustPolicy({
|
|
|
184
251
|
const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
|
|
185
252
|
const schemas = await loadKernelDistributionSchemas();
|
|
186
253
|
if (declaration.policy === null) {
|
|
187
|
-
if (declaration.required) {throw new EvidenceTrustRequiredError(dataRoot, 'declares no policy');}
|
|
188
254
|
const undeclared = undeclaredPolicyArtifacts(dataRoot, schemas);
|
|
189
255
|
if (undeclared.length > 0) {
|
|
190
256
|
console.error(
|
|
191
257
|
`${TAG} image-evidence trust policy FAILED: ${dataRoot} holds ${undeclared.join(', ')} ` +
|
|
192
|
-
`but ${EDITION_WORKSPACE_CONFIG_FILE} declares
|
|
193
|
-
'Declare it ({"
|
|
258
|
+
`but ${EDITION_WORKSPACE_CONFIG_FILE} declares no evidenceTrustPolicy, so nothing reads it. ` +
|
|
259
|
+
'Declare it ({"policy": ..., "pin": ...}) or remove it.',
|
|
194
260
|
);
|
|
195
261
|
return false;
|
|
196
262
|
}
|
|
197
|
-
|
|
198
|
-
`${
|
|
199
|
-
|
|
263
|
+
return notArmedOnReleaseVerdict(
|
|
264
|
+
`${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy (its develop editions are not evidence-gated)`,
|
|
265
|
+
'Declare a policy ({"policy": ..., "pin": ...}), authorize the release signers in it, pin it with `xema edition repin-evidence-trust`, and re-run `xema edition check` until it reports ARMED on the release channel.',
|
|
200
266
|
);
|
|
201
|
-
return true;
|
|
202
267
|
}
|
|
203
268
|
|
|
204
269
|
const { policy, policyDigest } = loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier });
|
|
@@ -213,8 +278,9 @@ export async function checkEvidenceTrustPolicy({
|
|
|
213
278
|
`${policy.trustedCosignBinaryDigests.length} cosign / ` +
|
|
214
279
|
`${policy.trustedGitBinaryDigests.length} git binary pins.`,
|
|
215
280
|
);
|
|
216
|
-
// Each channel is judged on its own signers. The
|
|
217
|
-
//
|
|
281
|
+
// Each channel is judged on its own signers. The develop channel admits
|
|
282
|
+
// not-armed and says so; a release channel that is not armed fails the
|
|
283
|
+
// check as soon as the tree holds a release edition.
|
|
218
284
|
const partial = (channel, unmet) => {
|
|
219
285
|
console.error(
|
|
220
286
|
`${TAG} image-evidence trust policy is PARTIALLY ARMED on the ${channel} channel — this mixed state is invalid ` +
|
|
@@ -232,20 +298,18 @@ export async function checkEvidenceTrustPolicy({
|
|
|
232
298
|
const release = assessTrustPolicyArming(policy, schemas, EditionChannel.Release);
|
|
233
299
|
if (release.state === TrustPolicyArmingState.PARTIAL) {
|
|
234
300
|
partial(EditionChannel.Release, release.unmet);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
console.error(`${TAG} ${refusal.code}: ${refusal.message}`);
|
|
301
|
+
console.error(
|
|
302
|
+
`${TAG} ${NO_RELEASE_ARTIFACT_NOTICE}. Complete the release channel in ${declaration.policy} (every line above), ` +
|
|
303
|
+
're-pin with `xema edition repin-evidence-trust`, and re-run `xema edition check`.',
|
|
304
|
+
);
|
|
240
305
|
return false;
|
|
241
306
|
}
|
|
242
307
|
if (develop.state === TrustPolicyArmingState.UNARMED) {console.error(`${TAG} ${DEVELOP_NOT_ARMED_NOTICE}`);}
|
|
243
308
|
if (release.state === TrustPolicyArmingState.UNARMED) {
|
|
244
|
-
|
|
245
|
-
`${
|
|
246
|
-
|
|
309
|
+
return notArmedOnReleaseVerdict(
|
|
310
|
+
`${declaration.policy} is declared but UNARMED on the release channel (it authorizes no release signer)`,
|
|
311
|
+
`Authorize the release signers and pin the cosign and git binaries in ${declaration.policy}, re-pin with \`xema edition repin-evidence-trust\`, and re-run \`xema edition check\` until it reports ARMED on the release channel.`,
|
|
247
312
|
);
|
|
248
|
-
return true;
|
|
249
313
|
}
|
|
250
314
|
console.log(`${TAG} image-evidence trust policy: ARMED on the release channel${develop.armed ? ' and on the develop channel' : ''}.`);
|
|
251
315
|
return true;
|
|
@@ -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`,
|