@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.
Files changed (46) hide show
  1. package/dist/edition/verify-image-evidence.bundle.mjs +1996 -1012
  2. package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
  3. package/dist/runtime/lock-loader.d.ts +2 -1
  4. package/dist/runtime/lock-loader.d.ts.map +1 -1
  5. package/dist/runtime/lock-loader.js +15 -5
  6. package/dist/runtime/lock-loader.js.map +1 -1
  7. package/edition/README.md +8 -3
  8. package/edition/biome-content.mjs +4 -4
  9. package/edition/candidate-edition.mjs +8 -3
  10. package/edition/check-committed-image-evidence.mjs +44 -15
  11. package/edition/customer-edition-verbs.mjs +15 -4
  12. package/edition/develop-current-resolution.mjs +29 -0
  13. package/edition/develop-edition-resolve.mjs +18 -10
  14. package/edition/develop-edition.mjs +30 -9
  15. package/edition/develop-freeze.mjs +24 -1
  16. package/edition/develop-git-bundles.mjs +201 -0
  17. package/edition/develop-graph.mjs +5 -3
  18. package/edition/develop-image-probe.mjs +6 -4
  19. package/edition/develop-image-record.mjs +194 -0
  20. package/edition/develop-record-ancestry.mjs +23 -5
  21. package/edition/develop-select-records.mjs +29 -21
  22. package/edition/develop-selection-line.mjs +30 -0
  23. package/edition/develop-selection-pins.mjs +30 -0
  24. package/edition/dist-check.mjs +45 -9
  25. package/edition/distribution-evidence-set.mjs +14 -9
  26. package/edition/edition-image-seal.mjs +2 -1
  27. package/edition/edition-protocol.mjs +6 -20
  28. package/edition/external-source-walk.mjs +37 -15
  29. package/edition/image-evidence.mjs +6 -2
  30. package/edition/image-lock-service-rows.mjs +4 -4
  31. package/edition/image-subject-identity.mjs +24 -0
  32. package/edition/images-from-record.mjs +46 -110
  33. package/edition/lib.mjs +24 -26
  34. package/edition/locked-bundle-signer.mjs +52 -8
  35. package/edition/resolve-customer-edition.mjs +1 -1
  36. package/edition/resolve-distribution.mjs +84 -21
  37. package/edition/resolve-edition.mjs +2 -1
  38. package/edition/resolve-images.mjs +224 -12
  39. package/edition/seal-image-digests.mjs +24 -13
  40. package/edition/third-party-bundle-registry.mjs +73 -17
  41. package/edition/third-party-source-evidence.mjs +39 -14
  42. package/edition/third-party-source-writer.mjs +2 -18
  43. package/edition/trust-policy-arming.mjs +17 -6
  44. package/edition/verify-image-platforms.mjs +2 -0
  45. package/edition/workspace-sections.mjs +11 -1
  46. 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 { writeFileSync } from 'node:fs';
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
- 'usage: resolve-images.mjs <distribution-id> --trusted-write-v3',
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) => [`${entry.subjectKind}:${entry.subjectId}`, 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 = `${subject.kind}:${subject.id}`;
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) => `${subject.kind}:${subject.id}`);
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
- /** The shared in-memory half of the seal: reads both locks, applies `digests`, seals or verifies. Writes nothing. */
489
- async function prepareEditionSeal({ distributionId, root, digests = new Map(), check = false, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, fetchManifest = fetchRegistryManifest, checkAdapters = true }) {
490
- const schemas = await loadKernelDistributionSchemas();
491
- const lockPath = join(root, distributionId, 'distribution.lock.json');
492
- const imagesPath = join(root, distributionId, 'images.lock.json');
493
- // The file is re-read rather than the PARSED lock re-serialised: the parse is a
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(`${subject.kind}:${subject.id}`);
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
- * (a) against the registry: keyless `cosign verify` and `cosign verify-attestation --type slsaprovenance1` of the bundle
126
- * artifact under EXACTLY the signer row's identity and issuer, the row's workflow repository, ref and the stated
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 verifyBundleOnRegistry({ trustedCosign, policy, policyRoot, row, workflowSha, ociRef, env = process.env }) {
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
- const run = (command, args) => {
139
- try {
140
- execFileSync(assertPreparedExecutable(trustedCosign, 'cosign'), [command, ...args], { env: environment, stdio: ['ignore', 'ignore', 'pipe'], timeout: REGISTRY_COSIGN_TIMEOUT_MS });
141
- } catch (error) {
142
- throw new Error(`cosign ${command} refused ${ociRef} under row "${row.policyId}": ${tail(error)}`);
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
- run('verify', [...common, ociRef]);
146
- run('verify-attestation', ['--type', COSIGN_SLSA_PROVENANCE_ALIAS, ...common, ociRef]);
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
  }