@xemahq/xema 0.41.1 → 0.43.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 +2 -1
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +1289 -840
- package/dist/edition/verify-image-evidence.bundle.provenance.json +494 -458
- package/dist/runtime/edition/verb-map.d.ts +1 -0
- 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/edition/README.md +31 -1
- package/edition/authored-source-check.mjs +135 -0
- package/edition/biome-content.mjs +4 -0
- package/edition/customer-edition-verbs.mjs +1 -0
- package/edition/develop-edition-invariants.mjs +141 -0
- package/edition/develop-edition-resolve.mjs +7 -1
- package/edition/develop-edition.mjs +22 -2
- package/edition/dispatched-runtime-components.mjs +4 -3
- package/edition/dist-check.mjs +108 -63
- package/edition/edition-protocol.mjs +36 -9
- package/edition/edition-workspace.mjs +2 -0
- package/edition/kernel-protocol-choice.mjs +239 -0
- package/edition/lib.mjs +100 -3
- package/edition/operation-grant-admission.mjs +65 -1
- package/edition/operation-requirements.mjs +18 -1
- package/edition/resolve-customer-edition.mjs +4 -3
- package/edition/resolve-distribution.mjs +16 -0
- package/edition/resolve-images.mjs +124 -15
- package/package.json +4 -4
package/edition/dist-check.mjs
CHANGED
|
@@ -370,7 +370,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
async function checkCommittedRuntimeImageSourceIndex(checkSources) {
|
|
373
|
+
export async function checkCommittedRuntimeImageSourceIndex(checkSources) {
|
|
374
374
|
try {
|
|
375
375
|
const schemas = await loadKernelDistributionSchemas();
|
|
376
376
|
schemas.parseRuntimeImageSourceIndex(
|
|
@@ -394,7 +394,7 @@ async function checkCommittedRuntimeImageSourceIndex(checkSources) {
|
|
|
394
394
|
}
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
function checkCommittedIndex() {
|
|
397
|
+
export function checkCommittedIndex() {
|
|
398
398
|
try {
|
|
399
399
|
const index = validateImmutableBiomeIndex(
|
|
400
400
|
JSON.parse(readFileSync(biomeIndexPath(), 'utf8')),
|
|
@@ -464,7 +464,7 @@ export function checkCommittedDataRootReaders() {
|
|
|
464
464
|
return green;
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
-
async function checkCommittedPlatformServiceIndex() {
|
|
467
|
+
export async function checkCommittedPlatformServiceIndex() {
|
|
468
468
|
try {
|
|
469
469
|
const schemas = await loadKernelDistributionSchemas();
|
|
470
470
|
const index = schemas.parsePlatformServiceSourceIndex(
|
|
@@ -493,7 +493,7 @@ async function checkCommittedPlatformServiceIndex() {
|
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
function discoverDistributionIds() {
|
|
496
|
+
export function discoverDistributionIds() {
|
|
497
497
|
if (!existsSync(editionsRoot())) {
|
|
498
498
|
console.error(`[dist:check] ${editionsRoot()} does not exist.`);
|
|
499
499
|
process.exit(1);
|
|
@@ -514,37 +514,16 @@ function discoverDistributionIds() {
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
|
|
517
|
+
* The lock-free half of the ownership guard: the kernel's reader, exercised
|
|
518
|
+
* against synthetic tables. (a) two biomes claiming one domain is refused,
|
|
519
|
+
* naming it; (b) a listed domain resolves to its owner and an unlisted one is
|
|
520
|
+
* not first-party; (c) an invalid domain is refused. Reads no lock, so the
|
|
521
|
+
* authored check runs it too.
|
|
524
522
|
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
* edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
|
|
528
|
-
* org admin could install the owning biome from the store and THEN bind
|
|
529
|
-
* `kb:page.read@1` to a URL they control, silently shadowing the platform
|
|
530
|
-
* data plane. The table is now built from the FULL biome index and reserves
|
|
531
|
-
* the domain even when its owning biome is absent from that edition — which
|
|
532
|
-
* is exactly the case the reservation exists to cover.
|
|
533
|
-
*
|
|
534
|
-
* Also re-asserts the two structural rules, through the KERNEL's own reader:
|
|
535
|
-
* (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
|
|
536
|
-
* no safe resolution — fail the build, never pick a winner);
|
|
537
|
-
* (b) a domain in NO table entry is not first-party (so a claim that never
|
|
538
|
-
* made it into a resolved, committed, drift-checked lock carries no
|
|
539
|
-
* weight), and an invalid domain is refused rather than silently carried.
|
|
540
|
-
*
|
|
541
|
-
* @param {string[]} ids every distribution id discovered in this repo.
|
|
542
|
-
* @returns {Promise<boolean>} true when green.
|
|
523
|
+
* @param {(lock: object) => Map<string, string>} resolveFirstPartyCapabilityDomains the kernel's reader
|
|
524
|
+
* @returns {string[]} the failures, empty when green
|
|
543
525
|
*/
|
|
544
|
-
|
|
545
|
-
const { resolveFirstPartyCapabilityDomains } =
|
|
546
|
-
await loadKernelDistributionSchemas();
|
|
547
|
-
|
|
526
|
+
export function ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains) {
|
|
548
527
|
const lockOf = (capabilityDomainOwners) => ({
|
|
549
528
|
schemaVersion: 1,
|
|
550
529
|
distributionId: 'dist-check-selftest',
|
|
@@ -599,6 +578,43 @@ async function checkOwnershipInvariant(ids) {
|
|
|
599
578
|
/* expected */
|
|
600
579
|
}
|
|
601
580
|
|
|
581
|
+
return failures;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Guard for the capability-domain OWNERSHIP (reservation) table.
|
|
586
|
+
*
|
|
587
|
+
* This repo has no test runner (no package.json, no CI workflow of its own) —
|
|
588
|
+
* `dist:check` IS its gate — so the invariant is asserted here, where it
|
|
589
|
+
* actually runs, rather than in a spec file nothing would execute.
|
|
590
|
+
*
|
|
591
|
+
* THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
|
|
592
|
+
*
|
|
593
|
+
* Ownership is a property of the platform, not of what an edition preloads.
|
|
594
|
+
* An earlier design put the claim on each `LockedBiome`, which made a trimmed
|
|
595
|
+
* edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
|
|
596
|
+
* org admin could install the owning biome from the store and THEN bind
|
|
597
|
+
* `kb:page.read@1` to a URL they control, silently shadowing the platform
|
|
598
|
+
* data plane. The table is now built from the FULL biome index and reserves
|
|
599
|
+
* the domain even when its owning biome is absent from that edition — which
|
|
600
|
+
* is exactly the case the reservation exists to cover.
|
|
601
|
+
*
|
|
602
|
+
* Also re-asserts the two structural rules, through the KERNEL's own reader:
|
|
603
|
+
* (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
|
|
604
|
+
* no safe resolution — fail the build, never pick a winner);
|
|
605
|
+
* (b) a domain in NO table entry is not first-party (so a claim that never
|
|
606
|
+
* made it into a resolved, committed, drift-checked lock carries no
|
|
607
|
+
* weight), and an invalid domain is refused rather than silently carried.
|
|
608
|
+
*
|
|
609
|
+
* @param {string[]} ids every distribution id discovered in this repo.
|
|
610
|
+
* @returns {Promise<boolean>} true when green.
|
|
611
|
+
*/
|
|
612
|
+
async function checkOwnershipInvariant(ids) {
|
|
613
|
+
const { resolveFirstPartyCapabilityDomains } =
|
|
614
|
+
await loadKernelDistributionSchemas();
|
|
615
|
+
|
|
616
|
+
const failures = ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains);
|
|
617
|
+
|
|
602
618
|
// (d) THE headline invariant — every committed edition carries the same,
|
|
603
619
|
// non-empty table. An edition-specific table is the fail-open bug.
|
|
604
620
|
let reference;
|
|
@@ -919,13 +935,16 @@ export async function checkBiomeContentHashes(id) {
|
|
|
919
935
|
}
|
|
920
936
|
}
|
|
921
937
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
938
|
+
/**
|
|
939
|
+
* The data-root plane both checks grade before any edition: the kernel speaks
|
|
940
|
+
* a supported edition protocol (a refusal throws), the committed biome,
|
|
941
|
+
* platform-service and runtime-image-source indexes validate offline, the
|
|
942
|
+
* evidence trust policy and the data-root readers are sound. `checkSources`
|
|
943
|
+
* adds the trusted live-source audit of the indexes (release path only).
|
|
944
|
+
* Returns true when green.
|
|
945
|
+
*/
|
|
946
|
+
export async function checkDataRootPlane({ checkSources }) {
|
|
947
|
+
let green = true;
|
|
929
948
|
// 0. The pinned kernel speaks an edition protocol this CLI supports. This is
|
|
930
949
|
// the ONE comparison between the CLI and the kernel; a refusal throws
|
|
931
950
|
// EditionProtocolMismatchError and stops the check before anything is read.
|
|
@@ -938,13 +957,13 @@ async function main() {
|
|
|
938
957
|
// A live sibling-source scan is an explicit trusted regeneration audit only;
|
|
939
958
|
// CI/shared working trees must not make unrelated uncommitted sources into
|
|
940
959
|
// packaging inputs.
|
|
941
|
-
if (!checkCommittedIndex()) {
|
|
942
|
-
if (!(await checkCommittedPlatformServiceIndex())) {
|
|
960
|
+
if (!checkCommittedIndex()) {green = false;}
|
|
961
|
+
if (!(await checkCommittedPlatformServiceIndex())) {green = false;}
|
|
943
962
|
if (!(await checkCommittedRuntimeImageSourceIndex(checkSources))) {
|
|
944
|
-
|
|
963
|
+
green = false;
|
|
945
964
|
}
|
|
946
|
-
if (!(await checkEvidenceTrustPolicy())) {
|
|
947
|
-
if (!checkCommittedDataRootReaders()) {
|
|
965
|
+
if (!(await checkEvidenceTrustPolicy())) {green = false;}
|
|
966
|
+
if (!checkCommittedDataRootReaders()) {green = false;}
|
|
948
967
|
if (checkSources) {
|
|
949
968
|
try {
|
|
950
969
|
execFileSync(
|
|
@@ -958,9 +977,48 @@ async function main() {
|
|
|
958
977
|
{ stdio: 'inherit' },
|
|
959
978
|
);
|
|
960
979
|
} catch {
|
|
961
|
-
|
|
980
|
+
green = false;
|
|
962
981
|
}
|
|
963
982
|
}
|
|
983
|
+
return green;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The pinned kernel's authorization composer (`kernel` null when the pin
|
|
988
|
+
* predates it). `green` is false when the composer could not be loaded.
|
|
989
|
+
*/
|
|
990
|
+
export async function loadPlaneAuthorizationKernel() {
|
|
991
|
+
try {
|
|
992
|
+
return { green: true, kernel: await loadKernelAuthorizationComposer({ kernelPackageDir: kernelPackageDir() }) };
|
|
993
|
+
} catch (err) {
|
|
994
|
+
console.error(`[dist:check] ${err.message}`);
|
|
995
|
+
return { green: false, kernel: undefined };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The fleet chart-set pin every edition must carry (edition-chart-set.mjs), as
|
|
1001
|
+
* committed text, or null when it is absent or not a rostered chart-set lock.
|
|
1002
|
+
*/
|
|
1003
|
+
export function readFleetChartSetPin() {
|
|
1004
|
+
try {
|
|
1005
|
+
const text = readFileSync(fleetChartSetLockPath(), 'utf8');
|
|
1006
|
+
assertRosteredChartSetLock(JSON.parse(text), EDITION_CHART_SET_FILE);
|
|
1007
|
+
return text;
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function main() {
|
|
1015
|
+
let failed = false;
|
|
1016
|
+
// The tests spawned below resolve the editions root from THEIR OWN argv and
|
|
1017
|
+
// env; a root stated here by --editions-root must reach them, or they
|
|
1018
|
+
// would grade the vendor's editions under a customer's dist-check.
|
|
1019
|
+
const checkSources = process.argv.includes('--check-sources');
|
|
1020
|
+
|
|
1021
|
+
if (!(await checkDataRootPlane({ checkSources }))) {failed = true;}
|
|
964
1022
|
|
|
965
1023
|
const distributionIds = discoverDistributionIds();
|
|
966
1024
|
|
|
@@ -972,25 +1030,12 @@ async function main() {
|
|
|
972
1030
|
if (!(await checkOwnershipInvariant(distributionIds))) {failed = true;}
|
|
973
1031
|
|
|
974
1032
|
// The pinned kernel's authorization composer (null when the pin predates it).
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
authorizationKernel = await loadKernelAuthorizationComposer({
|
|
978
|
-
kernelPackageDir: kernelPackageDir(),
|
|
979
|
-
});
|
|
980
|
-
} catch (err) {
|
|
981
|
-
console.error(`[dist:check] ${err.message}`);
|
|
982
|
-
failed = true;
|
|
983
|
-
}
|
|
1033
|
+
const { green: authorizationKernelGreen, kernel: authorizationKernel } = await loadPlaneAuthorizationKernel();
|
|
1034
|
+
if (!authorizationKernelGreen) {failed = true;}
|
|
984
1035
|
|
|
985
1036
|
// The fleet chart-set pin every edition must carry (edition-chart-set.mjs).
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
chartSetPinText = readFileSync(fleetChartSetLockPath(), 'utf8');
|
|
989
|
-
assertRosteredChartSetLock(JSON.parse(chartSetPinText), EDITION_CHART_SET_FILE);
|
|
990
|
-
} catch (err) {
|
|
991
|
-
console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
|
|
992
|
-
failed = true;
|
|
993
|
-
}
|
|
1037
|
+
const chartSetPinText = readFleetChartSetPin();
|
|
1038
|
+
if (chartSetPinText === null) {failed = true;}
|
|
994
1039
|
|
|
995
1040
|
// 3 + 4 + 5 + 6 + 7. Every distribution: distribution.lock, images.lock,
|
|
996
1041
|
// authorization.lock, chart-set.lock.
|
|
@@ -28,12 +28,15 @@ import { realpathSync } from 'node:fs';
|
|
|
28
28
|
|
|
29
29
|
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
30
30
|
|
|
31
|
-
/** The edition protocols this library interprets: version -> content digest.
|
|
31
|
+
/** The edition protocols this library interprets: version -> content digest. v10..v14 grow the kernel
|
|
32
|
+
* source closure only (entry subpaths and hashing unchanged), so one library speaks every row. */
|
|
32
33
|
export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
|
|
33
|
-
Object.freeze({
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}),
|
|
34
|
+
Object.freeze({ version: 9, contentDigest: '7cd6d760e7e7caaec3634a08c739debbdeeeaac4f1bad6791246a12f4f248e37' }),
|
|
35
|
+
Object.freeze({ version: 10, contentDigest: '660129065650a8300f7c0ad54f957b4d43507e1a44e093c9812d2b1bbf143104' }),
|
|
36
|
+
Object.freeze({ version: 11, contentDigest: '600451d2253c470c7d21bafe87260dfc3bfd43b6ad3eee2ef4d8b1f7ad08eb6e' }),
|
|
37
|
+
Object.freeze({ version: 12, contentDigest: 'cff51ea06b6ba7d95d02dcd6305b6a55061b428da48ae74dcbff8a5c8107ce41' }),
|
|
38
|
+
Object.freeze({ version: 13, contentDigest: '5f69fe73d1dea1650ef1757204a53ccd4030ade78bfa91959c2d01c937fc0e16' }),
|
|
39
|
+
Object.freeze({ version: 14, contentDigest: '403b6a491b5922df1a423ba8a2d6ae3d3900bb1caff4e8c0caa48de90a47eb9b' }),
|
|
37
40
|
]);
|
|
38
41
|
|
|
39
42
|
/** Where a kernel package publishes its identity and its contract, package-relative. */
|
|
@@ -64,6 +67,12 @@ export const EditionProtocolMismatchReason = Object.freeze({
|
|
|
64
67
|
ContractMismatch: 'contract-mismatch',
|
|
65
68
|
/** The kernel's protocol is not the one compiled into the running verifier. */
|
|
66
69
|
BundleMismatch: 'bundle-mismatch',
|
|
70
|
+
/** No commit on the kernel checkout's first-parent history declares a protocol this library supports. */
|
|
71
|
+
NoSupportedCommit: 'no-supported-commit',
|
|
72
|
+
/** A kernel commit's protocol declaration source exists but does not parse; refused, never skipped. */
|
|
73
|
+
Unparseable: 'unparseable',
|
|
74
|
+
/** An explicit kernel commit is not on the first-parent history of the checkout's develop head. */
|
|
75
|
+
NotOnDevelop: 'not-on-develop',
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
export class EditionProtocolMismatchError extends Error {
|
|
@@ -76,7 +85,8 @@ export class EditionProtocolMismatchError extends Error {
|
|
|
76
85
|
|
|
77
86
|
const describe = ({ version, contentDigest }) => `edition protocol v${version} (${contentDigest})`;
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
/** `v<N>, v<M>`: the versions this library supports, as every refusal names them. */
|
|
89
|
+
export const supportedList = () =>
|
|
80
90
|
SUPPORTED_EDITION_PROTOCOLS.map((protocol) => `v${protocol.version}`).join(', ');
|
|
81
91
|
|
|
82
92
|
/** Refuse an identity that states `provisional: true`; nothing provisional is ever read as a protocol. */
|
|
@@ -147,10 +157,11 @@ function contractIdentity(contractBytes) {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
150
|
-
* Refuse an identity this library does not support
|
|
151
|
-
*
|
|
160
|
+
* Refuse an identity this library does not support: a provisional one, an
|
|
161
|
+
* unsupported version, or a supported version at another digest. Returns it.
|
|
152
162
|
*/
|
|
153
|
-
export function
|
|
163
|
+
export function assertSupportedEditionIdentity(identity, source) {
|
|
164
|
+
assertFinalIdentity(identity, source);
|
|
154
165
|
const supported = SUPPORTED_EDITION_PROTOCOLS.find(
|
|
155
166
|
(protocol) => protocol.version === identity.version,
|
|
156
167
|
);
|
|
@@ -169,6 +180,22 @@ export function assertSupportedEditionProtocol({ identity, contractBytes, source
|
|
|
169
180
|
'contract without a new protocol version.',
|
|
170
181
|
);
|
|
171
182
|
}
|
|
183
|
+
return identity;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** True when `identity` is final and is a row of the supported table (version and digest). */
|
|
187
|
+
export const isSupportedEditionIdentity = (identity) =>
|
|
188
|
+
identity?.provisional === false &&
|
|
189
|
+
SUPPORTED_EDITION_PROTOCOLS.some(
|
|
190
|
+
(protocol) => protocol.version === identity.version && protocol.contentDigest === identity.contentDigest,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Refuse an identity this library does not support, and a contract file that
|
|
195
|
+
* does not state that identity. Returns the identity.
|
|
196
|
+
*/
|
|
197
|
+
export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
|
|
198
|
+
assertSupportedEditionIdentity(identity, source);
|
|
172
199
|
const stated = contractIdentity(contractBytes);
|
|
173
200
|
if (stated?.version !== identity.version || stated?.contentDigest !== identity.contentDigest) {
|
|
174
201
|
throw new EditionProtocolMismatchError(
|
|
@@ -38,6 +38,8 @@ const ROOT_MEANING = Object.freeze({
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
|
|
41
|
+
/** The kernel package's path inside the kernel-contracts repository, `/`-separated (a git tree path). */
|
|
42
|
+
export const KERNEL_PACKAGE_REPO_PATH = KERNEL_PACKAGE_SUBPATH.join('/');
|
|
41
43
|
|
|
42
44
|
let configured = Object.freeze({});
|
|
43
45
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// kernel-protocol-choice — which kernel commit a develop edition runs.
|
|
2
|
+
//
|
|
3
|
+
// The CLI runs the kernel's code and refuses a protocol it does not support
|
|
4
|
+
// (edition-protocol.mjs), so a develop edition that always took the kernel's
|
|
5
|
+
// develop HEAD would break on every kernel protocol bump until a CLI release
|
|
6
|
+
// caught up. Commits that declare the same protocol identity (version AND
|
|
7
|
+
// digest) are interchangeable by the digest contract, so the develop kernel is
|
|
8
|
+
// the NEWEST commit on the checkout's first-parent history whose declared
|
|
9
|
+
// identity is in this CLI's supported set: a deterministic choice, never a
|
|
10
|
+
// fallback.
|
|
11
|
+
//
|
|
12
|
+
// - head supported -> the head (reason head-supported)
|
|
13
|
+
// - head ahead of the CLI -> the newest supported (reason head-ahead, held)
|
|
14
|
+
// - an explicit 40-hex pin -> the pin, still checked (reason explicit-pin); it must be on the first-parent
|
|
15
|
+
// history of the develop head (refs/remotes/origin/develop), else not-on-develop
|
|
16
|
+
// - nothing supported -> refused (EditionProtocolMismatchError, no-supported-commit); never the head.
|
|
17
|
+
// The walk ends at the first commit with no declaration file (it predates the protocol).
|
|
18
|
+
// - a declaration that exists but does not parse, at the head or any walked commit
|
|
19
|
+
// -> refused (EditionProtocolMismatchError, unparseable) naming the commit; never skipped
|
|
20
|
+
//
|
|
21
|
+
// The declared identity is read from each commit's SOURCE
|
|
22
|
+
// (`<kernel package>/src/edition-protocol/lib/edition-protocol.ts`, the literal
|
|
23
|
+
// `EDITION_PROTOCOL = Object.freeze({ version, provisional, contentDigest })`),
|
|
24
|
+
// because the history is not built; the built kernel is checked again from its
|
|
25
|
+
// dist files by every loader. Reads git objects only; writes nothing.
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
EditionProtocolMismatchError,
|
|
31
|
+
EditionProtocolMismatchReason,
|
|
32
|
+
assertSupportedEditionIdentity,
|
|
33
|
+
isSupportedEditionIdentity,
|
|
34
|
+
supportedList,
|
|
35
|
+
} from './edition-protocol.mjs';
|
|
36
|
+
import { KERNEL_PACKAGE_REPO_PATH } from './edition-workspace.mjs';
|
|
37
|
+
|
|
38
|
+
/** The kernel source file that declares the edition protocol identity, repository-relative. */
|
|
39
|
+
export const KERNEL_EDITION_PROTOCOL_SOURCE = `${KERNEL_PACKAGE_REPO_PATH}/src/edition-protocol/lib/edition-protocol.ts`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The kernel checkout's develop head: the remote-tracking ref a full-history checkout (`fetch-depth: 0`)
|
|
43
|
+
* always has. Every choice and every explicit commit is judged against its first-parent history.
|
|
44
|
+
*/
|
|
45
|
+
export const KERNEL_DEVELOP_REF = 'refs/remotes/origin/develop';
|
|
46
|
+
|
|
47
|
+
/** Why a kernel commit was chosen. A closed set; `held` is exactly `head-ahead`. */
|
|
48
|
+
export const KernelChoiceReason = Object.freeze({
|
|
49
|
+
HeadSupported: 'head-supported',
|
|
50
|
+
HeadAhead: 'head-ahead',
|
|
51
|
+
ExplicitPin: 'explicit-pin',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
55
|
+
const MAX_GIT_OUTPUT = 256 * 1024 * 1024;
|
|
56
|
+
const DECLARATION = /export const EDITION_PROTOCOL\b[^=]*=\s*Object\.freeze\(\{([^}]*)\}\)/g;
|
|
57
|
+
const FIELDS = Object.freeze([
|
|
58
|
+
['version', /\bversion:\s*([1-9][0-9]*)\s*(?:,|$)/],
|
|
59
|
+
['provisional', /\bprovisional:\s*(true|false)\s*(?:,|$)/],
|
|
60
|
+
['contentDigest', /\bcontentDigest:\s*'([a-f0-9]{64})'\s*(?:,|$)/],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** A declaration source that exists but whose `EDITION_PROTOCOL` literal is absent or not exactly the three fields. */
|
|
64
|
+
const malformed = (label) =>
|
|
65
|
+
new EditionProtocolMismatchError(
|
|
66
|
+
EditionProtocolMismatchReason.Unparseable,
|
|
67
|
+
`${label} does not parse: it must declare exactly one EDITION_PROTOCOL = Object.freeze({ version: <positive integer>, ` +
|
|
68
|
+
"provisional: <boolean>, contentDigest: '<64 lowercase hex>' })",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The identity `{contentDigest, provisional, version}` the source text declares. Strict: exactly one
|
|
73
|
+
* declaration, each field exactly once, nothing else in the literal. Throws Unparseable otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export function parseDeclaredEditionProtocol(text, label) {
|
|
76
|
+
const declarations = [...text.matchAll(DECLARATION)];
|
|
77
|
+
if (declarations.length !== 1) {throw malformed(label);}
|
|
78
|
+
let rest = declarations[0][1];
|
|
79
|
+
const fields = {};
|
|
80
|
+
for (const [name, pattern] of FIELDS) {
|
|
81
|
+
const all = rest.match(new RegExp(pattern.source, 'g')) ?? [];
|
|
82
|
+
if (all.length !== 1) {throw malformed(label);}
|
|
83
|
+
fields[name] = all[0].match(pattern)[1];
|
|
84
|
+
rest = rest.replace(all[0], '');
|
|
85
|
+
}
|
|
86
|
+
if (rest.replace(/\/\/[^\n]*/g, '').trim() !== '') {throw malformed(label);}
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
contentDigest: fields.contentDigest,
|
|
89
|
+
provisional: fields.provisional === 'true',
|
|
90
|
+
version: Number(fields.version),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const describeShort = (identity) =>
|
|
95
|
+
identity === undefined ? 'no declared protocol' : `v${identity.version}${identity.provisional ? '-provisional' : ''}`;
|
|
96
|
+
|
|
97
|
+
/** Git against one checkout; `runGit(args, input?)` returns stdout as a string. */
|
|
98
|
+
function gitIn(repoDir, runGit) {
|
|
99
|
+
return (args, input) =>
|
|
100
|
+
runGit('git', ['-C', repoDir, ...args], {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
|
+
...(input === undefined ? {} : { input }),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertCommit(git, sha, label) {
|
|
109
|
+
if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) {
|
|
110
|
+
throw new Error(`${label} is not a 40-hex commit SHA: ${JSON.stringify(sha)}`);
|
|
111
|
+
}
|
|
112
|
+
let resolved;
|
|
113
|
+
try {
|
|
114
|
+
resolved = git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).trim();
|
|
115
|
+
} catch {
|
|
116
|
+
resolved = '';
|
|
117
|
+
}
|
|
118
|
+
if (resolved !== sha) {throw new Error(`${label} is not a commit in the kernel checkout`);}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The commit {@link KERNEL_DEVELOP_REF} names; refused when the checkout has no such ref. */
|
|
122
|
+
function readDevelopHead(git) {
|
|
123
|
+
let head;
|
|
124
|
+
try {
|
|
125
|
+
head = git(['rev-parse', '--verify', '--quiet', `${KERNEL_DEVELOP_REF}^{commit}`]).trim();
|
|
126
|
+
} catch {
|
|
127
|
+
head = '';
|
|
128
|
+
}
|
|
129
|
+
if (!SHA_PATTERN.test(head)) {
|
|
130
|
+
throw new Error(`the kernel checkout has no ${KERNEL_DEVELOP_REF}; check it out with full history (fetch-depth: 0)`);
|
|
131
|
+
}
|
|
132
|
+
return head;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The first-parent history of `head`, newest first. */
|
|
136
|
+
const firstParentHistory = (git, head) =>
|
|
137
|
+
git(['rev-list', '--first-parent', head]).split('\n').filter((line) => line.length > 0);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The declaring blob of every commit, by one `git cat-file --batch-check`: a Map commit -> blob sha, or
|
|
141
|
+
* undefined when the commit has no such file.
|
|
142
|
+
*/
|
|
143
|
+
function declaringBlobs(git, commits) {
|
|
144
|
+
const input = commits.map((commit) => `${commit}:${KERNEL_EDITION_PROTOCOL_SOURCE}\n`).join('');
|
|
145
|
+
const lines = git(['cat-file', '--batch-check'], input).split('\n').filter((line) => line.length > 0);
|
|
146
|
+
if (lines.length !== commits.length) {
|
|
147
|
+
throw new Error(`git cat-file answered ${lines.length} lines for ${commits.length} kernel commits`);
|
|
148
|
+
}
|
|
149
|
+
return new Map(
|
|
150
|
+
commits.map((commit, index) => {
|
|
151
|
+
const [sha, type] = lines[index].split(' ');
|
|
152
|
+
return [commit, type === 'blob' && SHA_PATTERN.test(sha) ? sha : undefined];
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A reader of the declared identity per blob (memoized); `undefined` for an absent file. Throws Unparseable. */
|
|
158
|
+
function identityReader(git) {
|
|
159
|
+
const byBlob = new Map();
|
|
160
|
+
return (commit, blob) => {
|
|
161
|
+
if (blob === undefined) {return undefined;}
|
|
162
|
+
if (!byBlob.has(blob)) {
|
|
163
|
+
const label = `the kernel commit ${commit}: ${KERNEL_EDITION_PROTOCOL_SOURCE}`;
|
|
164
|
+
byBlob.set(blob, parseDeclaredEditionProtocol(git(['cat-file', 'blob', blob]), label));
|
|
165
|
+
}
|
|
166
|
+
return byBlob.get(blob);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refuse a kernel commit whose declared identity this CLI does not support (absent, provisional, another
|
|
172
|
+
* version, or a supported version at another digest), naming `label`. The one check behind an explicit pin
|
|
173
|
+
* and `freeze-graph --kernel-sha`. Returns the identity.
|
|
174
|
+
*/
|
|
175
|
+
export function assertKernelCommitSupported({ repoDir, commit, label = `the kernel commit ${commit}`, runGit = execFileSync }) {
|
|
176
|
+
const git = gitIn(repoDir, runGit);
|
|
177
|
+
assertCommit(git, commit, label);
|
|
178
|
+
const developHead = readDevelopHead(git);
|
|
179
|
+
if (!firstParentHistory(git, developHead).includes(commit)) {
|
|
180
|
+
throw new EditionProtocolMismatchError(
|
|
181
|
+
EditionProtocolMismatchReason.NotOnDevelop,
|
|
182
|
+
`${label} is not on the first-parent history of the kernel develop head ${developHead} (${KERNEL_DEVELOP_REF}); ` +
|
|
183
|
+
'an explicit kernel commit must be one that kernel develop actually passed through.',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const identity = identityReader(git)(commit, declaringBlobs(git, [commit]).get(commit));
|
|
187
|
+
if (identity === undefined) {
|
|
188
|
+
throw new EditionProtocolMismatchError(
|
|
189
|
+
EditionProtocolMismatchReason.Undeclared,
|
|
190
|
+
`${label} declares no edition protocol (${KERNEL_EDITION_PROTOCOL_SOURCE} is absent); this CLI supports ${supportedList()}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return assertSupportedEditionIdentity(identity, `${label} (${KERNEL_EDITION_PROTOCOL_SOURCE})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Choose the develop kernel commit in the checkout at `repoDir` (fetched with full history).
|
|
198
|
+
*
|
|
199
|
+
* @param {object} request
|
|
200
|
+
* @param {string} request.repoDir the kernel-contracts checkout
|
|
201
|
+
* @param {string} [request.pin] an explicit 40-hex commit; it wins, and is still checked
|
|
202
|
+
* @returns {{ choice: {chosenSha: string, headSha: string, held: boolean, reason: string}, notice?: string }}
|
|
203
|
+
* `choice` is what is recorded; `notice` (only when held) is the run-summary line, never persisted.
|
|
204
|
+
*/
|
|
205
|
+
export function chooseKernelCommit({ repoDir, pin, runGit = execFileSync }) {
|
|
206
|
+
const git = gitIn(repoDir, runGit);
|
|
207
|
+
const headSha = readDevelopHead(git);
|
|
208
|
+
if (pin !== undefined) {
|
|
209
|
+
assertKernelCommitSupported({ repoDir, commit: pin, label: `the kernel pin ${pin}`, runGit });
|
|
210
|
+
return { choice: { chosenSha: pin, headSha, held: false, reason: KernelChoiceReason.ExplicitPin } };
|
|
211
|
+
}
|
|
212
|
+
const commits = firstParentHistory(git, headSha);
|
|
213
|
+
const blobs = declaringBlobs(git, commits);
|
|
214
|
+
const identityAt = identityReader(git);
|
|
215
|
+
// Fail closed: an unparseable declaration (head or walked) is refused by name, never skipped; a commit
|
|
216
|
+
// with no declaration file predates the protocol, so the walk ends there.
|
|
217
|
+
const headIdentity = identityAt(headSha, blobs.get(headSha));
|
|
218
|
+
if (isSupportedEditionIdentity(headIdentity)) {
|
|
219
|
+
return { choice: { chosenSha: headSha, headSha, held: false, reason: KernelChoiceReason.HeadSupported } };
|
|
220
|
+
}
|
|
221
|
+
for (const commit of headIdentity === undefined ? [] : commits.slice(1)) {
|
|
222
|
+
const identity = identityAt(commit, blobs.get(commit));
|
|
223
|
+
if (identity === undefined) {break;}
|
|
224
|
+
if (isSupportedEditionIdentity(identity)) {
|
|
225
|
+
return {
|
|
226
|
+
choice: { chosenSha: commit, headSha, held: true, reason: KernelChoiceReason.HeadAhead },
|
|
227
|
+
notice:
|
|
228
|
+
`kernel held at ${describeShort(identity)} (${commit.slice(0, 12)}), ` +
|
|
229
|
+
`head at ${describeShort(headIdentity)} (${headSha.slice(0, 12)}): raise the CLI`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw new EditionProtocolMismatchError(
|
|
234
|
+
EditionProtocolMismatchReason.NoSupportedCommit,
|
|
235
|
+
`no commit on the first-parent history of the kernel head ${headSha} declares an edition protocol this CLI ` +
|
|
236
|
+
`supports (this CLI supports ${supportedList()}; the head declares ${describeShort(headIdentity)}` +
|
|
237
|
+
`${headIdentity === undefined ? '' : ` at ${headIdentity.contentDigest}`}). Raise the CLI; the head is never taken instead.`,
|
|
238
|
+
);
|
|
239
|
+
}
|