@xemahq/xema 0.54.0 → 0.56.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/edition/verify-image-evidence.bundle.mjs +1996 -1012
- package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
- package/dist/runtime/lock-loader.d.ts +2 -1
- package/dist/runtime/lock-loader.d.ts.map +1 -1
- package/dist/runtime/lock-loader.js +15 -5
- package/dist/runtime/lock-loader.js.map +1 -1
- package/edition/README.md +8 -3
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/customer-edition-verbs.mjs +15 -4
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-resolve.mjs +18 -10
- package/edition/develop-edition.mjs +30 -9
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +6 -4
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- package/edition/develop-select-records.mjs +29 -21
- package/edition/develop-selection-line.mjs +30 -0
- package/edition/develop-selection-pins.mjs +30 -0
- package/edition/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +6 -20
- package/edition/external-source-walk.mjs +37 -15
- package/edition/image-evidence.mjs +6 -2
- package/edition/image-lock-service-rows.mjs +4 -4
- package/edition/image-subject-identity.mjs +24 -0
- package/edition/images-from-record.mjs +46 -110
- package/edition/lib.mjs +24 -26
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +224 -12
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +11 -1
- package/package.json +3 -3
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
// anyone comparing their outcomes needs to know which one they ran.
|
|
34
34
|
|
|
35
35
|
import { execFileSync } from 'node:child_process';
|
|
36
|
-
import {
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
38
|
import { join, relative, sep } from 'node:path';
|
|
38
39
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
39
40
|
|
|
@@ -72,6 +73,7 @@ import {
|
|
|
72
73
|
} from './lib.mjs';
|
|
73
74
|
import { assertReleaseContent } from './release-channel-guard.mjs';
|
|
74
75
|
import { composeEdition } from './resolve-distribution.mjs';
|
|
76
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
75
77
|
import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
|
|
76
78
|
import {
|
|
77
79
|
assertOciBundleSourceOrigin,
|
|
@@ -1031,6 +1033,18 @@ export class ComposedProcessInputsError extends Error {
|
|
|
1031
1033
|
* the `components[].artifact.path` mapping) is one of them. Returns one row
|
|
1032
1034
|
* per biome: the service ids carried and the ones not.
|
|
1033
1035
|
*/
|
|
1036
|
+
/** A component's declared `imageClass` validated with the kernel's own schema; absent stays undefined (the default applies). */
|
|
1037
|
+
function declaredImageClass(declared, subject, enums) {
|
|
1038
|
+
if (declared === undefined) {return undefined;}
|
|
1039
|
+
const parsed = enums.ComposedImageClassSchema.safeParse(declared);
|
|
1040
|
+
if (!parsed.success) {
|
|
1041
|
+
throw new ComposedProcessInputsError(
|
|
1042
|
+
`service "${subject.id}" of ${subject.repo} declares components[].imageClass ${JSON.stringify(declared)}, which is not an image class the kernel knows`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
return parsed.data;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1034
1048
|
function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
|
|
1035
1049
|
const inputsPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
1036
1050
|
const { dir } = locate(repo);
|
|
@@ -1053,11 +1067,12 @@ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
|
|
|
1053
1067
|
const byBiome = new Map();
|
|
1054
1068
|
for (const subject of structure.subjects) {
|
|
1055
1069
|
if (subject.kind !== enums.RuntimeImageSubjectKind.Service || subject.repo !== repo) {continue;}
|
|
1056
|
-
const { path: servicePath, componentKind } = subjectServiceSource(subject, sourceCommit, locate);
|
|
1070
|
+
const { path: servicePath, componentKind, imageClass: declaredClass } = subjectServiceSource(subject, sourceCommit, locate);
|
|
1071
|
+
const imageClass = declaredImageClass(declaredClass, subject, enums);
|
|
1057
1072
|
for (const owner of subject.owners) {
|
|
1058
1073
|
if (owner.kind !== enums.RuntimeImageOwnerKind.Biome) {continue;}
|
|
1059
1074
|
const row = byBiome.get(owner.biomeId) ??
|
|
1060
|
-
{ biomeId: owner.biomeId, carried: [], uncarried: [], carriedWorkers: [], packageDirs: {} };
|
|
1075
|
+
{ biomeId: owner.biomeId, carried: [], uncarried: [], carriedWorkers: [], packageDirs: {}, imageClasses: {} };
|
|
1061
1076
|
if (!carried.has(servicePath)) {
|
|
1062
1077
|
row.uncarried.push(subject.id);
|
|
1063
1078
|
} else if (componentKind === 'worker') {
|
|
@@ -1067,6 +1082,8 @@ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
|
|
|
1067
1082
|
row.carried.push(subject.id);
|
|
1068
1083
|
// The member's package directory IS the composed build's own input entry.
|
|
1069
1084
|
row.packageDirs[subject.id] = servicePath;
|
|
1085
|
+
// The class its manifest declares (`components[].imageClass`); absent stays undefined (the kernel default applies).
|
|
1086
|
+
row.imageClasses[subject.id] = imageClass;
|
|
1070
1087
|
}
|
|
1071
1088
|
byBiome.set(owner.biomeId, row);
|
|
1072
1089
|
}
|
|
@@ -1140,6 +1157,78 @@ export function readComposedProcessDeclarations(
|
|
|
1140
1157
|
return declarations;
|
|
1141
1158
|
}
|
|
1142
1159
|
|
|
1160
|
+
/**
|
|
1161
|
+
* The image class of one composed artifact, DERIVED from its members' manifests: each member's `components[].imageClass`,
|
|
1162
|
+
* absent meaning the kernel's `DEFAULT_COMPOSED_IMAGE_CLASS`. All members must agree; a mixed artifact is refused naming
|
|
1163
|
+
* the artifact and the members of each class.
|
|
1164
|
+
*/
|
|
1165
|
+
export function composedArtifactImageClass(artifactId, memberRows, declaredClassOf, enums) {
|
|
1166
|
+
const byClass = new Map();
|
|
1167
|
+
for (const { service } of memberRows) {
|
|
1168
|
+
const declared = declaredClassOf.get(service);
|
|
1169
|
+
const imageClass = declared ?? enums.DEFAULT_COMPOSED_IMAGE_CLASS;
|
|
1170
|
+
byClass.set(imageClass, [...(byClass.get(imageClass) ?? []), service]);
|
|
1171
|
+
}
|
|
1172
|
+
if (byClass.size > 1) {
|
|
1173
|
+
const detail = [...byClass].sort(([a], [b]) => compareStrings(a, b)).map(([imageClass, services]) => `${imageClass}: ${services.join(', ')}`).join('; ');
|
|
1174
|
+
throw new ComposedProcessInputsError(
|
|
1175
|
+
`composed-process artifact "${artifactId}" mixes image classes across its members (${detail}) — ` +
|
|
1176
|
+
'an artifact has one class; split the members or align their components[].imageClass',
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
return [...byClass.keys()][0];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** The services the edition SELECTS, read off its distribution lock: every service of every non-web biome whose image is its own (a shared-image worker is not one). */
|
|
1183
|
+
export function editionRosterServices(distributionLock) {
|
|
1184
|
+
return distributionLock.biomes
|
|
1185
|
+
.filter((biome) => biome.target !== 'web')
|
|
1186
|
+
.flatMap((biome) => {
|
|
1187
|
+
const shared = new Set(Object.keys(biome.workerImages ?? {}));
|
|
1188
|
+
return (biome.services ?? []).filter((service) => !shared.has(service));
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Refuse a build that does not cover every service of the edition roster: each must be a dedicated service subject or a
|
|
1194
|
+
* member of a built composed artifact. A service neither is never dropped from the lock silently, and the carry-forward
|
|
1195
|
+
* (which sees only what was built) is never the one to lose it.
|
|
1196
|
+
*/
|
|
1197
|
+
export function assertBuiltCoversEditionRoster({ rosterServices, serviceSubjects, composedBuilt }) {
|
|
1198
|
+
const covered = new Set([
|
|
1199
|
+
...serviceSubjects.map((subject) => subject.id),
|
|
1200
|
+
...composedBuilt.flatMap((artifact) => (artifact.members ?? []).map((member) => member.service)),
|
|
1201
|
+
]);
|
|
1202
|
+
const missing = [...new Set(rosterServices)].filter((service) => !covered.has(service)).sort(compareStrings);
|
|
1203
|
+
if (missing.length > 0) {
|
|
1204
|
+
throw new ComposedProcessInputsError(
|
|
1205
|
+
`the build covers no image for ${missing.map((service) => `"${service}"`).join(', ')} of the edition roster — ` +
|
|
1206
|
+
'a service of the edition is never dropped from the lock',
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The members of a composed subject with the key of each: the member service's own image-input hash (the one function the packager's tag is emitted from). */
|
|
1212
|
+
export function composedMemberKeys({ subject, repoDirectory, sourceCommit, crossRepoInputsByConsumer, buildStepDirectories, hash = exactImageBuildInputHash }) {
|
|
1213
|
+
return subject.members.map((member) => ({
|
|
1214
|
+
...member,
|
|
1215
|
+
key: hash(
|
|
1216
|
+
{
|
|
1217
|
+
repoDir: repoDirectory,
|
|
1218
|
+
service: member.service,
|
|
1219
|
+
servicePath: member.packageDir,
|
|
1220
|
+
commit: sourceCommit,
|
|
1221
|
+
crossRepoInputs: [
|
|
1222
|
+
...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
|
|
1223
|
+
...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${member.service}`) ?? []),
|
|
1224
|
+
],
|
|
1225
|
+
buildStepDirectories,
|
|
1226
|
+
},
|
|
1227
|
+
imageInputSourceHashAlgorithm(),
|
|
1228
|
+
),
|
|
1229
|
+
}));
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1143
1232
|
/**
|
|
1144
1233
|
* Add ONE `composed-process` subject per declaring repository to an edition's
|
|
1145
1234
|
* structure. Pure over its inputs, so the structural check and the source
|
|
@@ -1186,6 +1275,7 @@ export function withComposedProcessSubjects(
|
|
|
1186
1275
|
const biomeOwners = [];
|
|
1187
1276
|
const members = new Set();
|
|
1188
1277
|
const packageDirOf = new Map();
|
|
1278
|
+
const imageClassOf = new Map();
|
|
1189
1279
|
for (const row of declaration.carriage) {
|
|
1190
1280
|
// A worker is never a member. One the composed build carries must run
|
|
1191
1281
|
// from a carried sibling's image (`workerImages`, declared in the
|
|
@@ -1220,6 +1310,7 @@ export function withComposedProcessSubjects(
|
|
|
1220
1310
|
biomeOwners.push({ kind: enums.RuntimeImageOwnerKind.Biome, biomeId: row.biomeId });
|
|
1221
1311
|
for (const id of row.carried) {members.add(id);}
|
|
1222
1312
|
for (const [id, packageDir] of Object.entries(row.packageDirs ?? {})) {packageDirOf.set(id, packageDir);}
|
|
1313
|
+
for (const [id, imageClass] of Object.entries(row.imageClasses ?? {})) {imageClassOf.set(id, imageClass);}
|
|
1223
1314
|
}
|
|
1224
1315
|
if (biomeOwners.length === 0) {
|
|
1225
1316
|
throw new Error(
|
|
@@ -1295,6 +1386,7 @@ export function withComposedProcessSubjects(
|
|
|
1295
1386
|
}
|
|
1296
1387
|
memberRows.sort((a, b) => compareStrings(a.service, b.service));
|
|
1297
1388
|
const id = composedProcessSubjectId(declaration.sourceId);
|
|
1389
|
+
const imageClass = composedArtifactImageClass(id, memberRows, imageClassOf, enums);
|
|
1298
1390
|
addSubject(subjects, {
|
|
1299
1391
|
kind: enums.RuntimeImageSubjectKind.ComposedProcess,
|
|
1300
1392
|
id,
|
|
@@ -1307,6 +1399,7 @@ export function withComposedProcessSubjects(
|
|
|
1307
1399
|
requiredPlatforms,
|
|
1308
1400
|
owners: biomeOwners,
|
|
1309
1401
|
members: memberRows,
|
|
1402
|
+
imageClass,
|
|
1310
1403
|
});
|
|
1311
1404
|
}
|
|
1312
1405
|
return {
|
|
@@ -1422,7 +1515,7 @@ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES)
|
|
|
1422
1515
|
const toRepoRelative = (absolute) =>
|
|
1423
1516
|
relative(repoDirectory, absolute).split(sep).join('/') || '.';
|
|
1424
1517
|
if (subject.sourceManifestPath) {
|
|
1425
|
-
return { path: toRepoRelative(join(base, subject.path)), componentKind: 'service' };
|
|
1518
|
+
return { path: toRepoRelative(join(base, subject.path)), componentKind: 'service', imageClass: undefined };
|
|
1426
1519
|
}
|
|
1427
1520
|
const biomePath = toRepoRelative(join(base, subject.path));
|
|
1428
1521
|
const manifestPath =
|
|
@@ -1461,6 +1554,7 @@ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES)
|
|
|
1461
1554
|
}
|
|
1462
1555
|
let servicePath;
|
|
1463
1556
|
let componentKind = 'service';
|
|
1557
|
+
let imageClass;
|
|
1464
1558
|
if (v4ServiceComponents.length > 0) {
|
|
1465
1559
|
// A SERVICE is identified by `protocol.serviceName`; a WORKER has no
|
|
1466
1560
|
// service protocol and is identified by its component `key`. Matching only
|
|
@@ -1485,6 +1579,7 @@ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES)
|
|
|
1485
1579
|
}
|
|
1486
1580
|
servicePath = component.artifact.path;
|
|
1487
1581
|
componentKind = component.kind;
|
|
1582
|
+
imageClass = component.imageClass;
|
|
1488
1583
|
} else {
|
|
1489
1584
|
if (legacyApis.length === 0) {
|
|
1490
1585
|
throw new Error(
|
|
@@ -1502,7 +1597,7 @@ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES)
|
|
|
1502
1597
|
}
|
|
1503
1598
|
servicePath = api.path;
|
|
1504
1599
|
}
|
|
1505
|
-
return { path: toRepoRelative(join(base, subject.path, servicePath)), componentKind };
|
|
1600
|
+
return { path: toRepoRelative(join(base, subject.path, servicePath)), componentKind, imageClass };
|
|
1506
1601
|
}
|
|
1507
1602
|
|
|
1508
1603
|
function assertSourceManifestParity(structure, commitByRepo, locate = FIRST_PARTY_TREES) {
|
|
@@ -1787,7 +1882,7 @@ export function supplementalSourcesFor(runtimeSourceIndex) {
|
|
|
1787
1882
|
* of one (a customer's own rows, merged with the catalogue's) parses the lock
|
|
1788
1883
|
* it completes. `resolveImages` is these rows parsed as the whole lock.
|
|
1789
1884
|
*/
|
|
1790
|
-
export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages } = {}) {
|
|
1885
|
+
export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock = null } = {}) {
|
|
1791
1886
|
assertCanonicalDistributionId(id);
|
|
1792
1887
|
if ((inputs === undefined) !== (lock === undefined)) {
|
|
1793
1888
|
throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
|
|
@@ -1902,9 +1997,20 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, reco
|
|
|
1902
1997
|
subject;
|
|
1903
1998
|
void ignoredSourceManifestPath;
|
|
1904
1999
|
// With a build record the content hash and source commit are the record's, carried and never recomputed.
|
|
2000
|
+
const isComposed = subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess;
|
|
1905
2001
|
const hashed = recordedImages === undefined ? {
|
|
1906
2002
|
...lockedSubject,
|
|
1907
2003
|
sourceCommit,
|
|
2004
|
+
// The hashing lane computes each member's key itself: the member service's own image-input hash.
|
|
2005
|
+
...(isComposed ? {
|
|
2006
|
+
members: composedMemberKeys({
|
|
2007
|
+
subject,
|
|
2008
|
+
repoDirectory,
|
|
2009
|
+
sourceCommit,
|
|
2010
|
+
crossRepoInputsByConsumer,
|
|
2011
|
+
buildStepDirectories: buildStepDirectoriesByRepo.get(subject.repo) ?? [],
|
|
2012
|
+
}),
|
|
2013
|
+
} : {}),
|
|
1908
2014
|
contentHash: exactImageBuildInputHash(
|
|
1909
2015
|
{
|
|
1910
2016
|
repoDir: repoDirectory,
|
|
@@ -1929,6 +2035,20 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, reco
|
|
|
1929
2035
|
}
|
|
1930
2036
|
// Every subject missing from, contradicted by or extra in the build record, refused at once.
|
|
1931
2037
|
recordedImages?.settle();
|
|
2038
|
+
// The composed artifacts are sealed with their generation: the roster's composed services must all be built, then the
|
|
2039
|
+
// kernel carries the previous lock's still-pinned artifacts forward (`previousLock` null or a v4 lock: generation 1).
|
|
2040
|
+
const ComposedProcess = schemas.RuntimeImageSubjectKind.ComposedProcess;
|
|
2041
|
+
const composedBuilt = subjects.filter((subject) => subject.kind === ComposedProcess);
|
|
2042
|
+
assertBuiltCoversEditionRoster({
|
|
2043
|
+
rosterServices: editionRosterServices(distributionLock),
|
|
2044
|
+
serviceSubjects: [...subjects, ...structure.thirdPartySubjects].filter((subject) => subject.kind === schemas.RuntimeImageSubjectKind.Service),
|
|
2045
|
+
composedBuilt,
|
|
2046
|
+
});
|
|
2047
|
+
if (composedBuilt.length > 0 || previousLock !== null) {
|
|
2048
|
+
const sealed = schemas.carryForwardComposedArtifacts({ previous: previousLock, built: composedBuilt });
|
|
2049
|
+
subjects.splice(0, subjects.length, ...subjects.filter((subject) => subject.kind !== ComposedProcess), ...sealed);
|
|
2050
|
+
subjects.sort(compareSubject);
|
|
2051
|
+
}
|
|
1932
2052
|
// Release lane only: a develop snapshot or customer overlay is not covered by
|
|
1933
2053
|
// the committed ledger.
|
|
1934
2054
|
if (inputs === undefined) {
|
|
@@ -1968,9 +2088,82 @@ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, reco
|
|
|
1968
2088
|
};
|
|
1969
2089
|
}
|
|
1970
2090
|
|
|
2091
|
+
export const PREVIOUS_SEAL_USAGE =
|
|
2092
|
+
'exactly one of `--previous-lock <images.lock.json> --previous-lock-sha256 <hex>` (the caller takes the digest from the images.lock ' +
|
|
2093
|
+
'layer of the latest signed, published edition of this distribution, under the per-edition concurrency group) or `--first-seal`';
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input: the previous lock
|
|
2097
|
+
* with the sha256 of its bytes (taken by the caller from the signed, published edition's images.lock layer), or
|
|
2098
|
+
* `--first-seal` (null: generation 1). Neither, both kinds, or half of the pair is refused; the bytes must hash to the given
|
|
2099
|
+
* digest, and the lock is parsed with the kernel's `parseImageLock` and must be this edition's own (`distributionId`).
|
|
2100
|
+
*/
|
|
2101
|
+
export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false }) {
|
|
2102
|
+
const given = [previousLockPath, previousLockSha256].filter((value) => value !== undefined);
|
|
2103
|
+
if (firstSeal) {
|
|
2104
|
+
if (given.length > 0) {
|
|
2105
|
+
throw new Error(`--first-seal and a previous lock (--previous-lock/--previous-lock-sha256) are both given; give ${PREVIOUS_SEAL_USAGE}`);
|
|
2106
|
+
}
|
|
2107
|
+
return null;
|
|
2108
|
+
}
|
|
2109
|
+
if (given.length !== 2) {
|
|
2110
|
+
throw new Error(
|
|
2111
|
+
given.length === 0
|
|
2112
|
+
? `no previous seal is given; give ${PREVIOUS_SEAL_USAGE}`
|
|
2113
|
+
: `--previous-lock and --previous-lock-sha256 go together (one was given); give ${PREVIOUS_SEAL_USAGE}`,
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
const bytes = readFileSync(previousLockPath);
|
|
2117
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
2118
|
+
if (actual !== previousLockSha256) {
|
|
2119
|
+
throw new Error(`--previous-lock ${previousLockPath} has sha256 ${actual}, not the given --previous-lock-sha256 ${previousLockSha256}`);
|
|
2120
|
+
}
|
|
2121
|
+
return parsePreviousLock(distributionId, readStrictJsonFile(previousLockPath), '--previous-lock');
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** The in-repo lane's previous lock: the committed file at `outPath`, parsed before it is overwritten; null (first seal) when there is none. */
|
|
2125
|
+
export async function readCommittedPreviousLock(distributionId, outPath) {
|
|
2126
|
+
return existsSync(outPath) ? parsePreviousLock(distributionId, readStrictJsonFile(outPath), outPath) : null;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
async function parsePreviousLock(distributionId, document, label) {
|
|
2130
|
+
const previous = (await loadKernelDistributionSchemas()).parseImageLock(document);
|
|
2131
|
+
if (previous.distributionId !== distributionId) {
|
|
2132
|
+
throw new Error(`${label} is the images lock of "${previous.distributionId}", not of "${distributionId}"`);
|
|
2133
|
+
}
|
|
2134
|
+
return previous;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* The composed artifacts of a STRUCTURAL view (no source hashing), carried forward from `previousLock` through the kernel's own
|
|
2139
|
+
* `carryForwardComposedArtifacts`, exactly as a reseal does. A structural subject has no content hash or member keys; each
|
|
2140
|
+
* composed id takes them from its newest previous artifact (a structure the previous lock does not cover keeps none, and the
|
|
2141
|
+
* kernel refuses it: the caller reports that as out of date). Source drift is `--check-sources`' job, through `resolveImages`.
|
|
2142
|
+
*/
|
|
2143
|
+
export function carryComposedForwardStructurally({ subjects, previousLock, schemas }) {
|
|
2144
|
+
const ComposedProcess = schemas.RuntimeImageSubjectKind.ComposedProcess;
|
|
2145
|
+
const newest = new Map();
|
|
2146
|
+
for (const subject of previousLock?.subjects ?? []) {
|
|
2147
|
+
if (subject.kind === ComposedProcess && (newest.get(subject.id)?.generation ?? 0) <= (subject.generation ?? 0)) {newest.set(subject.id, subject);}
|
|
2148
|
+
}
|
|
2149
|
+
const built = subjects.filter((subject) => subject.kind === ComposedProcess).map((subject) => {
|
|
2150
|
+
const previous = newest.get(subject.id);
|
|
2151
|
+
if (previous === undefined) {return subject;}
|
|
2152
|
+
const keyOf = new Map((previous.members ?? []).map((member) => [member.service, member.key]));
|
|
2153
|
+
return {
|
|
2154
|
+
...subject,
|
|
2155
|
+
contentHash: previous.contentHash,
|
|
2156
|
+
members: (subject.members ?? []).map((member) => (keyOf.has(member.service) ? { ...member, key: keyOf.get(member.service) } : member)),
|
|
2157
|
+
};
|
|
2158
|
+
});
|
|
2159
|
+
if (built.length === 0 && previousLock === null) {return subjects;}
|
|
2160
|
+
const sealed = schemas.carryForwardComposedArtifacts({ previous: previousLock, built });
|
|
2161
|
+
return [...subjects.filter((subject) => subject.kind !== ComposedProcess), ...sealed].sort(compareSubject);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1971
2164
|
/** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
|
|
1972
|
-
export async function resolveImages(id, { inputs, lock, onSubjectHashed, recordedImages } = {}) {
|
|
1973
|
-
const { subjects, thirdPartySubjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages });
|
|
2165
|
+
export async function resolveImages(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock = null } = {}) {
|
|
2166
|
+
const { subjects, thirdPartySubjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock });
|
|
1974
2167
|
const schemas = await loadKernelDistributionSchemas();
|
|
1975
2168
|
const imageLock = validateImmutableImageLock(
|
|
1976
2169
|
schemas.parseImageLock({
|
|
@@ -1994,7 +2187,7 @@ export async function resolveImages(id, { inputs, lock, onSubjectHashed, recorde
|
|
|
1994
2187
|
*/
|
|
1995
2188
|
export async function writeResolvedImages(
|
|
1996
2189
|
id,
|
|
1997
|
-
{ trustedWriteV3 = false, log = true } = {},
|
|
2190
|
+
{ trustedWriteV3 = false, log = true, previousLockPath = undefined, previousLockSha256 = undefined, firstSeal = false, previousFromTarget = false } = {},
|
|
1998
2191
|
) {
|
|
1999
2192
|
assertCanonicalDistributionId(id);
|
|
2000
2193
|
if (!trustedWriteV3) {
|
|
@@ -2004,12 +2197,18 @@ export async function writeResolvedImages(
|
|
|
2004
2197
|
}
|
|
2005
2198
|
// The RELEASE lock is published-only: a develop edition resolves through
|
|
2006
2199
|
// `develop-edition.mjs` and never reaches this writer.
|
|
2007
|
-
const { imageLock, commits, thirdPartySubjects } = await resolveImages(id);
|
|
2008
2200
|
const outPath = distributionOutputPath(
|
|
2009
2201
|
editionsRoot(),
|
|
2010
2202
|
id,
|
|
2011
2203
|
'images.lock.json',
|
|
2012
2204
|
);
|
|
2205
|
+
// The in-repo lane: the previous lock is the committed file at the target path (git is its authority), read before it is
|
|
2206
|
+
// overwritten; with no file there, this is the first seal.
|
|
2207
|
+
const previousLock = previousFromTarget
|
|
2208
|
+
? await readCommittedPreviousLock(id, outPath)
|
|
2209
|
+
: await resolvePreviousSeal({ distributionId: id, previousLockPath, previousLockSha256, firstSeal });
|
|
2210
|
+
if (previousFromTarget && previousLock === null && log) {console.log(`[dist:images] ${id}: first seal: no previous images.lock.json`);}
|
|
2211
|
+
const { imageLock, commits, thirdPartySubjects } = await resolveImages(id, { previousLock });
|
|
2013
2212
|
assertReleaseContent(imageLock, `the images lock for "${id}"`);
|
|
2014
2213
|
writeFileSync(outPath, serializeJson(imageLock));
|
|
2015
2214
|
if (log) {
|
|
@@ -2031,7 +2230,7 @@ async function main() {
|
|
|
2031
2230
|
const trustedWriteV3 = process.argv.includes('--trusted-write-v3');
|
|
2032
2231
|
if (!id) {
|
|
2033
2232
|
throw new Error(
|
|
2034
|
-
|
|
2233
|
+
`usage: resolve-images.mjs <distribution-id> --trusted-write-v3 (${PREVIOUS_SEAL_USAGE})`,
|
|
2035
2234
|
);
|
|
2036
2235
|
}
|
|
2037
2236
|
// `--editions-root` (and its `=` spelling, plus the value that follows
|
|
@@ -2041,15 +2240,28 @@ async function main() {
|
|
|
2041
2240
|
// command that reads it.
|
|
2042
2241
|
const rest = process.argv.slice(3);
|
|
2043
2242
|
const unknownArgs = [];
|
|
2243
|
+
let previousLockPath;
|
|
2244
|
+
let previousLockSha256;
|
|
2245
|
+
let firstSeal = false;
|
|
2044
2246
|
for (let i = 0; i < rest.length; i += 1) {
|
|
2045
2247
|
const argument = rest[i];
|
|
2046
2248
|
if (argument === '--trusted-write-v3') {continue;}
|
|
2249
|
+
if (argument === '--first-seal') {
|
|
2250
|
+
firstSeal = true;
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
if (argument === '--previous-lock' || argument === '--previous-lock-sha256') {
|
|
2254
|
+
i += 1;
|
|
2255
|
+
if (rest[i] === undefined) {throw new Error(`${argument} needs a value`);}
|
|
2256
|
+
if (argument === '--previous-lock') {previousLockPath = rest[i];} else {previousLockSha256 = rest[i];}
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2047
2259
|
unknownArgs.push(argument);
|
|
2048
2260
|
}
|
|
2049
2261
|
if (unknownArgs.length > 0) {
|
|
2050
2262
|
throw new Error(`unknown argument "${unknownArgs[0]}"`);
|
|
2051
2263
|
}
|
|
2052
|
-
await writeResolvedImages(id, { trustedWriteV3 });
|
|
2264
|
+
await writeResolvedImages(id, { trustedWriteV3, previousLockPath, previousLockSha256, firstSeal });
|
|
2053
2265
|
}
|
|
2054
2266
|
|
|
2055
2267
|
if (isEntryModule(import.meta.url)) {
|
|
@@ -128,6 +128,7 @@ import {
|
|
|
128
128
|
isNotFinalYet,
|
|
129
129
|
readFleetImageLocks,
|
|
130
130
|
} from './fleet-coordinate-finality.mjs';
|
|
131
|
+
import { imageSubjectIdentity } from './image-subject-identity.mjs';
|
|
131
132
|
import { adoptCatalogueKernel } from './kernel-package.mjs';
|
|
132
133
|
import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
|
|
133
134
|
import { assertPlatformCanParseLock } from './platform-lock-readability.mjs';
|
|
@@ -239,12 +240,12 @@ export function planDigestSeal({ subjects, verified }) {
|
|
|
239
240
|
);
|
|
240
241
|
}
|
|
241
242
|
const byId = new Map(
|
|
242
|
-
verified.map((entry) => [
|
|
243
|
+
verified.map((entry) => [imageSubjectIdentity({ kind: entry.subjectKind, id: entry.subjectId, imageRef: entry.subjectImageRef, contentHash: entry.subjectContentHash }), entry]),
|
|
243
244
|
);
|
|
244
245
|
const writes = [];
|
|
245
246
|
const unchanged = [];
|
|
246
247
|
for (const subject of subjects) {
|
|
247
|
-
const key =
|
|
248
|
+
const key = imageSubjectIdentity(subject);
|
|
248
249
|
const entry = byId.get(key);
|
|
249
250
|
if (!entry) {
|
|
250
251
|
throw new Error(
|
|
@@ -346,7 +347,7 @@ export function classifyPublicationState({
|
|
|
346
347
|
}
|
|
347
348
|
const unsealed = subjects
|
|
348
349
|
.filter((subject) => typeof subject.imageDigest !== 'string' || subject.imageDigest.length === 0)
|
|
349
|
-
.map((subject) =>
|
|
350
|
+
.map((subject) => imageSubjectIdentity(subject));
|
|
350
351
|
if (unsealed.length === 0) {
|
|
351
352
|
return {
|
|
352
353
|
verdict: PublicationVerdict.Publishable,
|
|
@@ -485,18 +486,15 @@ export async function buildSealedEditionLock(options) {
|
|
|
485
486
|
return prepared.result.lock;
|
|
486
487
|
}
|
|
487
488
|
|
|
488
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// validation, not a round-trip, and writing back a schema-normalised document
|
|
495
|
-
// would silently reformat or drop anything the parser does not model.
|
|
496
|
-
const rawImages = JSON.parse(readFileSync(imagesPath, 'utf8'));
|
|
489
|
+
/**
|
|
490
|
+
* Write each planned digest into the raw images lock's subject of the same identity (a composed artifact is its
|
|
491
|
+
* `imageRef:contentHash`, so two live generations take their own digests), and count them. A plan the file does not
|
|
492
|
+
* match, in either direction, is refused: nothing is written.
|
|
493
|
+
*/
|
|
494
|
+
export function applyDigestsToSubjects(rawImages, digests) {
|
|
497
495
|
let applied = 0;
|
|
498
496
|
for (const subject of rawImages.subjects) {
|
|
499
|
-
const digest = digests.get(
|
|
497
|
+
const digest = digests.get(imageSubjectIdentity(subject));
|
|
500
498
|
if (digest) {
|
|
501
499
|
subject.imageDigest = digest;
|
|
502
500
|
applied += 1;
|
|
@@ -508,6 +506,19 @@ async function prepareEditionSeal({ distributionId, root, digests = new Map(), c
|
|
|
508
506
|
'in the file on disk; nothing written',
|
|
509
507
|
);
|
|
510
508
|
}
|
|
509
|
+
return applied;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The shared in-memory half of the seal: reads both locks, applies `digests`, seals or verifies. Writes nothing. */
|
|
513
|
+
async function prepareEditionSeal({ distributionId, root, digests = new Map(), check = false, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, fetchManifest = fetchRegistryManifest, checkAdapters = true }) {
|
|
514
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
515
|
+
const lockPath = join(root, distributionId, 'distribution.lock.json');
|
|
516
|
+
const imagesPath = join(root, distributionId, 'images.lock.json');
|
|
517
|
+
// The file is re-read rather than the PARSED lock re-serialised: the parse is a
|
|
518
|
+
// validation, not a round-trip, and writing back a schema-normalised document
|
|
519
|
+
// would silently reformat or drop anything the parser does not model.
|
|
520
|
+
const rawImages = JSON.parse(readFileSync(imagesPath, 'utf8'));
|
|
521
|
+
const applied = applyDigestsToSubjects(rawImages, digests);
|
|
511
522
|
const raw = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
512
523
|
const imageLock = schemas.parseImageLock(rawImages);
|
|
513
524
|
// A release seal reads the EDITION's indexes and pins (a held repository is sealed at the held commit); a
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs';
|
|
7
|
+
import { certificateConstraintArgs, certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
|
|
8
8
|
import { COSIGN_ENV, decodeExactUtf8, sha256Digest, validatePinnedTrustedRoot } from './image-evidence.mjs';
|
|
9
9
|
import { splitImageRef } from './registry-credentials.mjs';
|
|
10
10
|
import { BUNDLE_MEDIA_TYPE } from './sigstore-bundle-media-type.mjs';
|
|
@@ -119,29 +119,85 @@ export async function fetchBundleProvenance({ clientFor, ociRef }) {
|
|
|
119
119
|
return found[0];
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
|
|
123
|
-
|
|
124
122
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
|
|
128
|
-
* with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
|
|
123
|
+
* The `<repository>@sha256:<hex>` a bundle reference names: a digest reference as it is, a tag reference resolved at
|
|
124
|
+
* the registry (the manifest the tag serves, its bytes verified against the digest the registry described).
|
|
129
125
|
*/
|
|
130
|
-
export function
|
|
126
|
+
export async function bundleDigestRef({ clientFor, ociRef }) {
|
|
127
|
+
if (ociRef.includes('@')) {return ociRef;}
|
|
128
|
+
const colon = ociRef.lastIndexOf(':');
|
|
129
|
+
if (colon <= ociRef.lastIndexOf('/')) {
|
|
130
|
+
throw new Error(`bundle reference ${JSON.stringify(ociRef)} names neither a tag nor a digest`);
|
|
131
|
+
}
|
|
132
|
+
const name = ociRef.slice(0, colon);
|
|
133
|
+
const { authority, repository } = splitImageRef(name);
|
|
134
|
+
const { digest } = await clientFor(authority).getManifestByTag(repository, ociRef.slice(colon + 1));
|
|
135
|
+
return `${name}@${digest}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tail = (error) => String(error?.stderr ?? error?.message ?? error).trim().split('\n').slice(-4).join(' | ').slice(0, 1024);
|
|
139
|
+
|
|
140
|
+
/** The cosign run of a registry verification: the trusted binary, the operator's registry credential (HOME / DOCKER_CONFIG). */
|
|
141
|
+
function registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env }) {
|
|
131
142
|
const root = validatePinnedTrustedRoot(policyRoot, policy);
|
|
132
|
-
const common = ['--offline', '--trusted-root', root.path, ...certificateConstraintArgs(row, workflowSha)];
|
|
133
143
|
const environment = {
|
|
134
144
|
...COSIGN_ENV,
|
|
135
145
|
...(env.HOME === undefined ? {} : { HOME: env.HOME }),
|
|
136
146
|
...(env.DOCKER_CONFIG === undefined ? {} : { DOCKER_CONFIG: env.DOCKER_CONFIG }),
|
|
137
147
|
};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
return {
|
|
149
|
+
trustArgs: ['--offline', '--trusted-root', root.path],
|
|
150
|
+
run(command, args) {
|
|
151
|
+
try {
|
|
152
|
+
return execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], {
|
|
153
|
+
env: environment,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
156
|
+
timeout: REGISTRY_COSIGN_TIMEOUT_MS,
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
144
162
|
};
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
|
|
167
|
+
* artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
|
|
168
|
+
* commit, the pinned trusted root and the trusted cosign binary. `--offline` verifies the tlog inclusion proof carried
|
|
169
|
+
* with the signature; the tlog is never ignored. The registry credential is the operator's (HOME / DOCKER_CONFIG).
|
|
170
|
+
*/
|
|
171
|
+
export function verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
|
|
172
|
+
const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
|
|
173
|
+
const common = [...cosign.trustArgs, ...certificateConstraintArgs(row, workflowSha)];
|
|
174
|
+
cosign.run('verify', [...common, ociRef]);
|
|
175
|
+
cosign.run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const WORKFLOW_SHA = /^[0-9a-f]{40}$/u;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The commit the row's workflow signed the bundle artifact at, as cosign states it: `cosign verify` under the row's
|
|
182
|
+
* identity, issuer, workflow repository and ref (no commit: it is what is asked), and the `githubWorkflowSha` claim of
|
|
183
|
+
* every signature cosign verified. Exactly one commit is required; none, or two, is refused. The caller then holds the
|
|
184
|
+
* signature AND the provenance to that commit with `verifyBundleOnRegistry`.
|
|
185
|
+
*/
|
|
186
|
+
export function verifiedBundleSignerCommit({ trustedCosign, policy, policyRoot, row, ociRef, env = process.env }) {
|
|
187
|
+
const cosign = registryCosign({ trustedCosign, policy, policyRoot, row, ociRef, env });
|
|
188
|
+
const output = cosign.run('verify', [...cosign.trustArgs, ...certificateIdentityArgs(row), '--output', 'json', ociRef]);
|
|
189
|
+
let verified;
|
|
190
|
+
try {
|
|
191
|
+
verified = JSON.parse(output);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(`cosign verify of ${ociRef} under row "${row.policyId}" printed no JSON list of verified signatures`);
|
|
194
|
+
}
|
|
195
|
+
const commits = [...new Set((Array.isArray(verified) ? verified : []).map((signature) => signature?.optional?.githubWorkflowSha))];
|
|
196
|
+
if (commits.length !== 1 || !WORKFLOW_SHA.test(String(commits[0]))) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${ociRef} carries signatures by row "${row.policyId}" (${row.certificateIdentity}) stating ${commits.length} workflow commits ` +
|
|
199
|
+
`(${commits.map(String).join(', ')}); exactly one 40-hex commit is required`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return commits[0];
|
|
147
203
|
}
|